Skip to content

Commit 16c0613

Browse files
committed
fix(compact): body-signal 客户端流式请求的响应合成回 SSE
v0.1.147 的 body-signal 提升(#3804)把 Codex remote compact v2 的流式 /responses 请求改写为上游 unary /responses/compact(JSON),但把 JSON 文档原样写回给了按 Responses SSE 协议消费的客户端。Codex 只从 response.output_item.done 收集 item 且必须收到 response.completed, 收到 JSON 后报 "stream disconnected before completion: stream closed before response.completed" 无限重连,每次重连再白烧一次上游 compact 配额(#3875,v0.1.146 无提升逻辑故正常)。 修复保留提升的全部收益(requireCompact 调度过滤、compact 模型映射、 白名单归一化、上游 unary JSON),补上协议转换缺失的响应半程: - handler 在 body-signal 提升时记录原始 body 的 stream:true 意图 (MarkOpenAICompactClientStream);path-based 请求不标记,Codex v1 unary 协议与链式 sub2api 的 JSON 写回行为保持不变。 - 四个非流式写回点(handleNonStreamingResponse / handleSSEToJSON / handleNonStreamingResponsePassthrough / handlePassthroughSSEToJSON) 对已标记的 2xx JSON 响应经 writeOpenAICompactSSEBridge 合成最小 Responses SSE:每个 output[] item 一条 response.output_item.done (原始字段逐字节保留),最后一条 response.completed 携带完整 response 对象。 - 按 codex-rs 解析器硬约束兜底:先 json.Compact 防 pretty JSON 换行 破坏 SSE 帧;response.id 缺失时注入 resp_*(必填 string);usage 缺 input_tokens/output_tokens/total_tokens 整数字段时整体删除, 避免整条 completed 事件解析失败。 - 非 2xx 一律保持 JSON 原样,Codex 依赖 HTTP 状态码走重试链路。 新增 9 个 service 测试 + 4 个 handler 测试覆盖合成形态、id/usage 兜底、 path-based 不受影响、主链路/透传链路/上游 SSE 提取再合成三条端到端。 Fixes #3875 Refs #3777
1 parent 1a3cc2a commit 16c0613

6 files changed

Lines changed: 498 additions & 5 deletions

backend/internal/handler/openai_gateway_compact_body_signal_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,46 @@ func TestNormalizeOpenAIResponsesCompactRequest_SubpathNotPromoted(t *testing.T)
117117
require.Equal(t, "/v1/responses/resp_123/cancel", c.Request.URL.Path)
118118
require.Equal(t, body, normalized)
119119
}
120+
121+
// 回归 #3875:body-signal 原始请求 stream:true 时必须标记 client-stream,
122+
// 供响应写回阶段把上游 unary JSON 合成回 Codex remote compact v2 所需的 SSE。
123+
func TestNormalizeOpenAIResponsesCompactRequest_BodySignalStreamTrueMarksClientStream(t *testing.T) {
124+
h := &OpenAIGatewayHandler{}
125+
body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`)
126+
c := newCompactBodySignalTestContext(t, "/v1/responses", body)
127+
128+
_, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
129+
require.True(t, ok)
130+
131+
marked, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
132+
require.True(t, exists)
133+
require.Equal(t, true, marked)
134+
}
135+
136+
func TestNormalizeOpenAIResponsesCompactRequest_BodySignalStreamFalseNotMarked(t *testing.T) {
137+
h := &OpenAIGatewayHandler{}
138+
for name, body := range map[string][]byte{
139+
"stream_false": []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`),
140+
"stream_absent": []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`),
141+
} {
142+
c := newCompactBodySignalTestContext(t, "/v1/responses", body)
143+
_, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
144+
require.True(t, ok, name)
145+
require.Equal(t, "/v1/responses/compact", c.Request.URL.Path, name)
146+
_, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
147+
require.False(t, exists, "case %s 不应标记 client-stream", name)
148+
}
149+
}
150+
151+
// path-based compact(Codex v1 unary 协议)即使 body 带 stream:true 也不标记,
152+
// 保持 JSON 写回行为不变。
153+
func TestNormalizeOpenAIResponsesCompactRequest_PathBasedStreamTrueNotMarked(t *testing.T) {
154+
h := &OpenAIGatewayHandler{}
155+
body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
156+
c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body)
157+
158+
_, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
159+
require.True(t, ok)
160+
_, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
161+
require.False(t, exists)
162+
}

backend/internal/handler/openai_gateway_handler.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,14 @@ func (h *OpenAIGatewayHandler) normalizeOpenAIResponsesCompactRequest(c *gin.Con
585585
if !isCompactRequest && isBareOpenAIResponsesPath(c) && service.HasCompactionTriggerInInput(body) {
586586
c.Request.URL.Path = strings.TrimRight(c.Request.URL.Path, "/") + "/compact"
587587
isCompactRequest = true
588-
reqLog.Info("codex.remote_compact.detected_body_signal")
588+
// Codex remote compact v2 的原始请求是流式 /responses:白名单归一化会删除
589+
// stream 并让上游走 unary JSON,但客户端仍按 SSE 消费响应。记录原始
590+
// stream 意图,响应写回阶段据此把 JSON 合成回 SSE(#3875)。
591+
clientStream := gjson.GetBytes(body, "stream").Bool()
592+
if clientStream {
593+
service.MarkOpenAICompactClientStream(c)
594+
}
595+
reqLog.Info("codex.remote_compact.detected_body_signal", zap.Bool("client_stream", clientStream))
589596
}
590597
if !isCompactRequest {
591598
return body, true
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package service
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"strings"
7+
8+
"github.com/gin-gonic/gin"
9+
"github.com/google/uuid"
10+
"github.com/tidwall/gjson"
11+
"github.com/tidwall/sjson"
12+
)
13+
14+
// openAICompactClientStreamKey 标记 body-signal compact 请求(Codex remote
15+
// compact v2,见 #3777)的原始 body 携带 stream:true。白名单归一化会删除
16+
// stream 字段并让上游走 unary /responses/compact(JSON),但客户端仍按
17+
// Responses SSE 协议消费响应:它必须收到 response.output_item.done(其中恰好
18+
// 一个 type=compaction 的 item)和 response.completed,否则报
19+
// "stream closed before response.completed" 并无限重连(#3875)。
20+
const openAICompactClientStreamKey = "openai_compact_client_stream"
21+
22+
// MarkOpenAICompactClientStream 由 handler 在 body-signal 提升时调用,记录
23+
// 客户端的原始 stream 意图,供响应写回阶段决定是否合成 SSE。
24+
func MarkOpenAICompactClientStream(c *gin.Context) {
25+
if c == nil {
26+
return
27+
}
28+
c.Set(openAICompactClientStreamKey, true)
29+
}
30+
31+
func OpenAICompactClientStreamKeyForTest() string {
32+
return openAICompactClientStreamKey
33+
}
34+
35+
func openAICompactClientWantsStream(c *gin.Context) bool {
36+
if c == nil {
37+
return false
38+
}
39+
value, ok := c.Get(openAICompactClientStreamKey)
40+
if !ok {
41+
return false
42+
}
43+
wants, _ := value.(bool)
44+
return wants
45+
}
46+
47+
// writeOpenAICompactSSEBridge 将 unary compact 的最终 JSON 响应按 Codex remote
48+
// compact v2 的消费协议合成为最小 Responses SSE 流写回客户端。仅当请求被标记
49+
// 为 body-signal 客户端流式、状态码为 2xx 且 body 是合法 JSON 对象时生效;
50+
// 返回 false 表示未写出任何内容,调用方应按原路径写回。
51+
func writeOpenAICompactSSEBridge(c *gin.Context, statusCode int, finalResponse []byte) bool {
52+
if c == nil || statusCode < 200 || statusCode >= 300 || !openAICompactClientWantsStream(c) {
53+
return false
54+
}
55+
payload, ok := buildOpenAICompactSSEPayload(finalResponse)
56+
if !ok {
57+
return false
58+
}
59+
header := c.Writer.Header()
60+
header.Set("Content-Type", "text/event-stream")
61+
header.Set("Cache-Control", "no-cache")
62+
header.Set("Connection", "keep-alive")
63+
header.Set("X-Accel-Buffering", "no")
64+
c.Writer.WriteHeader(statusCode)
65+
_, _ = c.Writer.Write(payload)
66+
c.Writer.Flush()
67+
return true
68+
}
69+
70+
// buildOpenAICompactSSEPayload 把 compact 的 Response JSON 转成 SSE 事件序列:
71+
// 每个 output[] item 一条 response.output_item.done,最后一条 response.completed
72+
// 携带完整 response 对象。Codex 的 SSE 解析只从 output_item.done 收集 item,
73+
// 并要求 response.completed 的 response.id 必填、usage(若存在)必须携带
74+
// input_tokens/output_tokens/total_tokens 整数字段,否则整条 completed 事件
75+
// 解析失败,故此处做兜底修补。
76+
func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
77+
if len(finalResponse) == 0 || !gjson.ValidBytes(finalResponse) {
78+
return nil, false
79+
}
80+
if !gjson.ParseBytes(finalResponse).IsObject() {
81+
return nil, false
82+
}
83+
// SSE 的 data 行不允许出现裸换行:上游 JSON 可能是 pretty-printed 形态,
84+
// 嵌入前必须压缩为单行。
85+
var compacted bytes.Buffer
86+
if err := json.Compact(&compacted, finalResponse); err != nil {
87+
return nil, false
88+
}
89+
response := compacted.Bytes()
90+
root := gjson.ParseBytes(response)
91+
if strings.TrimSpace(root.Get("id").String()) == "" {
92+
next, err := sjson.SetBytes(response, "id", "resp_"+strings.ReplaceAll(uuid.NewString(), "-", ""))
93+
if err != nil {
94+
return nil, false
95+
}
96+
response = next
97+
}
98+
if usage := gjson.GetBytes(response, "usage"); usage.Exists() && !openAICompactUsageParsableByCodex(usage) {
99+
next, err := sjson.DeleteBytes(response, "usage")
100+
if err != nil {
101+
return nil, false
102+
}
103+
response = next
104+
}
105+
106+
var buf bytes.Buffer
107+
outputIndex := 0
108+
appendEvent := func(eventType string, data []byte) {
109+
buf.WriteString("event: ")
110+
buf.WriteString(eventType)
111+
buf.WriteString("\ndata: ")
112+
buf.Write(data)
113+
buf.WriteString("\n\n")
114+
}
115+
for _, item := range gjson.GetBytes(response, "output").Array() {
116+
if !item.IsObject() {
117+
continue
118+
}
119+
event, err := sjson.SetBytes([]byte(`{"type":"response.output_item.done"}`), "output_index", outputIndex)
120+
if err != nil {
121+
return nil, false
122+
}
123+
event, err = sjson.SetRawBytes(event, "item", []byte(item.Raw))
124+
if err != nil {
125+
return nil, false
126+
}
127+
appendEvent("response.output_item.done", event)
128+
outputIndex++
129+
}
130+
131+
completed, err := sjson.SetRawBytes([]byte(`{"type":"response.completed"}`), "response", response)
132+
if err != nil {
133+
return nil, false
134+
}
135+
appendEvent("response.completed", completed)
136+
return buf.Bytes(), true
137+
}
138+
139+
func openAICompactUsageParsableByCodex(usage gjson.Result) bool {
140+
if !usage.IsObject() {
141+
return false
142+
}
143+
for _, field := range []string{"input_tokens", "output_tokens", "total_tokens"} {
144+
if usage.Get(field).Type != gjson.Number {
145+
return false
146+
}
147+
}
148+
return true
149+
}

0 commit comments

Comments
 (0)