chatcompletions_responses_reasoning_cache_test.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. // Encrypted-only reasoning items (empty summary + opaque encrypted_content,
  8. // e.g. after codex remote compaction) carry no plaintext the bridge can map to
  9. // reasoning_content. The gateway-side cache keyed by reasoning item id restores
  10. // it; without the restore, DeepSeek thinking mode rejects the history with 400
  11. // "The `reasoning_content` in the thinking mode must be passed back to the API".
  12. func TestResponsesToChat_ReasoningCacheLookup_RestoresEncryptedOnlyItem(t *testing.T) {
  13. req := &ResponsesRequest{
  14. Model: "deepseek-reasoner",
  15. Input: json.RawMessage(`[
  16. {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
  17. {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
  18. {"type":"function_call_output","call_id":"call_1","output":"ok"},
  19. {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
  20. ]`),
  21. }
  22. out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
  23. ReasoningContentByID: func(itemID string) string {
  24. if itemID == "item_enc1" {
  25. return "cached thinking"
  26. }
  27. return ""
  28. },
  29. })
  30. require.NoError(t, err)
  31. require.Len(t, out.Messages, 3)
  32. require.Equal(t, "assistant", out.Messages[0].Role)
  33. require.Equal(t, "cached thinking", out.Messages[0].ReasoningContent)
  34. require.Len(t, out.Messages[0].ToolCalls, 1)
  35. require.Equal(t, "call_1", out.Messages[0].ToolCalls[0].ID)
  36. require.Equal(t, "tool", out.Messages[1].Role)
  37. require.Equal(t, "user", out.Messages[2].Role)
  38. }
  39. // A cache miss keeps the original behavior: no reasoning_content, no error.
  40. func TestResponsesToChat_ReasoningCacheLookup_MissKeepsOriginalBehavior(t *testing.T) {
  41. req := &ResponsesRequest{
  42. Model: "deepseek-reasoner",
  43. Input: json.RawMessage(`[
  44. {"type":"reasoning","id":"item_unknown","summary":[],"encrypted_content":"opaque"},
  45. {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
  46. {"type":"function_call_output","call_id":"call_1","output":"ok"},
  47. {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
  48. ]`),
  49. }
  50. out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
  51. ReasoningContentByID: func(string) string { return "" },
  52. })
  53. require.NoError(t, err)
  54. require.Len(t, out.Messages, 3)
  55. require.Empty(t, out.Messages[0].ReasoningContent)
  56. // Nil options (legacy path) behaves identically.
  57. legacy, err := ResponsesToChatCompletionsRequest(req)
  58. require.NoError(t, err)
  59. require.Equal(t, out.Messages, legacy.Messages)
  60. }
  61. // Plaintext summary wins and the cache lookup is not consulted.
  62. func TestResponsesToChat_ReasoningCacheLookup_PlaintextPreferred(t *testing.T) {
  63. req := &ResponsesRequest{
  64. Model: "deepseek-reasoner",
  65. Input: json.RawMessage(`[
  66. {"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
  67. {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
  68. {"type":"function_call_output","call_id":"call_1","output":"ok"},
  69. {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
  70. ]`),
  71. }
  72. lookupCalled := false
  73. out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
  74. ReasoningContentByID: func(string) string {
  75. lookupCalled = true
  76. return "cached thinking"
  77. },
  78. })
  79. require.NoError(t, err)
  80. require.Len(t, out.Messages, 3)
  81. require.Equal(t, "plain thinking", out.Messages[0].ReasoningContent)
  82. require.False(t, lookupCalled, "plaintext summary present → cache lookup must not run")
  83. }
  84. // DeepSeek emits reasoning only once per turn; chained tool calls
  85. // (reasoning → call A → output A → call B) have no reasoning item before call
  86. // B. The turn's reasoning must be replayed on B's assistant message, otherwise
  87. // DeepSeek thinking mode 400s the history ("reasoning_content ... must be
  88. // passed back"). Reproduced from a real codex 0.147.0 resume history.
  89. func TestResponsesToChat_ChainedToolCallsReplayTurnReasoning(t *testing.T) {
  90. req := &ResponsesRequest{
  91. Model: "deepseek-reasoner",
  92. Input: json.RawMessage(`[
  93. {"type":"reasoning","id":"item_r1","summary":[{"type":"summary_text","text":"turn thinking"}]},
  94. {"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n\n"}]},
  95. {"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{}"},
  96. {"type":"function_call_output","call_id":"call_a","output":"ok"},
  97. {"type":"function_call","call_id":"call_b","name":"exec_command","arguments":"{}"},
  98. {"type":"function_call_output","call_id":"call_b","output":"ok"},
  99. {"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]},
  100. {"type":"reasoning","id":"item_r2","summary":[{"type":"summary_text","text":"second turn"}]},
  101. {"type":"function_call","call_id":"call_c","name":"exec_command","arguments":"{}"},
  102. {"type":"function_call_output","call_id":"call_c","output":"ok"}
  103. ]`),
  104. }
  105. out, err := ResponsesToChatCompletionsRequest(req)
  106. require.NoError(t, err)
  107. byCallID := map[string]ChatMessage{}
  108. for _, m := range out.Messages {
  109. for _, tc := range m.ToolCalls {
  110. byCallID[tc.ID] = m
  111. }
  112. }
  113. require.Len(t, byCallID, 3)
  114. require.Equal(t, "turn thinking", byCallID["call_a"].ReasoningContent)
  115. require.Equal(t, "turn thinking", byCallID["call_b"].ReasoningContent,
  116. "链式第二个工具调用必须回放本轮 reasoning")
  117. require.Equal(t, "second turn", byCallID["call_c"].ReasoningContent,
  118. "user 消息后开启新轮次,不得沿用上一轮 reasoning")
  119. // 每一条 assistant 消息都必须带 reasoning_content(DeepSeek 契约)。
  120. for i, m := range out.Messages {
  121. if m.Role == "assistant" {
  122. require.NotEmpty(t, m.ReasoningContent, "messages[%d] 缺 reasoning_content", i)
  123. }
  124. }
  125. }
  126. func TestExtractResponsesReasoningItem(t *testing.T) {
  127. id, text, ok := ExtractResponsesReasoningItem(json.RawMessage(
  128. `{"type":"reasoning","id":"item_a","summary":[{"type":"summary_text","text":"think"}]}`))
  129. require.True(t, ok)
  130. require.Equal(t, "item_a", id)
  131. require.Equal(t, "think", text)
  132. // Encrypted-only item: ok with id but empty text.
  133. id, text, ok = ExtractResponsesReasoningItem(json.RawMessage(
  134. `{"type":"reasoning","id":"item_b","summary":[],"encrypted_content":"opaque"}`))
  135. require.True(t, ok)
  136. require.Equal(t, "item_b", id)
  137. require.Empty(t, text)
  138. // Non-reasoning items are skipped.
  139. _, _, ok = ExtractResponsesReasoningItem(json.RawMessage(
  140. `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}`))
  141. require.False(t, ok)
  142. _, _, ok = ExtractResponsesReasoningItem(json.RawMessage(`"bare string"`))
  143. require.False(t, ok)
  144. }