From f97b8534602f54af13d0d3f096d87ae20103683e Mon Sep 17 00:00:00 2001 From: iFwu Date: Sun, 10 May 2026 18:01:19 +0800 Subject: [PATCH] fix(mimic): rewrite tool_use names in messages to match renamed tools The Claude Code mimic path rewrites tool names in tools[] (and tool_choice) but left tool_use blocks in messages[] with their original names. Anthropic validates that every tool referenced by a tool_use block is declared in tools[], so the mismatch produces: messages.N.content.M: Input tag 'original_name' not found in tools (surfaced as HTTP 400 directly, or wrapped as 424 by upstream proxies such as Bedrock gateways.) The previous code comment asserted 'this matches Parrot; response-side bytes.Replace will restore the names'. Parrot's behavior is fine for Claude Code's own tool set, but breaks once the upstream client sends additional tools (e.g. web_search) that are not part of Claude Code and therefore get renamed here. Fix: apply the same ToolNameRewrite to messages[].content[] blocks where type == 'tool_use', keeping tools[], tool_choice and messages self-consistent before the request reaches Anthropic. tool_result blocks reference tools via tool_use_id, not name, so no change is needed there. A new unit test covers the full rewrite flow and guards against server tools (type != '') being affected. --- .../internal/service/gateway_tool_rewrite.go | 44 ++++++++++++++++--- .../service/gateway_tool_rewrite_test.go | 24 ++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/gateway_tool_rewrite.go b/backend/internal/service/gateway_tool_rewrite.go index c76cab62d72..ff174d90863 100644 --- a/backend/internal/service/gateway_tool_rewrite.go +++ b/backend/internal/service/gateway_tool_rewrite.go @@ -168,13 +168,13 @@ func buildToolNameRewriteFromBody(body []byte) *ToolNameRewrite { } // applyToolNameRewriteToBody 把已构造的 ToolNameRewrite 应用到 body 上: -// - 改写 $.tools[*].name(仅对 shouldMimicToolName 通过的 tool) -// - 在 $.tools[last].cache_control 上打 ephemeral 缓存断点(Parrot 行为对齐, -// ttl 客户端已有则透传,否则默认 claude.DefaultCacheControlTTL) -// - 改写 $.tool_choice.name(仅当 $.tool_choice.type == "tool") // -// 历史 $.messages[*].content[*].name(tool_use)不在请求侧改写——这与 Parrot 一致; -// 响应侧 bytes.Replace 会连带还原它们。 +// - 改写 $.tools[*].name(仅对 shouldMimicToolName 通过的 tool) +// - 改写 $.tool_choice.name(仅当 $.tool_choice.type == "tool") +// - 改写 $.messages[*].content[*].name(仅当 type == "tool_use") +// - 在 $.tools[last].cache_control 上打 ephemeral 缓存断点 +// +// 响应侧 bytes.Replace 会连带还原假名 → 真名。 func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte { if rw == nil || len(rw.Forward) == 0 { body = applyToolsLastCacheBreakpoint(body) @@ -213,6 +213,38 @@ func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte { } } + // Rewrite tool_use names in messages to match the renamed tools. + // Without this, Anthropic rejects requests where messages reference tools + // by their original name but tools[] declares the renamed (fake) name. + messages := gjson.GetBytes(body, "messages") + if messages.IsArray() { + messages.ForEach(func(msgKey, msg gjson.Result) bool { + msgIdx := int(msgKey.Num) + content := msg.Get("content") + if !content.IsArray() { + return true + } + content.ForEach(func(blkKey, blk gjson.Result) bool { + blkIdx := int(blkKey.Num) + if blk.Get("type").String() != "tool_use" { + return true + } + name := blk.Get("name").String() + if name == "" { + return true + } + if fake, ok := rw.Forward[name]; ok { + path := fmt.Sprintf("messages.%d.content.%d.name", msgIdx, blkIdx) + if next, err := sjson.SetBytes(body, path, fake); err == nil { + body = next + } + } + return true + }) + return true + }) + } + body = applyToolsLastCacheBreakpoint(body) return body } diff --git a/backend/internal/service/gateway_tool_rewrite_test.go b/backend/internal/service/gateway_tool_rewrite_test.go index 8f0e3939c38..ce94ba095a8 100644 --- a/backend/internal/service/gateway_tool_rewrite_test.go +++ b/backend/internal/service/gateway_tool_rewrite_test.go @@ -84,6 +84,30 @@ func TestApplyToolNameRewriteToBody_RenamesToolsAndToolChoice(t *testing.T) { require.Equal(t, "tool", gjson.GetBytes(out, "tool_choice.type").String()) } + +func TestApplyToolNameRewriteToBody_RenamesToolUseInMessages(t *testing.T) { + // sessions_list -> cc_sess_list (static prefix: sessions_ -> sessions_) + // web_search is a server tool (type != ""), not rewritten + // messages tool_use names must be rewritten to match tools[] + body := []byte(`{"tools":[{"name":"sessions_list","input_schema":{}},{"name":"web_search","type":"web_search_20250305"}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},{"role":"assistant","content":[{"type":"tool_use","id":"tu_01","name":"sessions_list","input":{}},{"type":"text","text":"thinking"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_01","content":"ok"}]}]}`) + rw := buildToolNameRewriteFromBody(body) + require.NotNil(t, rw) + require.Equal(t, "cc_sess_list", rw.Forward["sessions_list"]) + + out := applyToolNameRewriteToBody(body, rw) + + // tools[0].name rewritten + require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tools.0.name").String()) + // tools[1].name untouched (server tool) + require.Equal(t, "web_search", gjson.GetBytes(out, "tools.1.name").String()) + // messages[1].content[0].name (tool_use) also rewritten to match tools + require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "messages.1.content.0.name").String()) + // messages[1].content[1] (text) untouched + require.Equal(t, "thinking", gjson.GetBytes(out, "messages.1.content.1.text").String()) + // messages[2].content[0] (tool_result) untouched — no name field in tool_result + require.Equal(t, "ok", gjson.GetBytes(out, "messages.2.content.0.content").String()) +} + func TestApplyToolsLastCacheBreakpoint_InjectsDefault(t *testing.T) { body := []byte(`{"tools":[{"name":"a","input_schema":{}},{"name":"b","input_schema":{}}]}`) out := applyToolsLastCacheBreakpoint(body)