Skip to content

Commit 5bcc701

Browse files
fix(openai): stream compact responses to Codex clients
1 parent e2c731a commit 5bcc701

7 files changed

Lines changed: 166 additions & 0 deletions

backend/internal/handler/openai_gateway_compact_body_signal_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ func TestNormalizeOpenAIResponsesCompactRequest_BodySignalPromoted(t *testing.T)
6060
seed, exists := c.Get(service.OpenAICompactSessionSeedKeyForTest())
6161
require.True(t, exists)
6262
require.Equal(t, "pck-signal-1", seed)
63+
clientStream, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
64+
require.True(t, exists)
65+
require.True(t, clientStream.(bool))
6366
}
6467

6568
func TestNormalizeOpenAIResponsesCompactRequest_BodySignalTrailingSlash(t *testing.T) {

backend/internal/handler/openai_gateway_handler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,9 @@ func (h *OpenAIGatewayHandler) normalizeOpenAIResponsesCompactRequest(c *gin.Con
590590
if !isCompactRequest {
591591
return body, true
592592
}
593+
if streamResult := gjson.GetBytes(body, "stream"); streamResult.Type == gjson.True {
594+
service.SetOpenAICompactClientStreamRequested(c, true)
595+
}
593596
if compactSeed := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()); compactSeed != "" {
594597
c.Set(service.OpenAICompactSessionSeedKeyForTest(), compactSeed)
595598
}

backend/internal/service/openai_compact_model_mapping_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,55 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactOnlyModelMappingOverridesU
133133
require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String())
134134
require.Equal(t, "gpt-5.4", gjson.GetBytes(rec.Body.Bytes(), "model").String())
135135
}
136+
137+
func TestOpenAIGatewayService_OAuthPassthrough_CompactClientStreamDoesNotWrapJSONAsSSE(t *testing.T) {
138+
gin.SetMode(gin.TestMode)
139+
140+
rec := httptest.NewRecorder()
141+
c, _ := gin.CreateTestContext(rec)
142+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil))
143+
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
144+
c.Request.Header.Set("Content-Type", "application/json")
145+
SetOpenAICompactClientStreamRequested(c, true)
146+
147+
originalBody := []byte(`{"model":"gpt-5.4","stream":true,"store":true,"instructions":"compact-pass","input":[{"type":"text","text":"compact me"}]}`)
148+
upstream := &httpUpstreamRecorder{resp: &http.Response{
149+
StatusCode: http.StatusOK,
150+
Header: http.Header{"Content-Type": []string{"text/plain"}, "x-request-id": []string{"rid-compact-pass-stream"}},
151+
Body: io.NopCloser(strings.NewReader(`{
152+
"id": "cmp_125",
153+
"model": "gpt-5.4",
154+
"status": "completed",
155+
"output": [
156+
{"type": "compaction_summary", "content": "ok"}
157+
],
158+
"usage": {"input_tokens": 2, "output_tokens": 3}
159+
}`)),
160+
}}
161+
162+
svc := &OpenAIGatewayService{httpUpstream: upstream}
163+
account := &Account{
164+
ID: 4,
165+
Name: "openai-oauth-pass",
166+
Platform: PlatformOpenAI,
167+
Type: AccountTypeOAuth,
168+
Concurrency: 1,
169+
Credentials: map[string]any{
170+
"access_token": "oauth-token",
171+
"chatgpt_account_id": "chatgpt-acc",
172+
},
173+
Extra: map[string]any{"openai_passthrough": true},
174+
Status: StatusActive,
175+
Schedulable: true,
176+
}
177+
178+
result, err := svc.Forward(context.Background(), c, account, originalBody)
179+
require.NoError(t, err)
180+
require.NotNil(t, result)
181+
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept"))
182+
require.False(t, gjson.GetBytes(upstream.lastBody, "stream").Exists())
183+
require.NotContains(t, rec.Header().Get("Content-Type"), "text/event-stream")
184+
require.NotContains(t, rec.Body.String(), "event: response.completed")
185+
require.Equal(t, "cmp_125", gjson.Get(rec.Body.String(), "id").String())
186+
require.NotContains(t, rec.Body.String(), "data: [DONE]")
187+
}

backend/internal/service/openai_gateway_request_body.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,29 @@ func OpenAICompactSessionSeedKeyForTest() string {
155155
return openAICompactSessionSeedKey
156156
}
157157

158+
func OpenAICompactClientStreamKeyForTest() string {
159+
return openAICompactClientStreamKey
160+
}
161+
162+
func SetOpenAICompactClientStreamRequested(c *gin.Context, requested bool) {
163+
if c == nil {
164+
return
165+
}
166+
c.Set(openAICompactClientStreamKey, requested)
167+
}
168+
169+
func getOpenAICompactClientStreamRequested(c *gin.Context) bool {
170+
if c == nil {
171+
return false
172+
}
173+
value, ok := c.Get(openAICompactClientStreamKey)
174+
if !ok {
175+
return false
176+
}
177+
requested, _ := value.(bool)
178+
return requested
179+
}
180+
158181
func NormalizeOpenAICompactRequestBodyForTest(body []byte) ([]byte, bool, error) {
159182
return normalizeOpenAICompactRequestBody(body)
160183
}

backend/internal/service/openai_gateway_response_handling.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,16 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
817817
}
818818

819819
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
820+
if shouldWriteOpenAICompactClientStream(c, body) {
821+
writeOpenAICompactCompletedSSE(c, resp.StatusCode, body)
822+
return &openaiNonStreamingResult{
823+
OpenAIUsage: usage,
824+
usage: usage,
825+
responseID: extractOpenAIResponseIDFromJSONBytes(body),
826+
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
827+
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
828+
}, nil
829+
}
820830

821831
contentType := "application/json"
822832
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
@@ -836,6 +846,40 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
836846
}, nil
837847
}
838848

849+
func shouldWriteOpenAICompactClientStream(c *gin.Context, body []byte) bool {
850+
return isOpenAIResponsesCompactPath(c) && getOpenAICompactClientStreamRequested(c) && gjson.ValidBytes(body)
851+
}
852+
853+
func writeOpenAICompactCompletedSSE(c *gin.Context, statusCode int, responseBody []byte) {
854+
if statusCode == 0 {
855+
statusCode = http.StatusOK
856+
}
857+
responseBody = compactOpenAIJSONForSSE(responseBody)
858+
c.Writer.Header().Set("Content-Type", "text/event-stream")
859+
c.Writer.Header().Set("Cache-Control", "no-cache")
860+
c.Writer.Header().Set("X-Accel-Buffering", "no")
861+
c.Status(statusCode)
862+
_, _ = c.Writer.Write([]byte("event: response.completed\n"))
863+
_, _ = c.Writer.Write([]byte(`data: {"type":"response.completed","sequence_number":1,"response":`))
864+
_, _ = c.Writer.Write(responseBody)
865+
_, _ = c.Writer.Write([]byte("}\n\n"))
866+
if flusher, ok := c.Writer.(http.Flusher); ok {
867+
flusher.Flush()
868+
}
869+
}
870+
871+
func compactOpenAIJSONForSSE(body []byte) []byte {
872+
body = bytes.TrimSpace(body)
873+
if len(body) == 0 {
874+
return body
875+
}
876+
var compacted bytes.Buffer
877+
if err := json.Compact(&compacted, body); err != nil {
878+
return body
879+
}
880+
return compacted.Bytes()
881+
}
882+
839883
func isEventStreamResponse(header http.Header) bool {
840884
contentType := strings.ToLower(header.Get("Content-Type"))
841885
return strings.Contains(contentType, "text/event-stream")
@@ -899,6 +943,16 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
899943
}
900944

901945
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
946+
if ok && shouldWriteOpenAICompactClientStream(c, body) {
947+
writeOpenAICompactCompletedSSE(c, resp.StatusCode, body)
948+
return &openaiNonStreamingResult{
949+
OpenAIUsage: usage,
950+
usage: usage,
951+
responseID: extractOpenAIResponseIDFromJSONBytes(body),
952+
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
953+
imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
954+
}, nil
955+
}
902956

903957
contentType := "application/json; charset=utf-8"
904958
if !ok {

backend/internal/service/openai_gateway_service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const (
4949
openAIWSRetryBackoffMaxDefault = 2 * time.Second
5050
openAIWSRetryJitterRatioDefault = 0.2
5151
openAICompactSessionSeedKey = "openai_compact_session_seed"
52+
openAICompactClientStreamKey = "openai_compact_client_stream_requested"
5253
codexCLIVersion = "0.125.0"
5354
// Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。
5455
openAICodexSnapshotPersistMinInterval = 30 * time.Second

backend/internal/service/openai_gateway_service_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2856,6 +2856,36 @@ func TestHandleSSEToJSON_CompletedEventReturnsJSON(t *testing.T) {
28562856
require.NotContains(t, rec.Body.String(), "data:")
28572857
}
28582858

2859+
func TestHandleSSEToJSON_CompactClientStreamCompletedEventReturnsSSE(t *testing.T) {
2860+
gin.SetMode(gin.TestMode)
2861+
rec := httptest.NewRecorder()
2862+
c, _ := gin.CreateTestContext(rec)
2863+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
2864+
SetOpenAICompactClientStreamRequested(c, true)
2865+
2866+
svc := &OpenAIGatewayService{cfg: &config.Config{}}
2867+
resp := &http.Response{
2868+
StatusCode: http.StatusOK,
2869+
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
2870+
}
2871+
body := []byte(strings.Join([]string{
2872+
`data: {"type":"response.in_progress","response":{"id":"resp_compact_sse"}}`,
2873+
`data: {"type":"response.completed","response":{"id":"resp_compact_sse","model":"gpt-5.4","usage":{"input_tokens":7,"output_tokens":9,"input_tokens_details":{"cached_tokens":1}}}}`,
2874+
`data: [DONE]`,
2875+
}, "\n"))
2876+
2877+
usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-5.4", "gpt-5.4")
2878+
require.NoError(t, err)
2879+
require.NotNil(t, usage)
2880+
require.Equal(t, 7, usage.InputTokens)
2881+
require.Equal(t, 9, usage.OutputTokens)
2882+
require.Contains(t, rec.Header().Get("Content-Type"), "text/event-stream")
2883+
require.Contains(t, rec.Body.String(), "event: response.completed")
2884+
require.Contains(t, rec.Body.String(), `"type":"response.completed"`)
2885+
require.Contains(t, rec.Body.String(), `"id":"resp_compact_sse"`)
2886+
require.NotContains(t, rec.Body.String(), "data: [DONE]")
2887+
}
2888+
28592889
func TestHandleNonStreamingResponse_APIKeyFallsBackToSSEBodyWhenContentTypeIsWrong(t *testing.T) {
28602890
gin.SetMode(gin.TestMode)
28612891
rec := httptest.NewRecorder()

0 commit comments

Comments
 (0)