responses_to_anthropic_cc_chain_test.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. // These tests drive the exact production path for Chat Completions clients on an
  8. // Anthropic-platform group: ForwardAsChatCompletions runs
  9. // ChatCompletionsToResponses → ResponsesToAnthropicRequest
  10. // (gateway_forward_as_chat_completions.go), then forwards the Anthropic body
  11. // upstream. They assert the tool-pairing repair holds through that full chain,
  12. // not only for codex-style Responses input.
  13. func ccChainToAnthropic(t *testing.T, ccReq *ChatCompletionsRequest) []AnthropicMessage {
  14. t.Helper()
  15. respReq, err := ChatCompletionsToResponses(ccReq)
  16. require.NoError(t, err)
  17. anthReq, err := ResponsesToAnthropicRequest(respReq)
  18. require.NoError(t, err)
  19. assertAnthropicPairing(t, anthReq.Messages)
  20. return anthReq.Messages
  21. }
  22. // Reproduces the production 400:
  23. //
  24. // unexpected ...content.0: tool_use_id found in tool_result blocks:
  25. // call_00_TgfbRvKlnD7oK6Dg00sL1661. Each tool_result block must have a
  26. // corresponding tool_use block in the previous message.
  27. //
  28. // A Chat Completions client trimmed its history and kept a tool result whose
  29. // announcing assistant tool_calls message was dropped (sliding-window context
  30. // management). The orphan tool_result has no matching tool_use → upstream 400.
  31. // The repair drops the orphan so the request is valid.
  32. func TestCCChain_OrphanToolResultFromTrimmedHistory(t *testing.T) {
  33. orphanID := "call_00_TgfbRvKlnD7oK6Dg00sL1661"
  34. msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{
  35. Model: "deepseek-v4-pro",
  36. Messages: []ChatMessage{
  37. {Role: "user", Content: json.RawMessage(`"search the web for X"`)},
  38. // The assistant tool_calls message that announced orphanID was trimmed.
  39. {Role: "tool", ToolCallID: orphanID, Content: json.RawMessage(`"stale search results"`)},
  40. {Role: "assistant", Content: json.RawMessage(`"Here is what I found."`)},
  41. {Role: "user", Content: json.RawMessage(`"thanks, now do Y"`)},
  42. },
  43. })
  44. for _, m := range msgs {
  45. require.Falsef(t, hasToolResult(parseContentBlocks(m.Content), orphanID),
  46. "orphan tool_result %s should have been dropped", orphanID)
  47. }
  48. }
  49. // A parallel web_search where one sibling's result never came back (the tool
  50. // failed/was skipped). The unanswered tool_use would otherwise trip Anthropic's
  51. // "tool_use without tool_result" check; the repair drops it.
  52. func TestCCChain_ParallelToolOneResultMissing(t *testing.T) {
  53. msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{
  54. Model: "deepseek-v4-pro",
  55. Messages: []ChatMessage{
  56. {Role: "user", Content: json.RawMessage(`"search A and B"`)},
  57. {Role: "assistant", Content: json.RawMessage(`"searching both"`), ToolCalls: []ChatToolCall{
  58. {ID: "call_a", Type: "function", Function: ChatFunctionCall{Name: "web_search", Arguments: `{"q":"A"}`}},
  59. {ID: "call_b", Type: "function", Function: ChatFunctionCall{Name: "web_search", Arguments: `{"q":"B"}`}},
  60. }},
  61. {Role: "tool", ToolCallID: "call_a", Content: json.RawMessage(`"result A"`)},
  62. // call_b's result is missing.
  63. },
  64. })
  65. for _, m := range msgs {
  66. require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_b"),
  67. "unanswered tool_use call_b should have been dropped")
  68. }
  69. }
  70. // Baseline: a well-formed multi-round tool history (text + tool_calls per
  71. // assistant turn) converts and pairs correctly through the full chain.
  72. func TestCCChain_WellFormedMultiRound(t *testing.T) {
  73. msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{
  74. Model: "deepseek-v4-pro",
  75. Messages: []ChatMessage{
  76. {Role: "user", Content: json.RawMessage(`"do A then B"`)},
  77. {Role: "assistant", Content: json.RawMessage(`"running A"`), ToolCalls: []ChatToolCall{
  78. {ID: "call_a", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"cmd":"A"}`}},
  79. }},
  80. {Role: "tool", ToolCallID: "call_a", Content: json.RawMessage(`"A ok"`)},
  81. {Role: "assistant", Content: json.RawMessage(`"A done, running B"`), ToolCalls: []ChatToolCall{
  82. {ID: "call_b", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"cmd":"B"}`}},
  83. }},
  84. {Role: "tool", ToolCallID: "call_b", Content: json.RawMessage(`"B ok"`)},
  85. {Role: "assistant", Content: json.RawMessage(`"all done"`)},
  86. },
  87. })
  88. // Both calls survive and stay paired (assertAnthropicPairing already checks).
  89. var sawA, sawB bool
  90. for _, m := range msgs {
  91. blocks := parseContentBlocks(m.Content)
  92. sawA = sawA || hasToolUse(blocks, "call_a")
  93. sawB = sawB || hasToolUse(blocks, "call_b")
  94. }
  95. require.True(t, sawA && sawB, "both well-formed calls should be preserved")
  96. }