fix(apicompat): repair tool_use/tool_result pairing on the Responses→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>
This commit is contained in:
visa2 2026-06-03 17:26:05 +08:00
parent aa69e3947d
commit 60867022b6
3 changed files with 391 additions and 1 deletions

View File

@ -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")
}

View File

@ -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 {

View File

@ -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_<id>.
// 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"))
}