Skip to content

Commit c80216a

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

7 files changed

Lines changed: 145 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: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,64 @@ 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_CompactClientStreamWrapsJSONAsSSE(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.Contains(t, rec.Header().Get("Content-Type"), "text/event-stream")
184+
require.Empty(t, rec.Header().Get("Connection"))
185+
for _, line := range strings.Split(rec.Body.String(), "\n") {
186+
if line == "" {
187+
continue
188+
}
189+
require.Truef(t, strings.HasPrefix(line, "event:") || strings.HasPrefix(line, "data:"), "malformed SSE line: %q", line)
190+
}
191+
require.Contains(t, rec.Body.String(), "event: response.completed")
192+
require.Contains(t, rec.Body.String(), `"type":"response.completed"`)
193+
require.Contains(t, rec.Body.String(), `"sequence_number":1`)
194+
require.Contains(t, rec.Body.String(), `"id":"cmp_125"`)
195+
require.NotContains(t, rec.Body.String(), "data: [DONE]")
196+
}

backend/internal/service/openai_gateway_passthrough.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,16 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
10881088
if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
10891089
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
10901090
}
1091+
if shouldWriteOpenAICompactClientStream(c, body) {
1092+
writeOpenAICompactCompletedSSE(c, resp.StatusCode, body)
1093+
return &openaiNonStreamingResultPassthrough{
1094+
OpenAIUsage: usage,
1095+
usage: usage,
1096+
responseID: extractOpenAIResponseIDFromJSONBytes(body),
1097+
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
1098+
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
1099+
}, nil
1100+
}
10911101
c.Data(resp.StatusCode, contentType, body)
10921102
return &openaiNonStreamingResultPassthrough{
10931103
OpenAIUsage: usage,

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: 44 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")

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

0 commit comments

Comments
 (0)