responses_created_at_wire_test.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. // issue #5601:严格的 Responses 客户端(Rust serde 系,如 Codex / Grok CLI)把
  8. // created_at 声明为必填字段,缺失即 `missing field 'created_at'` 反序列化失败。
  9. // 网关合成的 Responses 对象(Chat→Responses、Anthropic→Responses 两座桥)此前从不
  10. // 写这个字段——尽管两个流式 state 早就采集好了 Created 时间戳,只是没有出口。
  11. // 原生 Responses 透传走 gjson/sjson 字节级改写,不受影响。
  12. // responseObjectOf 取出事件里的 response 子对象(按线格式,而不是按 Go 结构体)。
  13. func responseObjectOf(t *testing.T, evt ResponsesStreamEvent) map[string]any {
  14. t.Helper()
  15. m := marshalEvent(t, evt)
  16. resp, ok := m["response"].(map[string]any)
  17. require.True(t, ok, "event must carry a response object: %v", m)
  18. return resp
  19. }
  20. func requireCreatedAt(t *testing.T, resp map[string]any) int64 {
  21. t.Helper()
  22. raw, ok := resp["created_at"]
  23. require.True(t, ok, "response 对象必须带 created_at,否则严格客户端直接反序列化失败")
  24. value, ok := raw.(float64)
  25. require.True(t, ok, "created_at 必须是数字,得到 %T", raw)
  26. require.Greater(t, int64(value), int64(0), "created_at 必须是有效的 unix 时间戳")
  27. return int64(value)
  28. }
  29. // omitempty 陷阱守卫:created_at 为 0 时也必须出现在线格式里,
  30. // 否则「字段存在」这件事就依赖于运行时恰好非零。
  31. func TestWire_CreatedAtPresentEvenAtZero(t *testing.T) {
  32. resp := responseObjectOf(t, ResponsesStreamEvent{
  33. Type: "response.created",
  34. Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "in_progress"},
  35. })
  36. require.Contains(t, resp, "created_at", "created_at 不得带 omitempty")
  37. require.EqualValues(t, 0, resp["created_at"])
  38. }
  39. // ---------------------------------------------------------------------------
  40. // Chat Completions → Responses
  41. // ---------------------------------------------------------------------------
  42. func TestChatCompletionsResponseToResponses_CarriesCreatedAt(t *testing.T) {
  43. t.Run("uses_upstream_created_when_present", func(t *testing.T) {
  44. out := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{
  45. ID: "chatcmpl_1",
  46. Created: 1700000000,
  47. Model: "deepseek-v4-flash",
  48. Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}},
  49. }, "deepseek-v4-flash", nil, nil, false, nil)
  50. require.EqualValues(t, 1700000000, out.CreatedAt, "上游给了 created 就照搬,不要另起时间")
  51. })
  52. t.Run("stamps_now_when_upstream_omits_created", func(t *testing.T) {
  53. out := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{
  54. ID: "chatcmpl_2",
  55. Model: "deepseek-v4-flash",
  56. Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}},
  57. }, "deepseek-v4-flash", nil, nil, false, nil)
  58. require.Greater(t, out.CreatedAt, int64(0))
  59. })
  60. t.Run("nil_upstream_response_still_stamps", func(t *testing.T) {
  61. out := ChatCompletionsResponseToResponses(nil, "deepseek-v4-flash", nil, nil, false, nil)
  62. require.Greater(t, out.CreatedAt, int64(0), "空上游响应也必须产出可解析的对象")
  63. })
  64. }
  65. // 同一条流里 response.created 与终止事件必须报同一个 created_at
  66. // (官方语义:created_at 是这次 response 的创建时刻,不随事件变化)。
  67. func TestChatCompletionsToResponsesStream_CreatedAtStableAcrossEvents(t *testing.T) {
  68. state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
  69. require.Greater(t, state.Created, int64(0), "前提:state 早就采集了时间戳")
  70. var chunk ChatCompletionsChunk
  71. require.NoError(t, json.Unmarshal(
  72. []byte(`{"choices":[{"index":0,"delta":{"content":"hi"}}]}`), &chunk))
  73. events := ChatCompletionsChunkToResponsesEvents(&chunk, state)
  74. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  75. seen := map[string]int64{}
  76. for _, evt := range events {
  77. if evt.Response == nil {
  78. continue
  79. }
  80. seen[evt.Type] = requireCreatedAt(t, responseObjectOf(t, evt))
  81. }
  82. require.Contains(t, seen, "response.created")
  83. require.Contains(t, seen, "response.completed")
  84. require.Equal(t, state.Created, seen["response.created"])
  85. require.Equal(t, seen["response.created"], seen["response.completed"],
  86. "同一条流的 created_at 必须恒定")
  87. }
  88. // ---------------------------------------------------------------------------
  89. // Anthropic → Responses
  90. // ---------------------------------------------------------------------------
  91. func TestAnthropicToResponsesResponse_StampsCreatedAt(t *testing.T) {
  92. out := AnthropicToResponsesResponse(&AnthropicResponse{
  93. ID: "msg_1",
  94. Type: "message",
  95. Role: "assistant",
  96. Model: "claude-sonnet-4-20250514",
  97. Content: []AnthropicContentBlock{{Type: "text", Text: "hi"}},
  98. })
  99. require.Greater(t, out.CreatedAt, int64(0),
  100. "Anthropic 响应不带时间戳,网关必须自己盖一个")
  101. }
  102. func TestAnthropicEventToResponsesStream_CreatedAtStableAcrossEvents(t *testing.T) {
  103. state := NewAnthropicEventToResponsesState()
  104. state.Model = "claude-sonnet-4-20250514"
  105. require.Greater(t, state.Created, int64(0), "前提:state 早就采集了时间戳")
  106. var events []ResponsesStreamEvent
  107. for _, raw := range []string{
  108. `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`,
  109. `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
  110. `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`,
  111. `{"type":"content_block_stop","index":0}`,
  112. `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
  113. `{"type":"message_stop"}`,
  114. } {
  115. var evt AnthropicStreamEvent
  116. require.NoError(t, json.Unmarshal([]byte(raw), &evt))
  117. events = append(events, AnthropicEventToResponsesEvents(&evt, state)...)
  118. }
  119. events = append(events, FinalizeAnthropicResponsesStream(state)...)
  120. seen := map[string]int64{}
  121. for _, evt := range events {
  122. if evt.Response == nil {
  123. continue
  124. }
  125. seen[evt.Type] = requireCreatedAt(t, responseObjectOf(t, evt))
  126. }
  127. require.Contains(t, seen, "response.created")
  128. require.Contains(t, seen, "response.completed")
  129. require.Equal(t, state.Created, seen["response.created"])
  130. require.Equal(t, seen["response.created"], seen["response.completed"],
  131. "同一条流的 created_at 必须恒定")
  132. }
  133. // ResponsesClientToolStreamRestorer 对部分事件走 unmarshal→re-marshal。
  134. // 结构体没有该字段时,上游带来的 created_at 会在这一步被静默抹掉。
  135. func TestResponsesStreamEvent_CreatedAtSurvivesUnmarshalRemarshal(t *testing.T) {
  136. upstream := []byte(`{"type":"response.completed","response":{"id":"resp_9","object":"response",` +
  137. `"created_at":1700000123,"model":"gpt-5.5","status":"completed","output":[]}}`)
  138. var evt ResponsesStreamEvent
  139. require.NoError(t, json.Unmarshal(upstream, &evt))
  140. require.EqualValues(t, 1700000123, evt.Response.CreatedAt)
  141. require.EqualValues(t, 1700000123, requireCreatedAt(t, responseObjectOf(t, evt)))
  142. }