openai_compact_stream_bridge_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. package service
  2. import (
  3. "context"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. "github.com/tidwall/gjson"
  13. )
  14. func newCompactBridgeTestContext(t *testing.T, markClientStream bool) (*gin.Context, *httptest.ResponseRecorder) {
  15. t.Helper()
  16. gin.SetMode(gin.TestMode)
  17. rec := httptest.NewRecorder()
  18. c, _ := gin.CreateTestContext(rec)
  19. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
  20. if markClientStream {
  21. MarkOpenAICompactClientStream(c)
  22. }
  23. return c, rec
  24. }
  25. func newCompactBridgeTestService() *OpenAIGatewayService {
  26. cfg := &config.Config{}
  27. return &OpenAIGatewayService{
  28. cfg: cfg,
  29. toolCorrector: NewCodexToolCorrector(),
  30. }
  31. }
  32. // parseCompactBridgeSSE 把合成的 SSE 文本拆成 (eventType, dataJSON) 序列。
  33. func parseCompactBridgeSSE(t *testing.T, body string) [][2]string {
  34. t.Helper()
  35. var events [][2]string
  36. for _, block := range strings.Split(strings.TrimSpace(body), "\n\n") {
  37. lines := strings.Split(block, "\n")
  38. require.Len(t, lines, 2, "每个 SSE 事件应为 event+data 两行: %q", block)
  39. require.True(t, strings.HasPrefix(lines[0], "event: "), "缺少 event 行: %q", block)
  40. require.True(t, strings.HasPrefix(lines[1], "data: "), "缺少 data 行: %q", block)
  41. events = append(events, [2]string{
  42. strings.TrimPrefix(lines[0], "event: "),
  43. strings.TrimPrefix(lines[1], "data: "),
  44. })
  45. }
  46. return events
  47. }
  48. func TestBuildOpenAICompactSSEPayload_EmitsItemsAndCompleted(t *testing.T) {
  49. finalResponse := []byte(`{
  50. "id":"resp_compact_1",
  51. "object":"response",
  52. "model":"gpt-5.1-codex",
  53. "status":"completed",
  54. "output":[
  55. {"id":"cmp_1","type":"compaction","status":"completed","encrypted_content":"compact-payload","summary":[{"type":"summary_text","text":"compact summary"}],"opaque":{"kept":true}},
  56. {"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}
  57. ],
  58. "usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}
  59. }`)
  60. payload, ok := buildOpenAICompactSSEPayload(finalResponse)
  61. require.True(t, ok)
  62. events := parseCompactBridgeSSE(t, string(payload))
  63. require.Len(t, events, 3)
  64. require.Equal(t, "response.output_item.done", events[0][0])
  65. first := events[0][1]
  66. require.Equal(t, "response.output_item.done", gjson.Get(first, "type").String())
  67. require.Equal(t, int64(0), gjson.Get(first, "output_index").Int())
  68. require.Equal(t, "compaction", gjson.Get(first, "item.type").String())
  69. require.Equal(t, "cmp_1", gjson.Get(first, "item.id").String())
  70. require.Equal(t, "compact-payload", gjson.Get(first, "item.encrypted_content").String())
  71. require.Equal(t, "compact summary", gjson.Get(first, "item.summary.0.text").String())
  72. require.True(t, gjson.Get(first, "item.opaque.kept").Bool(), "item 原始字段必须逐字节保留")
  73. require.Equal(t, "response.output_item.done", events[1][0])
  74. require.Equal(t, int64(1), gjson.Get(events[1][1], "output_index").Int())
  75. require.Equal(t, "message", gjson.Get(events[1][1], "item.type").String())
  76. require.Equal(t, "response.completed", events[2][0])
  77. completed := events[2][1]
  78. require.Equal(t, "response.completed", gjson.Get(completed, "type").String())
  79. require.Equal(t, "resp_compact_1", gjson.Get(completed, "response.id").String())
  80. require.Equal(t, int64(13), gjson.Get(completed, "response.usage.total_tokens").Int())
  81. require.Len(t, gjson.Get(completed, "response.output").Array(), 2)
  82. }
  83. func TestBuildOpenAICompactSSEPayload_InjectsMissingResponseID(t *testing.T) {
  84. payload, ok := buildOpenAICompactSSEPayload([]byte(`{"output":[{"type":"compaction","encrypted_content":"x"}]}`))
  85. require.True(t, ok)
  86. events := parseCompactBridgeSSE(t, string(payload))
  87. require.Len(t, events, 2)
  88. completed := events[1][1]
  89. // Codex 的 ResponseCompleted 解析要求 response.id 为非空 string,缺失时必须注入。
  90. id := gjson.Get(completed, "response.id").String()
  91. require.True(t, strings.HasPrefix(id, "resp_"), "缺失 id 必须注入 resp_* 兜底: %q", id)
  92. require.NotEqual(t, "resp_", id)
  93. }
  94. func TestBuildOpenAICompactSSEPayload_DropsMalformedUsage(t *testing.T) {
  95. payload, ok := buildOpenAICompactSSEPayload([]byte(`{
  96. "id":"resp_1",
  97. "output":[{"type":"compaction","encrypted_content":"x"}],
  98. "usage":{"prompt_tokens":9,"completion_tokens":4}
  99. }`))
  100. require.True(t, ok)
  101. events := parseCompactBridgeSSE(t, string(payload))
  102. completed := events[len(events)-1][1]
  103. // usage 缺少 Codex 必需的整数字段时必须整体删除,否则 completed 事件解析失败。
  104. require.False(t, gjson.Get(completed, "response.usage").Exists())
  105. }
  106. func TestBuildOpenAICompactSSEPayload_KeepsWellFormedUsage(t *testing.T) {
  107. payload, ok := buildOpenAICompactSSEPayload([]byte(`{
  108. "id":"resp_1",
  109. "output":[{"type":"compaction","encrypted_content":"x"}],
  110. "usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13,"input_tokens_details":{"cached_tokens":2}}
  111. }`))
  112. require.True(t, ok)
  113. events := parseCompactBridgeSSE(t, string(payload))
  114. completed := events[len(events)-1][1]
  115. require.Equal(t, int64(9), gjson.Get(completed, "response.usage.input_tokens").Int())
  116. require.Equal(t, int64(2), gjson.Get(completed, "response.usage.input_tokens_details.cached_tokens").Int())
  117. }
  118. func TestBuildOpenAICompactSSEPayload_RejectsNonJSONObject(t *testing.T) {
  119. for name, body := range map[string][]byte{
  120. "empty": nil,
  121. "sse_text": []byte("data: {\"type\":\"response.completed\"}\n\n"),
  122. "array": []byte(`[{"id":"resp_1"}]`),
  123. "non_json": []byte("upstream said no"),
  124. "bare_true": []byte("true"),
  125. } {
  126. _, ok := buildOpenAICompactSSEPayload(body)
  127. require.False(t, ok, "case %s 不应被合成为 SSE", name)
  128. }
  129. }
  130. func TestWriteOpenAICompactSSEBridge_RequiresMarkAndSuccessStatus(t *testing.T) {
  131. finalResponse := []byte(`{"id":"resp_1","output":[{"type":"compaction","encrypted_content":"x"}]}`)
  132. // 未标记 client stream:不写出,走原 JSON 路径。
  133. c, rec := newCompactBridgeTestContext(t, false)
  134. require.False(t, writeOpenAICompactSSEBridge(c, http.StatusOK, finalResponse))
  135. require.Zero(t, rec.Body.Len())
  136. // 标记但上游非 2xx:错误响应保持 JSON 原样(Codex 依赖 HTTP 状态码走重试)。
  137. c, rec = newCompactBridgeTestContext(t, true)
  138. require.False(t, writeOpenAICompactSSEBridge(c, http.StatusBadGateway, finalResponse))
  139. require.Zero(t, rec.Body.Len())
  140. // 标记且 2xx:合成 SSE。
  141. c, rec = newCompactBridgeTestContext(t, true)
  142. require.True(t, writeOpenAICompactSSEBridge(c, http.StatusOK, finalResponse))
  143. require.Equal(t, http.StatusOK, rec.Code)
  144. require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
  145. require.Contains(t, rec.Body.String(), "event: response.completed")
  146. }
  147. // 回归 #3875:body-signal 提升后的 compact 请求,上游返回 unary JSON,
  148. // 客户端(Codex remote compact v2)必须收到 SSE 事件流而非 JSON 文档,
  149. // 否则报 "stream closed before response.completed" 并无限重连。
  150. func TestHandleNonStreamingResponse_CompactClientStreamBridgesToSSE(t *testing.T) {
  151. svc := newCompactBridgeTestService()
  152. c, rec := newCompactBridgeTestContext(t, true)
  153. resp := &http.Response{
  154. StatusCode: http.StatusOK,
  155. Header: http.Header{"Content-Type": []string{"application/json"}},
  156. Body: io.NopCloser(strings.NewReader(`{
  157. "id":"resp_compact_json",
  158. "object":"response",
  159. "model":"gpt-5.1-codex",
  160. "status":"completed",
  161. "output":[{"id":"cmp_1","type":"compaction","status":"completed","encrypted_content":"compact-payload"}],
  162. "usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}
  163. }`)),
  164. }
  165. result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeOAuth}, "gpt-5.5", "gpt-5.5")
  166. require.NoError(t, err)
  167. require.NotNil(t, result)
  168. require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
  169. events := parseCompactBridgeSSE(t, rec.Body.String())
  170. require.Len(t, events, 2)
  171. require.Equal(t, "response.output_item.done", events[0][0])
  172. require.Equal(t, "compaction", gjson.Get(events[0][1], "item.type").String())
  173. require.Equal(t, "response.completed", events[1][0])
  174. require.Equal(t, "resp_compact_json", gjson.Get(events[1][1], "response.id").String())
  175. // 计费与响应元数据不受写回形态影响。
  176. require.NotNil(t, result.usage)
  177. require.Equal(t, 9, result.usage.InputTokens)
  178. require.Equal(t, 4, result.usage.OutputTokens)
  179. require.Equal(t, "resp_compact_json", result.responseID)
  180. }
  181. // 回归防护:path-based compact(Codex v1 unary 协议、链式 sub2api)未标记
  182. // client stream,必须保持 v0.1.146 以来的 JSON 写回行为。
  183. func TestHandleNonStreamingResponse_PathBasedCompactStaysJSON(t *testing.T) {
  184. svc := newCompactBridgeTestService()
  185. c, rec := newCompactBridgeTestContext(t, false)
  186. resp := &http.Response{
  187. StatusCode: http.StatusOK,
  188. Header: http.Header{"Content-Type": []string{"application/json"}},
  189. Body: io.NopCloser(strings.NewReader(`{
  190. "id":"resp_compact_json",
  191. "output":[{"id":"cmp_1","type":"compaction","encrypted_content":"compact-payload"}],
  192. "usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}
  193. }`)),
  194. }
  195. result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeOAuth}, "gpt-5.5", "gpt-5.5")
  196. require.NoError(t, err)
  197. require.NotNil(t, result)
  198. require.NotContains(t, rec.Header().Get("Content-Type"), "text/event-stream")
  199. body := rec.Body.String()
  200. require.Equal(t, "resp_compact_json", gjson.Get(body, "id").String())
  201. require.Equal(t, "compaction", gjson.Get(body, "output.0.type").String())
  202. }
  203. // 上游对 compact 返回 SSE(如链式网关)时,最终响应经 SSE→JSON 提取后,
  204. // 对 client-stream 请求同样必须再合成回 SSE。
  205. func TestHandleSSEToJSON_CompactClientStreamBridgesToSSE(t *testing.T) {
  206. svc := newCompactBridgeTestService()
  207. c, rec := newCompactBridgeTestContext(t, true)
  208. upstreamSSE := strings.Join([]string{
  209. `data: {"type":"response.completed","response":{"id":"resp_compact_sse","object":"response","model":"gpt-5.1-codex","status":"completed","output":[{"id":"cmp_sse_1","type":"compaction","status":"completed","encrypted_content":"compact-sse-payload"}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}`,
  210. "",
  211. }, "\n")
  212. resp := &http.Response{
  213. StatusCode: http.StatusOK,
  214. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  215. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  216. }
  217. result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeOAuth}, "gpt-5.5", "gpt-5.5")
  218. require.NoError(t, err)
  219. require.NotNil(t, result)
  220. require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
  221. events := parseCompactBridgeSSE(t, rec.Body.String())
  222. require.Len(t, events, 2)
  223. require.Equal(t, "response.output_item.done", events[0][0])
  224. require.Equal(t, "compact-sse-payload", gjson.Get(events[0][1], "item.encrypted_content").String())
  225. require.Equal(t, "response.completed", events[1][0])
  226. require.Equal(t, "resp_compact_sse", gjson.Get(events[1][1], "response.id").String())
  227. }
  228. // 回归 #3887(#3777 问题 2):上游对 compact 返回 SSE,compaction item 只在
  229. // raw output_item.done 中、终态 response.completed 的 output 为空。SSE→JSON
  230. // 提取必须保留 raw item 修补终态 output,否则桥接合成 0 个 output_item.done,
  231. // Codex 报 "expected exactly one compaction output item, got 0" 并盲目重试,
  232. // 每次重试都重新计费。fixture 取自 #3777 的上游实录形态。
  233. func TestHandleSSEToJSON_CompactRawOutputItemDoneRepairsEmptyTerminalOutput(t *testing.T) {
  234. svc := newCompactBridgeTestService()
  235. c, rec := newCompactBridgeTestContext(t, true)
  236. upstreamSSE := strings.Join([]string{
  237. `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"cmp_1","type":"compaction_summary","status":"completed","summary":[{"type":"summary_text","text":"compact summary"}],"encrypted_content":"compact-payload","opaque":{"kept":true}}}`,
  238. ``,
  239. `data: {"type":"response.completed","response":{"id":"resp_compact","object":"response","model":"gpt-5.1-codex","status":"completed","output":[],"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}}`,
  240. ``,
  241. }, "\n")
  242. resp := &http.Response{
  243. StatusCode: http.StatusOK,
  244. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  245. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  246. }
  247. result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeOAuth}, "gpt-5.5", "gpt-5.5")
  248. require.NoError(t, err)
  249. require.NotNil(t, result)
  250. require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
  251. events := parseCompactBridgeSSE(t, rec.Body.String())
  252. require.Len(t, events, 2)
  253. require.Equal(t, "response.output_item.done", events[0][0])
  254. item := gjson.Get(events[0][1], "item")
  255. require.Equal(t, "compaction_summary", item.Get("type").String())
  256. require.Equal(t, "cmp_1", item.Get("id").String())
  257. require.Equal(t, "compact-payload", item.Get("encrypted_content").String())
  258. require.Equal(t, "compact summary", item.Get("summary.0.text").String())
  259. require.True(t, item.Get("opaque.kept").Bool(), "raw item 字段必须逐字节保留")
  260. require.Equal(t, "response.completed", events[1][0])
  261. require.Equal(t, "resp_compact", gjson.Get(events[1][1], "response.id").String())
  262. require.Len(t, gjson.Get(events[1][1], "response.output").Array(), 1)
  263. require.Equal(t, int64(13), gjson.Get(events[1][1], "response.usage.total_tokens").Int())
  264. require.NotNil(t, result.usage)
  265. require.Equal(t, 9, result.usage.InputTokens)
  266. require.Equal(t, 4, result.usage.OutputTokens)
  267. }
  268. // 同一形态经透传分支(handlePassthroughSSEToJSON)也必须修补。
  269. func TestHandlePassthroughSSEToJSON_CompactRawOutputItemDoneRepairsEmptyTerminalOutput(t *testing.T) {
  270. svc := newCompactBridgeTestService()
  271. c, rec := newCompactBridgeTestContext(t, true)
  272. upstreamSSE := strings.Join([]string{
  273. `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"cmp_pt_1","type":"compaction","status":"completed","encrypted_content":"compact-pt-raw"}}`,
  274. ``,
  275. `data: {"type":"response.completed","response":{"id":"resp_compact_pt_raw","object":"response","status":"completed","output":[],"usage":{"input_tokens":6,"output_tokens":2,"total_tokens":8}}}`,
  276. ``,
  277. }, "\n")
  278. resp := &http.Response{
  279. StatusCode: http.StatusOK,
  280. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  281. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  282. }
  283. result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "")
  284. require.NoError(t, err)
  285. require.NotNil(t, result)
  286. require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
  287. events := parseCompactBridgeSSE(t, rec.Body.String())
  288. require.Len(t, events, 2)
  289. require.Equal(t, "compaction", gjson.Get(events[0][1], "item.type").String())
  290. require.Equal(t, "compact-pt-raw", gjson.Get(events[0][1], "item.encrypted_content").String())
  291. require.Len(t, gjson.Get(events[1][1], "response.output").Array(), 1)
  292. }
  293. // path-based(Codex v1 unary、链式 sub2api)未标记 client stream:同一上游
  294. // 形态修补后仍按 JSON 写回,output 中必须包含 compaction item。
  295. func TestHandleSSEToJSON_PathBasedCompactRawOutputItemDoneRepairsJSON(t *testing.T) {
  296. svc := newCompactBridgeTestService()
  297. c, rec := newCompactBridgeTestContext(t, false)
  298. upstreamSSE := strings.Join([]string{
  299. `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"cmp_v1","type":"compaction_summary","encrypted_content":"compact-v1-raw"}}`,
  300. ``,
  301. `data: {"type":"response.completed","response":{"id":"resp_compact_v1","object":"response","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}`,
  302. ``,
  303. }, "\n")
  304. resp := &http.Response{
  305. StatusCode: http.StatusOK,
  306. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  307. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  308. }
  309. result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeOAuth}, "gpt-5.5", "gpt-5.5")
  310. require.NoError(t, err)
  311. require.NotNil(t, result)
  312. // 写回 body 必须是修补后的 JSON 文档(非 SSE 事件流)。
  313. body := rec.Body.String()
  314. require.NotContains(t, body, "event:")
  315. require.NotContains(t, body, "data:")
  316. require.Equal(t, "resp_compact_v1", gjson.Get(body, "id").String())
  317. require.Equal(t, "compaction_summary", gjson.Get(body, "output.0.type").String())
  318. require.Equal(t, "compact-v1-raw", gjson.Get(body, "output.0.encrypted_content").String())
  319. }
  320. // raw done item 是协议上的最终完整形态,优先于 delta 重建且不得重复计入。
  321. func TestReconstructResponseOutputFromSSE_PrefersRawDoneItems(t *testing.T) {
  322. bodyText := strings.Join([]string{
  323. `data: {"type":"response.output_text.delta","delta":"hel"}`,
  324. `data: {"type":"response.output_text.delta","delta":"lo"}`,
  325. `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hello"}]}}`,
  326. `data: {"type":"response.completed","response":{"id":"resp_1","output":[]}}`,
  327. }, "\n")
  328. outputJSON, ok := reconstructResponseOutputFromSSE(bodyText)
  329. require.True(t, ok)
  330. items := gjson.ParseBytes(outputJSON).Array()
  331. require.Len(t, items, 1, "raw done item 与 delta 重建不得重复")
  332. require.Equal(t, "msg_1", items[0].Get("id").String())
  333. require.Equal(t, "hello", items[0].Get("content.0.text").String())
  334. }
  335. // 无任何 done 事件时,退回收集 output_item.added 中的 compaction 类 item。
  336. func TestReconstructResponseOutputFromSSE_CompactionAddedFallback(t *testing.T) {
  337. bodyText := strings.Join([]string{
  338. `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"cmp_add","type":"compaction","encrypted_content":"added-only"}}`,
  339. `data: {"type":"response.completed","response":{"id":"resp_1","output":[]}}`,
  340. }, "\n")
  341. outputJSON, ok := reconstructResponseOutputFromSSE(bodyText)
  342. require.True(t, ok)
  343. items := gjson.ParseBytes(outputJSON).Array()
  344. require.Len(t, items, 1)
  345. require.Equal(t, "compaction", items[0].Get("type").String())
  346. require.Equal(t, "added-only", items[0].Get("encrypted_content").String())
  347. }
  348. // 混合形态:其他 item 有 done、compaction 只在 added 中——compaction 必须
  349. // 被补入;done 已含 compaction 时 added 不得重复计入。
  350. func TestReconstructResponseOutputFromSSE_MixedDoneAndCompactionAdded(t *testing.T) {
  351. bodyText := strings.Join([]string{
  352. `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"cmp_mixed","type":"compaction","encrypted_content":"mixed"}}`,
  353. `data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","content":[{"type":"output_text","text":"hi"}]}}`,
  354. `data: {"type":"response.completed","response":{"id":"resp_1","output":[]}}`,
  355. }, "\n")
  356. outputJSON, ok := reconstructResponseOutputFromSSE(bodyText)
  357. require.True(t, ok)
  358. items := gjson.ParseBytes(outputJSON).Array()
  359. require.Len(t, items, 2)
  360. require.Equal(t, "msg_1", items[0].Get("id").String())
  361. require.Equal(t, "cmp_mixed", items[1].Get("id").String())
  362. // done 已含 compaction:added 中的同一 item(无 id 可去重的最坏情况用
  363. // 不同 raw 表达)不得再收集,Codex 要求恰好一个 compaction item。
  364. bodyText = strings.Join([]string{
  365. `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"compaction","status":"in_progress"}}`,
  366. `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"compaction","status":"completed","encrypted_content":"final"}}`,
  367. `data: {"type":"response.completed","response":{"id":"resp_1","output":[]}}`,
  368. }, "\n")
  369. outputJSON, ok = reconstructResponseOutputFromSSE(bodyText)
  370. require.True(t, ok)
  371. items = gjson.ParseBytes(outputJSON).Array()
  372. require.Len(t, items, 1)
  373. require.Equal(t, "final", items[0].Get("encrypted_content").String())
  374. }
  375. // 上游不一致形态:终态 output 非空(含 message)但 compaction 只在 raw
  376. // output_item.done 中。146 纯流式透传下 Codex 直接读事件流能拿到 compaction,
  377. // SSE→JSON 提取必须补入等价结果。
  378. func TestHandleSSEToJSON_CompactSupplementsMissingCompactionIntoNonEmptyOutput(t *testing.T) {
  379. svc := newCompactBridgeTestService()
  380. c, rec := newCompactBridgeTestContext(t, true)
  381. upstreamSSE := strings.Join([]string{
  382. `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"cmp_sup","type":"compaction","encrypted_content":"supplement"}}`,
  383. ``,
  384. `data: {"type":"response.completed","response":{"id":"resp_sup","object":"response","status":"completed","output":[{"id":"msg_sup","type":"message","role":"assistant","content":[{"type":"output_text","text":"note"}]}],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`,
  385. ``,
  386. }, "\n")
  387. resp := &http.Response{
  388. StatusCode: http.StatusOK,
  389. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  390. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  391. }
  392. result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeOAuth}, "gpt-5.5", "gpt-5.5")
  393. require.NoError(t, err)
  394. require.NotNil(t, result)
  395. events := parseCompactBridgeSSE(t, rec.Body.String())
  396. require.Len(t, events, 3)
  397. itemTypes := []string{
  398. gjson.Get(events[0][1], "item.type").String(),
  399. gjson.Get(events[1][1], "item.type").String(),
  400. }
  401. require.Contains(t, itemTypes, "compaction")
  402. require.Contains(t, itemTypes, "message")
  403. require.Equal(t, "response.completed", events[2][0])
  404. require.Len(t, gjson.Get(events[2][1], "response.output").Array(), 2)
  405. }
  406. // 补全逻辑的门控:非 compact 请求原样返回;终态已含 compaction 不重复补入。
  407. func TestSupplementCompactionItemFromSSE_Gating(t *testing.T) {
  408. bodyText := `data: {"type":"response.output_item.done","item":{"id":"cmp_g","type":"compaction","encrypted_content":"g"}}` + "\n"
  409. // 非 compact 路径:不补入。
  410. gin.SetMode(gin.TestMode)
  411. rec := httptest.NewRecorder()
  412. c, _ := gin.CreateTestContext(rec)
  413. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  414. finalResponse := []byte(`{"id":"r1","output":[{"type":"message"}]}`)
  415. require.Equal(t, string(finalResponse), string(supplementCompactionItemFromSSE(c, finalResponse, bodyText)))
  416. // compact 路径 + 终态已含 compaction:不重复补入。
  417. c2, _ := newCompactBridgeTestContext(t, false)
  418. already := []byte(`{"id":"r2","output":[{"type":"compaction","encrypted_content":"x"}]}`)
  419. require.Equal(t, string(already), string(supplementCompactionItemFromSSE(c2, already, bodyText)))
  420. // compact 路径 + 终态非空缺 compaction:补入到末尾。
  421. missing := []byte(`{"id":"r3","output":[{"type":"message"}]}`)
  422. patched := supplementCompactionItemFromSSE(c2, missing, bodyText)
  423. items := gjson.GetBytes(patched, "output").Array()
  424. require.Len(t, items, 2)
  425. require.Equal(t, "compaction", items[1].Get("type").String())
  426. require.Equal(t, "g", items[1].Get("encrypted_content").String())
  427. }
  428. // 非 compaction 的 output_item.added 不参与回退收集(added 阶段的 message
  429. // 通常是空壳),仍走 delta 重建。
  430. func TestReconstructResponseOutputFromSSE_NonCompactionAddedStillUsesDeltas(t *testing.T) {
  431. bodyText := strings.Join([]string{
  432. `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","content":[]}}`,
  433. `data: {"type":"response.output_text.delta","delta":"hi"}`,
  434. `data: {"type":"response.completed","response":{"id":"resp_1","output":[]}}`,
  435. }, "\n")
  436. outputJSON, ok := reconstructResponseOutputFromSSE(bodyText)
  437. require.True(t, ok)
  438. items := gjson.ParseBytes(outputJSON).Array()
  439. require.Len(t, items, 1)
  440. require.Equal(t, "hi", items[0].Get("content.0.text").String())
  441. }
  442. // 透传分支(OAuth passthrough)同样命中桥接。
  443. func TestHandleNonStreamingResponsePassthrough_CompactClientStreamBridgesToSSE(t *testing.T) {
  444. svc := newCompactBridgeTestService()
  445. c, rec := newCompactBridgeTestContext(t, true)
  446. resp := &http.Response{
  447. StatusCode: http.StatusOK,
  448. Header: http.Header{"Content-Type": []string{"application/json"}},
  449. Body: io.NopCloser(strings.NewReader(`{
  450. "id":"resp_compact_pt",
  451. "output":[{"id":"cmp_pt_1","type":"compaction","encrypted_content":"compact-pt-payload"}],
  452. "usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}
  453. }`)),
  454. }
  455. result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "")
  456. require.NoError(t, err)
  457. require.NotNil(t, result)
  458. require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
  459. events := parseCompactBridgeSSE(t, rec.Body.String())
  460. require.Len(t, events, 2)
  461. require.Equal(t, "compaction", gjson.Get(events[0][1], "item.type").String())
  462. require.Equal(t, "resp_compact_pt", gjson.Get(events[1][1], "response.id").String())
  463. require.NotNil(t, result.usage)
  464. require.Equal(t, 7, result.usage.InputTokens)
  465. }