Merge pull request #2398 from 7RPH/fix/responses-sse-framing
fix: preserve SSE event boundaries for Responses streams
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -21,6 +22,25 @@ import (
|
|||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func writeResponsesSSEChunk(w io.Writer, chunk []byte) {
|
||||||
|
if w == nil || len(chunk) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := w.Write(chunk); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bytes.HasSuffix(chunk, []byte("\n\n")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suffix := []byte("\n\n")
|
||||||
|
if bytes.HasSuffix(chunk, []byte("\n")) {
|
||||||
|
suffix = []byte("\n")
|
||||||
|
}
|
||||||
|
if _, err := w.Write(suffix); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAIResponsesAPIHandler contains the handlers for OpenAIResponses API endpoints.
|
// OpenAIResponsesAPIHandler contains the handlers for OpenAIResponses API endpoints.
|
||||||
// It holds a pool of clients to interact with the backend service.
|
// It holds a pool of clients to interact with the backend service.
|
||||||
type OpenAIResponsesAPIHandler struct {
|
type OpenAIResponsesAPIHandler struct {
|
||||||
@@ -230,11 +250,7 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ
|
|||||||
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
|
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
|
||||||
|
|
||||||
// Write first chunk logic (matching forwardResponsesStream)
|
// Write first chunk logic (matching forwardResponsesStream)
|
||||||
if bytes.HasPrefix(chunk, []byte("event:")) {
|
writeResponsesSSEChunk(c.Writer, chunk)
|
||||||
_, _ = c.Writer.Write([]byte("\n"))
|
|
||||||
}
|
|
||||||
_, _ = c.Writer.Write(chunk)
|
|
||||||
_, _ = c.Writer.Write([]byte("\n"))
|
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
// Continue
|
// Continue
|
||||||
@@ -247,11 +263,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) {
|
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{
|
h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{
|
||||||
WriteChunk: func(chunk []byte) {
|
WriteChunk: func(chunk []byte) {
|
||||||
if bytes.HasPrefix(chunk, []byte("event:")) {
|
writeResponsesSSEChunk(c.Writer, chunk)
|
||||||
_, _ = c.Writer.Write([]byte("\n"))
|
|
||||||
}
|
|
||||||
_, _ = c.Writer.Write(chunk)
|
|
||||||
_, _ = c.Writer.Write([]byte("\n"))
|
|
||||||
},
|
},
|
||||||
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
|
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
|
||||||
if errMsg == nil {
|
if errMsg == nil {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user