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
This commit is contained in:
li
2026-08-14 11:05:29 +08:00
parent fbfdcef818
commit 3c5553e253
8 changed files with 299 additions and 36 deletions
+16 -11
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/gin-gonic/gin"
@@ -19,13 +20,16 @@ type responsesFailedError struct {
// responsesFailedBody 对齐 apicompat.makeResponsesCompletedEvent 输出的 response 子对象字段集。
// Output 用空 slice(不是 nil)确保 marshal 为 `[]` 而非 `null`。
// CreatedAt 不带 omitempty:严格客户端把它当必填字段,缺失会以
// `missing field 'created_at'` 反序列化失败——那正是本文件要避免的"客户端读不懂终止事件"。
type responsesFailedBody struct {
ID string `json:"id"`
Object string `json:"object"`
Model string `json:"model,omitempty"`
Status string `json:"status"`
Output []any `json:"output"`
Error responsesFailedError `json:"error"`
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Model string `json:"model,omitempty"`
Status string `json:"status"`
Output []any `json:"output"`
Error responsesFailedError `json:"error"`
}
// responsesFailedEvent 是写入 SSE data 行的顶层结构。
@@ -61,11 +65,12 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
payload, err := json.Marshal(responsesFailedEvent{
Type: "response.failed",
Response: responsesFailedBody{
ID: synthesizeResponseID(c),
Object: "response",
Model: requestModel(c),
Status: "failed",
Output: []any{},
ID: synthesizeResponseID(c),
Object: "response",
CreatedAt: time.Now().Unix(),
Model: requestModel(c),
Status: "failed",
Output: []any{},
Error: responsesFailedError{
Code: mapResponsesErrorCode(errType),
Message: message,
@@ -227,6 +227,22 @@ func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t
}
// Synthesized response.failed id falls back to uuid when no request_id is present.
// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即
// `missing field 'created_at'`。合成的终止事件若解析不了,本文件存在的意义
// (给客户端一个可识别的终止事件而不是盲重连)就落空了。
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesCreatedAt(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
resp, _ := parseResponsesFailedSSE(t, w.Body.String())
raw, ok := resp["created_at"]
assert.True(t, ok, "response.failed 必须带 created_at")
createdAt, ok := raw.(float64)
assert.True(t, ok, "created_at 必须是数字,得到 %T", raw)
assert.Greater(t, int64(createdAt), int64(0), "created_at 必须是有效的 unix 时间戳")
}
func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
c, _ := newGinContextForEndpoint(t, EndpointResponses)
id := synthesizeResponseID(c)
@@ -21,10 +21,13 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
id = generateResponsesID()
}
// Anthropic responses carry no creation timestamp, so stamp now — the same
// synthesize-what-the-client-requires rule the generated id above follows.
out := &ResponsesResponse{
ID: id,
Object: "response",
Model: resp.Model,
ID: id,
Object: "response",
CreatedAt: time.Now().Unix(),
Model: resp.Model,
}
var outputs []ResponsesOutput
@@ -551,11 +554,12 @@ func makeResponsesCreatedEvent(state *AnthropicEventToResponsesState) ResponsesS
Type: "response.created",
SequenceNumber: seq,
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
Model: state.Model,
Status: "in_progress",
Output: []ResponsesOutput{},
ID: state.ResponseID,
Object: "response",
CreatedAt: state.Created,
Model: state.Model,
Status: "in_progress",
Output: []ResponsesOutput{},
},
}
}
@@ -602,6 +606,7 @@ func makeResponsesCompletedEvent(
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
CreatedAt: state.Created,
Model: state.Model,
Status: status,
Output: outputs,
@@ -1042,11 +1042,22 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
id = generateResponsesID()
}
// Carry the upstream's own creation timestamp when it sent one; otherwise
// stamp now, same fallback shape as the generated id above.
createdAt := int64(0)
if resp != nil {
createdAt = resp.Created
}
if createdAt <= 0 {
createdAt = time.Now().Unix()
}
out := &ResponsesResponse{
ID: id,
Object: "response",
Model: model,
Status: "completed",
ID: id,
Object: "response",
CreatedAt: createdAt,
Model: model,
Status: "completed",
}
if resp == nil {
out.Output = []ResponsesOutput{emptyResponsesMessageOutput()}
@@ -1493,6 +1504,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
CreatedAt: state.Created,
Model: state.Model,
Status: status,
Output: state.chatOutput(),
@@ -1510,11 +1522,12 @@ func ensureChatToResponsesCreated(state *ChatCompletionsToResponsesStreamState)
state.CreatedSent = true
return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.created", &ResponsesStreamEvent{
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
Model: state.Model,
Status: "in_progress",
Output: []ResponsesOutput{},
ID: state.ResponseID,
Object: "response",
CreatedAt: state.Created,
Model: state.Model,
Status: "in_progress",
Output: []ResponsesOutput{},
},
})}
}
@@ -0,0 +1,166 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// issue #5601:严格的 Responses 客户端(Rust serde 系,如 Codex / Grok CLI)把
// created_at 声明为必填字段,缺失即 `missing field 'created_at'` 反序列化失败。
// 网关合成的 Responses 对象(Chat→Responses、Anthropic→Responses 两座桥)此前从不
// 写这个字段——尽管两个流式 state 早就采集好了 Created 时间戳,只是没有出口。
// 原生 Responses 透传走 gjson/sjson 字节级改写,不受影响。
// responseObjectOf 取出事件里的 response 子对象(按线格式,而不是按 Go 结构体)。
func responseObjectOf(t *testing.T, evt ResponsesStreamEvent) map[string]any {
t.Helper()
m := marshalEvent(t, evt)
resp, ok := m["response"].(map[string]any)
require.True(t, ok, "event must carry a response object: %v", m)
return resp
}
func requireCreatedAt(t *testing.T, resp map[string]any) int64 {
t.Helper()
raw, ok := resp["created_at"]
require.True(t, ok, "response 对象必须带 created_at,否则严格客户端直接反序列化失败")
value, ok := raw.(float64)
require.True(t, ok, "created_at 必须是数字,得到 %T", raw)
require.Greater(t, int64(value), int64(0), "created_at 必须是有效的 unix 时间戳")
return int64(value)
}
// omitempty 陷阱守卫:created_at 为 0 时也必须出现在线格式里,
// 否则「字段存在」这件事就依赖于运行时恰好非零。
func TestWire_CreatedAtPresentEvenAtZero(t *testing.T) {
resp := responseObjectOf(t, ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "in_progress"},
})
require.Contains(t, resp, "created_at", "created_at 不得带 omitempty")
require.EqualValues(t, 0, resp["created_at"])
}
// ---------------------------------------------------------------------------
// Chat Completions → Responses
// ---------------------------------------------------------------------------
func TestChatCompletionsResponseToResponses_CarriesCreatedAt(t *testing.T) {
t.Run("uses_upstream_created_when_present", func(t *testing.T) {
out := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{
ID: "chatcmpl_1",
Created: 1700000000,
Model: "deepseek-v4-flash",
Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}},
}, "deepseek-v4-flash", nil, false, nil)
require.EqualValues(t, 1700000000, out.CreatedAt, "上游给了 created 就照搬,不要另起时间")
})
t.Run("stamps_now_when_upstream_omits_created", func(t *testing.T) {
out := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{
ID: "chatcmpl_2",
Model: "deepseek-v4-flash",
Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}},
}, "deepseek-v4-flash", nil, false, nil)
require.Greater(t, out.CreatedAt, int64(0))
})
t.Run("nil_upstream_response_still_stamps", func(t *testing.T) {
out := ChatCompletionsResponseToResponses(nil, "deepseek-v4-flash", nil, false, nil)
require.Greater(t, out.CreatedAt, int64(0), "空上游响应也必须产出可解析的对象")
})
}
// 同一条流里 response.created 与终止事件必须报同一个 created_at
// (官方语义:created_at 是这次 response 的创建时刻,不随事件变化)。
func TestChatCompletionsToResponsesStream_CreatedAtStableAcrossEvents(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
require.Greater(t, state.Created, int64(0), "前提:state 早就采集了时间戳")
var chunk ChatCompletionsChunk
require.NoError(t, json.Unmarshal(
[]byte(`{"choices":[{"index":0,"delta":{"content":"hi"}}]}`), &chunk))
events := ChatCompletionsChunkToResponsesEvents(&chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
seen := map[string]int64{}
for _, evt := range events {
if evt.Response == nil {
continue
}
seen[evt.Type] = requireCreatedAt(t, responseObjectOf(t, evt))
}
require.Contains(t, seen, "response.created")
require.Contains(t, seen, "response.completed")
require.Equal(t, state.Created, seen["response.created"])
require.Equal(t, seen["response.created"], seen["response.completed"],
"同一条流的 created_at 必须恒定")
}
// ---------------------------------------------------------------------------
// Anthropic → Responses
// ---------------------------------------------------------------------------
func TestAnthropicToResponsesResponse_StampsCreatedAt(t *testing.T) {
out := AnthropicToResponsesResponse(&AnthropicResponse{
ID: "msg_1",
Type: "message",
Role: "assistant",
Model: "claude-sonnet-4-20250514",
Content: []AnthropicContentBlock{{Type: "text", Text: "hi"}},
})
require.Greater(t, out.CreatedAt, int64(0),
"Anthropic 响应不带时间戳,网关必须自己盖一个")
}
func TestAnthropicEventToResponsesStream_CreatedAtStableAcrossEvents(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-20250514"
require.Greater(t, state.Created, int64(0), "前提:state 早就采集了时间戳")
var events []ResponsesStreamEvent
for _, raw := range []string{
`{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`,
`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`,
`{"type":"content_block_stop","index":0}`,
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
`{"type":"message_stop"}`,
} {
var evt AnthropicStreamEvent
require.NoError(t, json.Unmarshal([]byte(raw), &evt))
events = append(events, AnthropicEventToResponsesEvents(&evt, state)...)
}
events = append(events, FinalizeAnthropicResponsesStream(state)...)
seen := map[string]int64{}
for _, evt := range events {
if evt.Response == nil {
continue
}
seen[evt.Type] = requireCreatedAt(t, responseObjectOf(t, evt))
}
require.Contains(t, seen, "response.created")
require.Contains(t, seen, "response.completed")
require.Equal(t, state.Created, seen["response.created"])
require.Equal(t, seen["response.created"], seen["response.completed"],
"同一条流的 created_at 必须恒定")
}
// ResponsesClientToolStreamRestorer 对部分事件走 unmarshal→re-marshal。
// 结构体没有该字段时,上游带来的 created_at 会在这一步被静默抹掉。
func TestResponsesStreamEvent_CreatedAtSurvivesUnmarshalRemarshal(t *testing.T) {
upstream := []byte(`{"type":"response.completed","response":{"id":"resp_9","object":"response",` +
`"created_at":1700000123,"model":"gpt-5.5","status":"completed","output":[]}}`)
var evt ResponsesStreamEvent
require.NoError(t, json.Unmarshal(upstream, &evt))
require.EqualValues(t, 1700000123, evt.Response.CreatedAt)
require.EqualValues(t, 1700000123, requireCreatedAt(t, responseObjectOf(t, evt)))
}
+11 -6
View File
@@ -339,12 +339,17 @@ func (t *ResponsesTool) UnmarshalJSON(data []byte) error {
// ResponsesResponse is the non-streaming response from POST /v1/responses.
type ResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"` // "response"
Model string `json:"model"`
Status string `json:"status"` // "completed" | "incomplete" | "failed"
Output []ResponsesOutput `json:"output"`
Usage *ResponsesUsage `json:"usage,omitempty"`
ID string `json:"id"`
Object string `json:"object"` // "response"
// CreatedAt is the unix creation timestamp. Strict Responses clients declare
// it non-optional and abort with `missing field 'created_at'` when it is
// absent, so it is always emitted — no omitempty. Same rule as ID (see the
// "clients treat it as required" fallback in ChatCompletionsResponseToAnthropic).
CreatedAt int64 `json:"created_at"`
Model string `json:"model"`
Status string `json:"status"` // "completed" | "incomplete" | "failed"
Output []ResponsesOutput `json:"output"`
Usage *ResponsesUsage `json:"usage,omitempty"`
// incomplete_details is present when status="incomplete"
IncompleteDetails *ResponsesIncompleteDetails `json:"incomplete_details,omitempty"`
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -117,8 +118,11 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType
"response": map[string]any{
"id": "resp_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
"object": "response",
"status": "failed",
"output": []any{},
// 严格客户端把 created_at 当必填字段,缺失会反序列化失败,
// 终止事件就白发了(退化成盲重连)。与 writeResponsesFailedSSE 对齐。
"created_at": time.Now().Unix(),
"status": "failed",
"output": []any{},
"error": map[string]any{
"code": errType,
"message": message,
@@ -0,0 +1,49 @@
//go:build unit
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即
// `missing field 'created_at'`。writeOpenAICompactSSEFailureMessage 存在的理由就是
// 让 Codex 能把这帧识别成合法终止事件;解析不了就退化回它想避免的盲重连。
func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
writeOpenAICompactSSEFailureMessage(c, http.StatusBadGateway, "upstream_error", "boom")
body := rec.Body.String()
require.Contains(t, body, "event: response.failed")
_, payload, found := strings.Cut(body, "data: ")
require.True(t, found, "SSE 帧必须带 data 行: %q", body)
var event struct {
Type string `json:"type"`
Response struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
} `json:"response"`
}
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(payload)), &event))
require.Equal(t, "response.failed", event.Type)
require.Equal(t, "response", event.Response.Object)
require.Equal(t, "failed", event.Response.Status)
require.Greater(t, event.Response.CreatedAt, int64(0),
"response.failed 必须带有效的 created_at,否则严格客户端读不出这帧")
}