Files
sub2api/backend/internal/service/openai_compact_stream_bridge.go
T
li 3c5553e253 fix(responses): 网关合成的 Responses 对象补齐 created_at,修复严格客户端反序列化失败
严格的 Responses 客户端(Rust serde 系,如 Codex / Grok CLI)把 created_at 声明为
必填字段,缺失即 `missing field 'created_at'` 直接读不出响应。

apicompat.ResponsesResponse 此前根本没有这个字段,六个构造点全部不写:
Chat→Responses 与 Anthropic→Responses 两座桥的非流式结果、response.created、
终止事件,以及两处手写的 response.failed 帧。

两个流式 state 早就在 New*State() 里采集了 Created 时间戳,只是没有出口——
反方向(Responses→Chat)每个 chunk 都老老实实发 created,正方向直接丢在地上。

原生 Responses 透传走 gjson/sjson 字节级改写,保留上游原样,不受影响;
本次只补合成路径。

Fixes #5601
2026-08-14 11:05:29 +08:00

221 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// openAICompactClientStreamKey 标记 body-signal compact 请求(Codex remote
// compact v2,见 #3777)的原始 body 携带 stream:true。白名单归一化会删除
// stream 字段并让上游走 unary /responses/compact(JSON),但客户端仍按
// Responses SSE 协议消费响应:它必须收到 response.output_item.done(其中恰好
// 一个 type=compaction 的 item)和 response.completed,否则报
// "stream closed before response.completed" 并无限重连(#3875)。
const openAICompactClientStreamKey = "openai_compact_client_stream"
// MarkOpenAICompactClientStream 由 handler 在 body-signal 提升时调用,记录
// 客户端的原始 stream 意图,供响应写回阶段决定是否合成 SSE。
func MarkOpenAICompactClientStream(c *gin.Context) {
if c == nil {
return
}
c.Set(openAICompactClientStreamKey, true)
}
func OpenAICompactClientStreamKeyForTest() string {
return openAICompactClientStreamKey
}
func openAICompactClientWantsStream(c *gin.Context) bool {
if c == nil {
return false
}
value, ok := c.Get(openAICompactClientStreamKey)
if !ok {
return false
}
wants, _ := value.(bool)
return wants
}
// writeOpenAICompactSSEBridge 将 unary compact 的最终 JSON 响应按 Codex remote
// compact v2 的消费协议合成为最小 Responses SSE 流写回客户端。仅当请求被标记
// 为 body-signal 客户端流式、状态码为 2xx 且 body 是合法 JSON 对象时生效;
// 返回 false 表示未写出任何内容,调用方应按原路径写回。
//
// 若下游心跳已把响应头提交为 200(见 openAICompactSSEKeepalive),则本函数
// 必须接管一切写回:非 2xx 或不可合成的响应降级为 response.failed 终止事件,
// 不能再返回 false(否则调用方的 JSON 写回会与已提交的 SSE 流交错)。
func writeOpenAICompactSSEBridge(c *gin.Context, statusCode int, finalResponse []byte) bool {
if c == nil || !openAICompactClientWantsStream(c) {
return false
}
// 先停心跳再写回,避免注释行与最终事件交错;停止后经互斥锁与心跳
// goroutine 建立 happens-before,可安全接管 ResponseWriter。
committed := StopOpenAICompactSSEKeepaliveCommitted(c)
if statusCode < 200 || statusCode >= 300 {
if committed {
writeOpenAICompactSSEFailure(c, statusCode, finalResponse)
return true
}
return false
}
payload, ok := buildOpenAICompactSSEPayload(finalResponse)
if !ok {
if committed {
writeOpenAICompactSSEFailure(c, http.StatusBadGateway, finalResponse)
return true
}
return false
}
if !committed {
header := c.Writer.Header()
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache")
header.Set("Connection", "keep-alive")
header.Set("X-Accel-Buffering", "no")
c.Writer.WriteHeader(statusCode)
}
_, _ = c.Writer.Write(payload)
c.Writer.Flush()
return true
}
// writeOpenAICompactSSEFailure 从上游错误 body 提取错误消息后,以
// response.failed 终止事件回传。仅用于心跳已提交 200、无法再按 HTTP 状态码
// 回传错误的场景。
func writeOpenAICompactSSEFailure(c *gin.Context, statusCode int, errorBody []byte) {
message := ""
if len(errorBody) > 0 {
message = sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(errorBody)))
}
if message == "" {
message = "Upstream compact request failed with HTTP " + strconv.Itoa(statusCode)
}
writeOpenAICompactSSEFailureMessage(c, statusCode, "upstream_error", message)
}
// writeOpenAICompactSSEFailureMessage 写出 response.failed 终止事件。Codex 对
// 流式 Responses 请求把 response.failed 作为合法终止事件处理(普通 error 帧
// 不被识别,会退化为 "stream closed before response.completed" 盲重连)。
// 同时标记流内错误,保证挂在 200 流上的失败仍进入 ops 错误看板。
func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType, message string) {
if c == nil {
return
}
MarkOpsStreamError(c, errType, message, statusCode)
payload, err := json.Marshal(map[string]any{
"type": "response.failed",
"response": map[string]any{
"id": "resp_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
"object": "response",
// 严格客户端把 created_at 当必填字段,缺失会反序列化失败,
// 终止事件就白发了(退化成盲重连)。与 writeResponsesFailedSSE 对齐。
"created_at": time.Now().Unix(),
"status": "failed",
"output": []any{},
"error": map[string]any{
"code": errType,
"message": message,
},
},
})
if err != nil {
return
}
_, _ = c.Writer.Write([]byte("event: response.failed\ndata: "))
_, _ = c.Writer.Write(payload)
_, _ = c.Writer.Write([]byte("\n\n"))
c.Writer.Flush()
}
// buildOpenAICompactSSEPayload 把 compact 的 Response JSON 转成 SSE 事件序列:
// 每个 output[] item 一条 response.output_item.done,最后一条 response.completed
// 携带完整 response 对象。Codex 的 SSE 解析只从 output_item.done 收集 item,
// 并要求 response.completed 的 response.id 必填、usage(若存在)必须携带
// input_tokens/output_tokens/total_tokens 整数字段,否则整条 completed 事件
// 解析失败,故此处做兜底修补。
func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
if len(finalResponse) == 0 || !gjson.ValidBytes(finalResponse) {
return nil, false
}
if !gjson.ParseBytes(finalResponse).IsObject() {
return nil, false
}
// SSE 的 data 行不允许出现裸换行:上游 JSON 可能是 pretty-printed 形态,
// 嵌入前必须压缩为单行。
var compacted bytes.Buffer
if err := json.Compact(&compacted, finalResponse); err != nil {
return nil, false
}
response := compacted.Bytes()
root := gjson.ParseBytes(response)
if strings.TrimSpace(root.Get("id").String()) == "" {
next, err := sjson.SetBytes(response, "id", "resp_"+strings.ReplaceAll(uuid.NewString(), "-", ""))
if err != nil {
return nil, false
}
response = next
}
if usage := gjson.GetBytes(response, "usage"); usage.Exists() && !openAICompactUsageParsableByCodex(usage) {
next, err := sjson.DeleteBytes(response, "usage")
if err != nil {
return nil, false
}
response = next
}
var buf bytes.Buffer
outputIndex := 0
appendEvent := func(eventType string, data []byte) {
_, _ = buf.WriteString("event: ")
_, _ = buf.WriteString(eventType)
_, _ = buf.WriteString("\ndata: ")
_, _ = buf.Write(data)
_, _ = buf.WriteString("\n\n")
}
for _, item := range gjson.GetBytes(response, "output").Array() {
if !item.IsObject() {
continue
}
event, err := sjson.SetBytes([]byte(`{"type":"response.output_item.done"}`), "output_index", outputIndex)
if err != nil {
return nil, false
}
event, err = sjson.SetRawBytes(event, "item", []byte(item.Raw))
if err != nil {
return nil, false
}
appendEvent("response.output_item.done", event)
outputIndex++
}
completed, err := sjson.SetRawBytes([]byte(`{"type":"response.completed"}`), "response", response)
if err != nil {
return nil, false
}
appendEvent("response.completed", completed)
return buf.Bytes(), true
}
func openAICompactUsageParsableByCodex(usage gjson.Result) bool {
if !usage.IsObject() {
return false
}
for _, field := range []string{"input_tokens", "output_tokens", "total_tokens"} {
if usage.Get(field).Type != gjson.Number {
return false
}
}
return true
}