| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- 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, 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, 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, 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)))
- }
|