From 4b67a1928abdd5289df28405f28ca861d092dad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Arne=20Vestb=C3=B8?= Date: Wed, 1 Apr 2026 01:06:25 +0200 Subject: [PATCH 1/2] Fix SSE streaming by preserving event: lines in proxy The proxy was stripping SSE event: lines and only forwarding data: lines. This caused Claude Code to log "Stream completed without receiving message_start event" and fall back to non-streaming mode, resulting in a duplicate request for every streamed request. Forward the complete SSE protocol (event: lines, data: lines, and blank separators) and add event: prefixes to the OpenAI-to-Anthropic stream transformer. --- proxy/internal/handler/handlers.go | 17 ++++++++++++----- proxy/internal/provider/openai.go | 14 +++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go index 05d1360b7..721c98d5d 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -282,15 +282,22 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() - if line == "" || !strings.HasPrefix(line, "data:") { + + // Forward every line to the client as-is, preserving the full SSE + // protocol (event: lines, data: lines, and blank separators). + fmt.Fprintf(w, "%s\n", line) + if line == "" { + // Blank line = SSE event boundary; flush to client. + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + if !strings.HasPrefix(line, "data:") { continue } streamingChunks = append(streamingChunks, line) - fmt.Fprintf(w, "%s\n\n", line) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } jsonData := strings.TrimPrefix(line, "data: ") diff --git a/proxy/internal/provider/openai.go b/proxy/internal/provider/openai.go index 5973026fb..34f552466 100644 --- a/proxy/internal/provider/openai.go +++ b/proxy/internal/provider/openai.go @@ -602,11 +602,11 @@ func transformOpenAIStreamToAnthropic(openAIStream io.ReadCloser, anthropicStrea if data == "[DONE]" { // Send Anthropic-style completion if contentStarted { - fmt.Fprintf(anthropicStream, "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n") + fmt.Fprintf(anthropicStream, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n") } if messageStarted { - fmt.Fprintf(anthropicStream, "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null}}\n\n") - fmt.Fprintf(anthropicStream, "data: {\"type\":\"message_stop\"}\n\n") + fmt.Fprintf(anthropicStream, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null}}\n\n") + fmt.Fprintf(anthropicStream, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") } break } @@ -644,7 +644,7 @@ func transformOpenAIStreamToAnthropic(openAIStream io.ReadCloser, anthropicStrea "usage": anthropicUsage, } usageJSON, _ := json.Marshal(usageDelta) - fmt.Fprintf(anthropicStream, "data: %s\n\n", usageJSON) + fmt.Fprintf(anthropicStream, "event: message_delta\ndata: %s\n\n", usageJSON) } } @@ -684,7 +684,7 @@ func transformOpenAIStreamToAnthropic(openAIStream io.ReadCloser, anthropicStrea }, } startJSON, _ := json.Marshal(messageStart) - fmt.Fprintf(anthropicStream, "data: %s\n\n", startJSON) + fmt.Fprintf(anthropicStream, "event: message_start\ndata: %s\n\n", startJSON) } // Handle content @@ -701,7 +701,7 @@ func transformOpenAIStreamToAnthropic(openAIStream io.ReadCloser, anthropicStrea }, } blockStartJSON, _ := json.Marshal(blockStart) - fmt.Fprintf(anthropicStream, "data: %s\n\n", blockStartJSON) + fmt.Fprintf(anthropicStream, "event: content_block_start\ndata: %s\n\n", blockStartJSON) } // Send content_block_delta @@ -714,7 +714,7 @@ func transformOpenAIStreamToAnthropic(openAIStream io.ReadCloser, anthropicStrea }, } deltaJSON, _ := json.Marshal(contentDelta) - fmt.Fprintf(anthropicStream, "data: %s\n\n", deltaJSON) + fmt.Fprintf(anthropicStream, "event: content_block_delta\ndata: %s\n\n", deltaJSON) } } From 8e362677b0e946f38271cabac4caf53a03f04ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Arne=20Vestb=C3=B8?= Date: Wed, 1 Apr 2026 01:28:28 +0200 Subject: [PATCH 2/2] Include SSE event: lines in raw streaming data display Store all SSE lines (not just data: lines) in streamingChunks and join them with newlines in the UI so the raw streaming display shows the actual SSE protocol structure. --- proxy/internal/handler/handlers.go | 6 ++++-- web/app/components/RequestDetailContent.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go index 721c98d5d..d9eede8d3 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -293,12 +293,14 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp } } + if line != "" { + streamingChunks = append(streamingChunks, line) + } + if !strings.HasPrefix(line, "data:") { continue } - streamingChunks = append(streamingChunks, line) - jsonData := strings.TrimPrefix(line, "data: ") // Parse as generic JSON first to capture usage data diff --git a/web/app/components/RequestDetailContent.tsx b/web/app/components/RequestDetailContent.tsx index 6b291c168..40aa4847d 100644 --- a/web/app/components/RequestDetailContent.tsx +++ b/web/app/components/RequestDetailContent.tsx @@ -513,7 +513,7 @@ function ResponseDetails({ response }: { response: NonNullable { let assembledText = ''; - let rawData = chunks.join(''); + let rawData = chunks.join('\n'); try { // Split by lines and process each SSE event