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:
parent
aa69e3947d
commit
60867022b6
@ -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")
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user