From 134a9eac9da0496b3eae57783053df36e1f33118 Mon Sep 17 00:00:00 2001 From: trph <894304504@qq.com> Date: Sun, 29 Mar 2026 17:23:16 +0800 Subject: [PATCH 1/4] fix: preserve SSE event boundaries for Responses streams --- .../openai/openai_responses_handlers.go | 28 +++++++++------ ...ai_responses_handlers_stream_error_test.go | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 3bca75f9..8e3fee33 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -10,6 +10,7 @@ import ( "bytes" "context" "fmt" + "io" "net/http" "github.com/gin-gonic/gin" @@ -21,6 +22,21 @@ import ( "github.com/tidwall/sjson" ) +func writeResponsesSSEChunk(w io.Writer, chunk []byte) { + if w == nil || len(chunk) == 0 { + return + } + _, _ = w.Write(chunk) + switch { + case bytes.HasSuffix(chunk, []byte("\n\n")): + return + case bytes.HasSuffix(chunk, []byte("\n")): + _, _ = w.Write([]byte("\n")) + default: + _, _ = w.Write([]byte("\n\n")) + } +} + // OpenAIResponsesAPIHandler contains the handlers for OpenAIResponses API endpoints. // It holds a pool of clients to interact with the backend service. type OpenAIResponsesAPIHandler struct { @@ -230,11 +246,7 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) // Write first chunk logic (matching forwardResponsesStream) - if bytes.HasPrefix(chunk, []byte("event:")) { - _, _ = c.Writer.Write([]byte("\n")) - } - _, _ = c.Writer.Write(chunk) - _, _ = c.Writer.Write([]byte("\n")) + writeResponsesSSEChunk(c.Writer, chunk) flusher.Flush() // Continue @@ -247,11 +259,7 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ func (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) { h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{ WriteChunk: func(chunk []byte) { - if bytes.HasPrefix(chunk, []byte("event:")) { - _, _ = c.Writer.Write([]byte("\n")) - } - _, _ = c.Writer.Write(chunk) - _, _ = c.Writer.Write([]byte("\n")) + writeResponsesSSEChunk(c.Writer, chunk) }, WriteTerminalError: func(errMsg *interfaces.ErrorMessage) { if errMsg == nil { diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go index dce73807..e1e6e7aa 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go @@ -41,3 +41,38 @@ func TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T t.Fatalf("expected streaming error chunk (top-level type), got HTTP error body: %q", body) } } + +func TestForwardResponsesStreamSeparatesDataOnlySSEChunks(t *testing.T) { + gin.SetMode(gin.TestMode) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + h := NewOpenAIResponsesAPIHandler(base) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + t.Fatalf("expected gin writer to implement http.Flusher") + } + + data := make(chan []byte, 2) + errs := make(chan *interfaces.ErrorMessage) + data <- []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\"}}") + data <- []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}") + close(data) + close(errs) + + h.forwardResponsesStream(c, flusher, func(error) {}, data, errs) + body := recorder.Body.String() + + if !strings.Contains(body, "data: {\"type\":\"response.output_item.done\"") { + t.Fatalf("expected first SSE data chunk, got: %q", body) + } + if !strings.Contains(body, "\n\ndata: {\"type\":\"response.completed\"") { + t.Fatalf("expected blank-line separation before second SSE event, got: %q", body) + } + if strings.Contains(body, "arguments\":\"{}\"}}data: {\"type\":\"response.completed\"") { + t.Fatalf("second SSE event was concatenated onto first event body: %q", body) + } +} From c03883ccf0f7b2539a669a09549789bf642f6b0d Mon Sep 17 00:00:00 2001 From: trph <894304504@qq.com> Date: Sun, 29 Mar 2026 22:00:46 +0800 Subject: [PATCH 2/4] fix: address responses SSE review feedback --- .../openai/openai_responses_handlers.go | 12 +++-- ...ai_responses_handlers_stream_error_test.go | 35 -------------- .../openai_responses_handlers_stream_test.go | 48 +++++++++++++++++++ 3 files changed, 55 insertions(+), 40 deletions(-) create mode 100644 sdk/api/handlers/openai/openai_responses_handlers_stream_test.go diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 8e3fee33..4fb00af6 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -26,13 +26,15 @@ func writeResponsesSSEChunk(w io.Writer, chunk []byte) { if w == nil || len(chunk) == 0 { return } - _, _ = w.Write(chunk) - switch { - case bytes.HasSuffix(chunk, []byte("\n\n")): + if _, err := w.Write(chunk); err != nil { return - case bytes.HasSuffix(chunk, []byte("\n")): + } + if bytes.HasSuffix(chunk, []byte("\n\n")) { + return + } + if bytes.HasSuffix(chunk, []byte("\n")) { _, _ = w.Write([]byte("\n")) - default: + } else { _, _ = w.Write([]byte("\n\n")) } } diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go index e1e6e7aa..dce73807 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go @@ -41,38 +41,3 @@ func TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T t.Fatalf("expected streaming error chunk (top-level type), got HTTP error body: %q", body) } } - -func TestForwardResponsesStreamSeparatesDataOnlySSEChunks(t *testing.T) { - gin.SetMode(gin.TestMode) - base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) - h := NewOpenAIResponsesAPIHandler(base) - - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - t.Fatalf("expected gin writer to implement http.Flusher") - } - - data := make(chan []byte, 2) - errs := make(chan *interfaces.ErrorMessage) - data <- []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\"}}") - data <- []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}") - close(data) - close(errs) - - h.forwardResponsesStream(c, flusher, func(error) {}, data, errs) - body := recorder.Body.String() - - if !strings.Contains(body, "data: {\"type\":\"response.output_item.done\"") { - t.Fatalf("expected first SSE data chunk, got: %q", body) - } - if !strings.Contains(body, "\n\ndata: {\"type\":\"response.completed\"") { - t.Fatalf("expected blank-line separation before second SSE event, got: %q", body) - } - if strings.Contains(body, "arguments\":\"{}\"}}data: {\"type\":\"response.completed\"") { - t.Fatalf("second SSE event was concatenated onto first event body: %q", body) - } -} diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go new file mode 100644 index 00000000..8fa908bb --- /dev/null +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go @@ -0,0 +1,48 @@ +package openai + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestForwardResponsesStreamSeparatesDataOnlySSEChunks(t *testing.T) { + gin.SetMode(gin.TestMode) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + h := NewOpenAIResponsesAPIHandler(base) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + t.Fatalf("expected gin writer to implement http.Flusher") + } + + data := make(chan []byte, 2) + errs := make(chan *interfaces.ErrorMessage) + data <- []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\"}}") + data <- []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}") + close(data) + close(errs) + + h.forwardResponsesStream(c, flusher, func(error) {}, data, errs) + body := recorder.Body.String() + + if !strings.Contains(body, "data: {\"type\":\"response.output_item.done\"") { + t.Fatalf("expected first SSE data chunk, got: %q", body) + } + if !strings.Contains(body, "\n\ndata: {\"type\":\"response.completed\"") { + t.Fatalf("expected blank-line separation before second SSE event, got: %q", body) + } + if strings.Contains(body, "arguments\":\"{}\"}}data: {\"type\":\"response.completed\"") { + t.Fatalf("second SSE event was concatenated onto first event body: %q", body) + } +} From 0fcc02fbea046c06ea91c5418950f11187cc19dd Mon Sep 17 00:00:00 2001 From: trph <894304504@qq.com> Date: Sun, 29 Mar 2026 22:10:28 +0800 Subject: [PATCH 3/4] fix: tighten responses SSE review follow-up --- .../openai/openai_responses_handlers.go | 8 ++++++-- .../openai_responses_handlers_stream_test.go | 18 +++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 4fb00af6..9d722162 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -33,9 +33,13 @@ func writeResponsesSSEChunk(w io.Writer, chunk []byte) { return } if bytes.HasSuffix(chunk, []byte("\n")) { - _, _ = w.Write([]byte("\n")) + if _, err := w.Write([]byte("\n")); err != nil { + return + } } else { - _, _ = w.Write([]byte("\n\n")) + if _, err := w.Write([]byte("\n\n")); err != nil { + return + } } } diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go index 8fa908bb..185a455a 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go @@ -35,14 +35,18 @@ func TestForwardResponsesStreamSeparatesDataOnlySSEChunks(t *testing.T) { h.forwardResponsesStream(c, flusher, func(error) {}, data, errs) body := recorder.Body.String() + parts := strings.Split(strings.TrimSpace(body), "\n\n") + if len(parts) != 2 { + t.Fatalf("expected 2 SSE events, got %d. Body: %q", len(parts), body) + } - if !strings.Contains(body, "data: {\"type\":\"response.output_item.done\"") { - t.Fatalf("expected first SSE data chunk, got: %q", body) + expectedPart1 := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\"}}" + if parts[0] != expectedPart1 { + t.Errorf("unexpected first event.\nGot: %q\nWant: %q", parts[0], expectedPart1) } - if !strings.Contains(body, "\n\ndata: {\"type\":\"response.completed\"") { - t.Fatalf("expected blank-line separation before second SSE event, got: %q", body) - } - if strings.Contains(body, "arguments\":\"{}\"}}data: {\"type\":\"response.completed\"") { - t.Fatalf("second SSE event was concatenated onto first event body: %q", body) + + expectedPart2 := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}" + if parts[1] != expectedPart2 { + t.Errorf("unexpected second event.\nGot: %q\nWant: %q", parts[1], expectedPart2) } } From f73d55ddaadde6b5f450c240403214a0233640bb Mon Sep 17 00:00:00 2001 From: trph <894304504@qq.com> Date: Sun, 29 Mar 2026 22:19:25 +0800 Subject: [PATCH 4/4] fix: simplify responses SSE suffix handling --- sdk/api/handlers/openai/openai_responses_handlers.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 9d722162..d1ba68c7 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -32,14 +32,12 @@ func writeResponsesSSEChunk(w io.Writer, chunk []byte) { if bytes.HasSuffix(chunk, []byte("\n\n")) { return } + suffix := []byte("\n\n") if bytes.HasSuffix(chunk, []byte("\n")) { - if _, err := w.Write([]byte("\n")); err != nil { - return - } - } else { - if _, err := w.Write([]byte("\n\n")); err != nil { - return - } + suffix = []byte("\n") + } + if _, err := w.Write(suffix); err != nil { + return } }