diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_cc_chain_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_cc_chain_test.go new file mode 100644 index 00000000..d64680f4 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_cc_chain_test.go @@ -0,0 +1,102 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// These tests drive the exact production path for Chat Completions clients on an +// Anthropic-platform group: ForwardAsChatCompletions runs +// ChatCompletionsToResponses → ResponsesToAnthropicRequest +// (gateway_forward_as_chat_completions.go), then forwards the Anthropic body +// upstream. They assert the tool-pairing repair holds through that full chain, +// not only for codex-style Responses input. +func ccChainToAnthropic(t *testing.T, ccReq *ChatCompletionsRequest) []AnthropicMessage { + t.Helper() + respReq, err := ChatCompletionsToResponses(ccReq) + require.NoError(t, err) + anthReq, err := ResponsesToAnthropicRequest(respReq) + require.NoError(t, err) + assertAnthropicPairing(t, anthReq.Messages) + return anthReq.Messages +} + +// Reproduces the production 400: +// +// unexpected ...content.0: tool_use_id found in tool_result blocks: +// call_00_TgfbRvKlnD7oK6Dg00sL1661. Each tool_result block must have a +// corresponding tool_use block in the previous message. +// +// A Chat Completions client trimmed its history and kept a tool result whose +// announcing assistant tool_calls message was dropped (sliding-window context +// management). The orphan tool_result has no matching tool_use → upstream 400. +// The repair drops the orphan so the request is valid. +func TestCCChain_OrphanToolResultFromTrimmedHistory(t *testing.T) { + orphanID := "call_00_TgfbRvKlnD7oK6Dg00sL1661" + msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{ + Model: "deepseek-v4-pro", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"search the web for X"`)}, + // The assistant tool_calls message that announced orphanID was trimmed. + {Role: "tool", ToolCallID: orphanID, Content: json.RawMessage(`"stale search results"`)}, + {Role: "assistant", Content: json.RawMessage(`"Here is what I found."`)}, + {Role: "user", Content: json.RawMessage(`"thanks, now do Y"`)}, + }, + }) + for _, m := range msgs { + require.Falsef(t, hasToolResult(parseContentBlocks(m.Content), orphanID), + "orphan tool_result %s should have been dropped", orphanID) + } +} + +// A parallel web_search where one sibling's result never came back (the tool +// failed/was skipped). The unanswered tool_use would otherwise trip Anthropic's +// "tool_use without tool_result" check; the repair drops it. +func TestCCChain_ParallelToolOneResultMissing(t *testing.T) { + msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{ + Model: "deepseek-v4-pro", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"search A and B"`)}, + {Role: "assistant", Content: json.RawMessage(`"searching both"`), ToolCalls: []ChatToolCall{ + {ID: "call_a", Type: "function", Function: ChatFunctionCall{Name: "web_search", Arguments: `{"q":"A"}`}}, + {ID: "call_b", Type: "function", Function: ChatFunctionCall{Name: "web_search", Arguments: `{"q":"B"}`}}, + }}, + {Role: "tool", ToolCallID: "call_a", Content: json.RawMessage(`"result A"`)}, + // call_b's result is missing. + }, + }) + for _, m := range msgs { + require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_b"), + "unanswered tool_use call_b should have been dropped") + } +} + +// Baseline: a well-formed multi-round tool history (text + tool_calls per +// assistant turn) converts and pairs correctly through the full chain. +func TestCCChain_WellFormedMultiRound(t *testing.T) { + msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{ + Model: "deepseek-v4-pro", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"do A then B"`)}, + {Role: "assistant", Content: json.RawMessage(`"running A"`), ToolCalls: []ChatToolCall{ + {ID: "call_a", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"cmd":"A"}`}}, + }}, + {Role: "tool", ToolCallID: "call_a", Content: json.RawMessage(`"A ok"`)}, + {Role: "assistant", Content: json.RawMessage(`"A done, running B"`), ToolCalls: []ChatToolCall{ + {ID: "call_b", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"cmd":"B"}`}}, + }}, + {Role: "tool", ToolCallID: "call_b", Content: json.RawMessage(`"B ok"`)}, + {Role: "assistant", Content: json.RawMessage(`"all done"`)}, + }, + }) + // Both calls survive and stay paired (assertAnthropicPairing already checks). + var sawA, sawB bool + for _, m := range msgs { + blocks := parseContentBlocks(m.Content) + sawA = sawA || hasToolUse(blocks, "call_a") + sawB = sawB || hasToolUse(blocks, "call_b") + } + require.True(t, sawA && sawB, "both well-formed calls should be preserved") +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 8fa652f2..672ad80c 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -192,12 +192,135 @@ func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage } } - // Merge consecutive same-role messages (Anthropic requires alternating roles) + // Repair tool_use/tool_result pairing, then merge consecutive same-role + // messages (Anthropic requires alternating roles). The first merge groups + // parallel calls (and their results) so the pairing pass sees them together; + // the pairing pass may re-split a user turn (e.g. when an injected message + // sat between a call and its output), so a second merge restores alternation. + messages = mergeConsecutiveMessages(messages) + messages = normalizeAnthropicToolPairing(messages) messages = mergeConsecutiveMessages(messages) return system, messages, nil } +// normalizeAnthropicToolPairing rebuilds the message sequence so it satisfies +// Anthropic's tool_use/tool_result invariants, which the naive item-by-item +// conversion violates whenever the Responses history interleaves anything +// between a function_call and its function_call_output: +// +// - every tool_result block must have a matching tool_use in the immediately +// preceding assistant message ("tool_result ... must have a corresponding +// tool_use block in the previous message"); +// - every tool_use block must be answered by a tool_result in the immediately +// following user message (Anthropic rejects unanswered tool_use ids); +// - user/assistant turns must alternate. +// +// codex (Responses, store:false) re-sends the whole history each turn and +// frequently injects items between a call and its output — a developer/approval +// notice, or a sibling parallel call whose output never arrived. The unrepaired +// converter emits each function_call as its own assistant message and each +// output as its own user message, so any such interleaving breaks +// tool_use↔tool_result adjacency and yields an upstream 400. +// +// The repair indexes every tool_result by its tool_use id, then for each +// assistant message carrying tool_use blocks keeps only the answered ones +// (dropping unanswered/dangling calls — and the assistant message entirely if it +// has no other content) and emits the matching tool_result blocks, in call +// order, as the very next user message. Standalone tool_result blocks are +// dropped from their original position (re-emitted adjacent to their call); +// orphan tool_results with no announcing tool_use are dropped. Non-tool content +// passes through in place. This mirrors normalizeChatMessages on the +// Responses→Chat path. +func normalizeAnthropicToolPairing(messages []AnthropicMessage) []AnthropicMessage { + // Index every tool_result block by its tool_use id (last wins on dup). + results := make(map[string]AnthropicContentBlock) + for _, m := range messages { + if m.Role != "user" { + continue + } + for _, b := range parseContentBlocks(m.Content) { + if b.Type == "tool_result" && b.ToolUseID != "" { + results[b.ToolUseID] = b + } + } + } + + out := make([]AnthropicMessage, 0, len(messages)) + for _, m := range messages { + blocks := parseContentBlocks(m.Content) + switch m.Role { + case "assistant": + var toolUses, others []AnthropicContentBlock + for _, b := range blocks { + if b.Type == "tool_use" { + toolUses = append(toolUses, b) + } else { + others = append(others, b) + } + } + if len(toolUses) == 0 { + out = append(out, m) + continue + } + kept := make([]AnthropicContentBlock, 0, len(toolUses)) + for _, tu := range toolUses { + if _, ok := results[tu.ID]; ok { + kept = append(kept, tu) + } + } + if len(kept) == 0 { + // No answered calls: keep any non-tool content, else drop. + if len(others) > 0 { + out = append(out, anthropicMessageFromBlocks("assistant", others)) + } + continue + } + asstBlocks := make([]AnthropicContentBlock, 0, len(others)+len(kept)) + asstBlocks = append(asstBlocks, others...) + asstBlocks = append(asstBlocks, kept...) + out = append(out, anthropicMessageFromBlocks("assistant", asstBlocks)) + + resBlocks := make([]AnthropicContentBlock, 0, len(kept)) + for _, tu := range kept { + resBlocks = append(resBlocks, results[tu.ID]) + } + out = append(out, anthropicMessageFromBlocks("user", resBlocks)) + + case "user": + var nonResult []AnthropicContentBlock + hasResult := false + for _, b := range blocks { + if b.Type == "tool_result" { + hasResult = true + continue + } + nonResult = append(nonResult, b) + } + if !hasResult { + out = append(out, m) + continue + } + // The tool_result blocks are re-emitted next to their call; keep any + // other content of this user turn in place, drop it if there is none. + if len(nonResult) > 0 { + out = append(out, anthropicMessageFromBlocks("user", nonResult)) + } + + default: + out = append(out, m) + } + } + return out +} + +// anthropicMessageFromBlocks builds an AnthropicMessage whose content is the +// marshaled block array. +func anthropicMessageFromBlocks(role string, blocks []AnthropicContentBlock) AnthropicMessage { + content, _ := json.Marshal(blocks) + return AnthropicMessage{Role: role, Content: content} +} + // extractTextFromContent extracts text from a content field that may be a // plain string or an array of content parts. func extractTextFromContent(raw json.RawMessage) string { diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go new file mode 100644 index 00000000..b2522f27 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -0,0 +1,165 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// assertAnthropicPairing enforces the Anthropic Messages tool-pairing invariants +// that, when violated, surface as upstream 400s. +func assertAnthropicPairing(t *testing.T, messages []AnthropicMessage) { + t.Helper() + for i, m := range messages { + blocks := parseContentBlocks(m.Content) + + // No two consecutive same-role messages. + if i > 0 { + require.NotEqualf(t, messages[i-1].Role, m.Role, "consecutive %s messages at %d", m.Role, i) + } + + for _, b := range blocks { + switch b.Type { + case "tool_result": + // Must have a matching tool_use in the immediately previous message. + require.Positivef(t, i, "tool_result %s has no previous message", b.ToolUseID) + prev := parseContentBlocks(messages[i-1].Content) + require.Truef(t, hasToolUse(prev, b.ToolUseID), + "tool_result %s has no corresponding tool_use in previous message", b.ToolUseID) + case "tool_use": + // Must be answered by a tool_result in the immediately next message. + require.Lessf(t, i+1, len(messages), "tool_use %s has no following message", b.ID) + next := parseContentBlocks(messages[i+1].Content) + require.Truef(t, hasToolResult(next, b.ID), + "tool_use %s is not answered in the next message", b.ID) + } + } + } +} + +func hasToolUse(blocks []AnthropicContentBlock, id string) bool { + for _, b := range blocks { + if b.Type == "tool_use" && b.ID == id { + return true + } + } + return false +} + +func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool { + for _, b := range blocks { + if b.Type == "tool_result" && b.ToolUseID == toolUseID { + return true + } + } + return false +} + +func convertAnthropic(t *testing.T, input string) []AnthropicMessage { + t.Helper() + _, messages, err := convertResponsesInputToAnthropic(json.RawMessage(input)) + require.NoError(t, err) + assertAnthropicPairing(t, messages) + return messages +} + +// Tests use call_-prefixed ids because fromResponsesCallIDToAnthropic passes +// those through unchanged (matching codex's real call_00_... ids); bare ids +// would be rewritten to toolu_. + +// A developer/approval message injected between a function_call and its output +// must be moved out of the tool_use→tool_result adjacency. This is the shape +// that produced the production 400 "tool_result ... must have a corresponding +// tool_use block in the previous message". +func TestAnthropicPairing_DeveloperMessageBetween(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]}, + {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"}, + {"type":"message","role":"developer","content":[{"type":"input_text","text":"Approved command prefix saved"}]}, + {"type":"function_call_output","call_id":"call_A","output":"ok"} + ]`) + // The assistant tool_use message is immediately followed by its tool_result. + for i, m := range msgs { + if hasToolUse(parseContentBlocks(m.Content), "call_A") { + require.Equal(t, "user", msgs[i+1].Role) + require.True(t, hasToolResult(parseContentBlocks(msgs[i+1].Content), "call_A")) + } + } +} + +// Parallel tool calls where both outputs arrive stay grouped: one assistant +// message with both tool_use blocks, the next user message with both results. +func TestAnthropicPairing_ParallelBothAnswered(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"features?"}]}, + {"type":"function_call","call_id":"call_c0","name":"exec","arguments":"{}"}, + {"type":"function_call","call_id":"call_c1","name":"exec","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_c0","output":"log"}, + {"type":"function_call_output","call_id":"call_c1","output":"tags"} + ]`) + var sawGrouped bool + for _, m := range msgs { + blocks := parseContentBlocks(m.Content) + if hasToolUse(blocks, "call_c0") && hasToolUse(blocks, "call_c1") { + sawGrouped = true + } + } + require.True(t, sawGrouped, "parallel tool_use blocks should share one assistant message") +} + +// A parallel call whose sibling output never arrived must be dropped so every +// remaining tool_use is answered. +func TestAnthropicPairing_ParallelOneUnanswered(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}, + {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"}, + {"type":"function_call","call_id":"call_B","name":"exec","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_A","output":"oa"} + ]`) + for _, m := range msgs { + require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_B"), + "unanswered tool_use call_B should have been dropped") + } +} + +// An orphan tool_result whose tool_use was never announced must be dropped. +func TestAnthropicPairing_OrphanToolResultDropped(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}, + {"type":"function_call_output","call_id":"call_ghost","output":"orphan"} + ]`) + for _, m := range msgs { + require.Falsef(t, hasToolResult(parseContentBlocks(m.Content), "call_ghost"), + "orphan tool_result should have been dropped") + } +} + +// A dangling tool_call at the end of the history (no output yet) drops the +// assistant message holding only that call, leaving no tool_use behind. +func TestAnthropicPairing_DanglingCallDropped(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}, + {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"} + ]`) + for _, m := range msgs { + require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_A"), + "dangling tool_use call_A should have been dropped") + } +} + +// Baseline: a single answered call pairs correctly and preserves the surrounding +// turns. +func TestAnthropicPairing_SingleCall(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"latest sha?"}]}, + {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{\"cmd\":\"git rev-parse HEAD\"}"}, + {"type":"function_call_output","call_id":"call_A","output":"deadbeef"}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"It is deadbeef."}]} + ]`) + // user, assistant(tool_use), user(tool_result), assistant(text) + require.GreaterOrEqual(t, len(msgs), 4) + require.Equal(t, "user", msgs[0].Role) + require.True(t, hasToolUse(parseContentBlocks(msgs[1].Content), "call_A")) + require.True(t, hasToolResult(parseContentBlocks(msgs[2].Content), "call_A")) +}