Skip to content

fix(apicompat): repair tool_use/tool_result pairing on the Responses→Anthropic path#3012

Open
visa2 wants to merge 1 commit into
Wei-Shaw:mainfrom
visa2:fix/responses-anthropic-tool-pairing
Open

fix(apicompat): repair tool_use/tool_result pairing on the Responses→Anthropic path#3012
visa2 wants to merge 1 commit into
Wei-Shaw:mainfrom
visa2:fix/responses-anthropic-tool-pairing

Conversation

@visa2
Copy link
Copy Markdown
Contributor

@visa2 visa2 commented Jun 3, 2026

Problem

When an OpenAI Chat Completions client targets an Anthropic-platform group, ForwardAsChatCompletions converts the request CC → Responses → Anthropic (ChatCompletionsToResponsesResponsesToAnthropicRequest) before forwarding it upstream. The same ResponsesToAnthropicRequest is used by the /v1/responses → Anthropic path.

That converter emits each function_call as its own assistant message and each function_call_output as its own user message, relying solely on mergeConsecutiveMessages to alternate roles. That is not enough to satisfy Anthropic's tool-pairing invariants, so a trimmed or partial tool history produces an upstream 400:

Failed to deserialize / 400: tool_use_id found in tool_result blocks: call_00_...
Each tool_result block must have a corresponding tool_use block in the previous message.

Failure classes left unrepaired:

  • orphan tool_result — a client doing sliding-window context management keeps a recent tool result but drops the assistant tool_calls message that announced it, so the tool_result has no matching tool_use;
  • unanswered / dangling tool_use — a parallel call whose sibling result never came back, or a call left dangling, which Anthropic also rejects.

Approach

Add normalizeAnthropicToolPairing, run between two merge passes:

  1. first merge groups parallel calls and their results so the pairing pass sees them together;
  2. the pairing pass indexes every tool_result by its tool_use id, keeps only answered tool_use blocks (dropping unanswered/dangling calls, and the assistant message entirely when nothing else remains), and re-emits the matching tool_result blocks as the immediately following user message; standalone/orphan tool_results are dropped from their original position;
  3. the second merge restores alternation.

This mirrors normalizeChatMessages on the Responses→Chat path.

Tests

  • responses_to_anthropic_tool_pairing_test.go — repair on direct Responses input (developer message between call and output, parallel both-answered kept grouped, parallel one-unanswered dropped, orphan tool_result, dangling call, single-call baseline).
  • responses_to_anthropic_cc_chain_test.go — drives the real ChatCompletionsToResponses → ResponsesToAnthropicRequest chain and reproduces the production 400 (orphan and unanswered-parallel). Both fail without the repair and pass with it.

go build ./..., the full apicompat suite, and golangci-lint run ./... (v2.9) are all green.

Note on overlap

This touches responses_to_anthropic_request.go, which #2916 also modifies (system from instructions/developer, polymorphic arguments/output, tool schema). The changes are orthogonal — #2916 does not add any tool_use/tool_result pairing repair — but depending on merge order one side may need a trivial rebase.

…Anthropic path

When an OpenAI Chat Completions client targets an Anthropic-platform group,
ForwardAsChatCompletions converts the request CC → Responses → Anthropic
(ChatCompletionsToResponses → ResponsesToAnthropicRequest) before forwarding it
upstream. The Responses→Anthropic converter emits each function_call as its own
assistant message and each function_call_output as its own user message and
relies solely on mergeConsecutiveMessages to alternate roles. That is not enough
to satisfy Anthropic's tool-pairing invariants, so a trimmed or partial tool
history produces an upstream 400, e.g.:

    tool_use_id found in tool_result blocks: call_00_...
    Each tool_result block must have a corresponding tool_use block in the
    previous message.

The failures this leaves unrepaired:

  - orphan tool_result — a client that does sliding-window context management
    keeps a recent tool result but drops the assistant tool_calls message that
    announced it, so the tool_result has no matching tool_use;
  - unanswered/dangling tool_use — a parallel call whose sibling result never
    came back, or a call left dangling, which Anthropic also rejects.

Add normalizeAnthropicToolPairing, run between two merge passes: the first merge
groups parallel calls and their results; the pairing pass indexes every
tool_result by its tool_use id, keeps only answered tool_use blocks (dropping
unanswered/dangling calls, and the assistant message entirely when nothing else
remains) and re-emits the matching tool_result blocks as the immediately
following user message; standalone/orphan tool_results are dropped from their
original position; the second merge restores alternation. This mirrors
normalizeChatMessages on the Responses→Chat path.

Tested two ways: responses_to_anthropic_tool_pairing_test.go covers the repair
on direct Responses input (developer message between call and output, parallel
both-answered kept grouped, parallel one-unanswered dropped, orphan tool_result,
dangling call, single-call baseline); responses_to_anthropic_cc_chain_test.go
drives the real ChatCompletionsToResponses → ResponsesToAnthropicRequest chain
and reproduces the production 400 (orphan and unanswered-parallel) — both fail
without the repair and pass with it. The full apicompat suite stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant