From 131a42a169e96586b4825789743fe1b987e66233 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 5 Sep 2026 22:18:31 +0800 Subject: [PATCH] fix(adapters): sanitize tool-call ids so one bad upstream can't kill every Claude slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic requires tool_use.id / tool_result.tool_use_id to match ^[a-zA-Z0-9_-]{1,64}$ and rejects the WHOLE request otherwise with REQUEST_BODY_INVALID / "Invalid tool use format". OpenAI has no such rule, so an OpenAI-compatible model can mint an id like "bash:0" (xinjianya/moonshotai/kimi-k3 does exactly that). In a fan-out router that id does not stay local: the client stores it in its history and replays it to every other source. One such id therefore kills every Claude slot at once — justwoker, tabitoken and 扇贝 are all Claude-behind-{OpenAI,Anthropic} — and an AUTO request falls through all four tiers to whatever tolerant model is left. Observed live: 4 consecutive 503 "all N auto providers failed" with tier 1/2/3 each reporting the same 400. Both directions are sanitized, in both adapters: - request: tool_calls[].id and tool_call_id, so poisoned history recovers - response: non-streaming tool_calls[].id and the first streamed fragment, so a bad id never enters a client session in the first place safe_tool_id is pure and deterministic, so a call and its result are rewritten identically within one request. A rewritten id keeps an 8-hex digest of the original, without which distinct ids could collapse ("a:b" and "a_b") into a duplicate/unpaired tool_use. Already-legal ids pass through byte-identical, so well-behaved traffic is unaffected. openai.lua carries its own copy because Lua adapters have no shared prelude. Streamed argument fragments carry no id and must stay id-less, otherwise index-based accumulation on the client breaks; a test pins that. --- internal/lua/adapters/anthropic.lua | 36 +++- internal/lua/adapters/openai.lua | 51 +++++- internal/lua/vm_test.go | 260 ++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 5 deletions(-) diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index 9470665..30eeed3 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -14,6 +14,34 @@ local ROLE_WHITELIST = { system = true, user = true, assistant = true, tool = true, } +-- Anthropic requires tool_use.id / tool_result.tool_use_id to match +-- ^[a-zA-Z0-9_-]{1,64}$ and rejects the WHOLE request otherwise with +-- REQUEST_BODY_INVALID / "Invalid tool use format". OpenAI imposes no such +-- rule, so an OpenAI-compatible upstream can hand a client an id like +-- "bash:0" (observed from moonshotai/kimi-k3); once that lands in the +-- client's history, every later replay kills every Claude slot at once and +-- an AUTO chain falls through all tiers. +-- +-- safe_tool_id is pure and deterministic, so a tool_use block and its +-- matching tool_result are rewritten identically within one request. A +-- rewritten id keeps an 8-hex digest of the ORIGINAL id: without it two +-- distinct ids could collapse into one ("a:b" and "a_b"), which Anthropic +-- rejects as a duplicate/unpaired tool_use. Already-legal ids pass through +-- untouched so upstreams that round-trip their own ids are unaffected. +local TOOL_ID_MAX = 64 + +local function safe_tool_id(id) + if type(id) ~= "string" or id == "" then return "" end + local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_") + if clean == id and #clean <= TOOL_ID_MAX then + return clean + end + local digest = string.sub(sha256_hex(id), 1, 8) + local keep = TOOL_ID_MAX - #digest - 1 + if #clean > keep then clean = string.sub(clean, 1, keep) end + return clean .. "_" .. digest +end + local function collect_blocks(content) if type(content) == "string" then if content == "" then return {} end @@ -104,7 +132,7 @@ function adapter.transform_request(raw_body) end table.insert(blocks, { type = "tool_use", - id = tc.id or "", + id = safe_tool_id(tc.id), name = fn.name or "", input = input, }) @@ -119,7 +147,7 @@ function adapter.transform_request(raw_body) -- user message with tool_result content blocks. table.insert(pending_tool, { type = "tool_result", - tool_use_id = m.tool_call_id or "", + tool_use_id = safe_tool_id(m.tool_call_id), content = text_of(m.content), }) @@ -242,7 +270,7 @@ function adapter.transform_response(raw_body) unified.reasoning_content = (unified.reasoning_content or "") .. block.thinking elseif block.type == "tool_use" then table.insert(tcs, { - id = block.id or "", + id = safe_tool_id(block.id), type = "function", name = block.name or "", arguments = block.input or {}, @@ -330,7 +358,7 @@ function adapter.transform_stream_chunk(raw_chunk) content = "", done = false, tool_calls = { { index = chunk.index or 0, - id = cb.id or "", + id = safe_tool_id(cb.id), type = "function", ["function"] = { name = cb.name or "", arguments = "" }, } }, diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index b051a4e..2e1e0b0 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -5,6 +5,36 @@ adapter.version = "2.0.0" adapter.endpoint = "/chat/completions" adapter.headers = {} +-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions +-- to the Anthropic Messages API internally, so they inherit Anthropic's +-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request +-- with "Invalid tool use format" / "tool_use.id: String should match pattern". +-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id +-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id +-- is replayed to every other source, so one such id kills every Claude slot at +-- once and an AUTO request falls through all tiers. +-- +-- safe_tool_id is pure and deterministic, so a tool_calls entry and its +-- matching tool_call_id are rewritten identically within one request. A +-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two +-- distinct ids could collapse into one ("a:b" and "a_b") and become an +-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so +-- well-behaved traffic is byte-identical to before. +-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.) +local TOOL_ID_MAX = 64 + +local function safe_tool_id(id) + if type(id) ~= "string" or id == "" then return id end + local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_") + if clean == id and #clean <= TOOL_ID_MAX then + return clean + end + local digest = string.sub(sha256_hex(id), 1, 8) + local keep = TOOL_ID_MAX - #digest - 1 + if #clean > keep then clean = string.sub(clean, 1, keep) end + return clean .. "_" .. digest +end + -- OpenAI /chat/completions format (pass-through, strip provider-specific fields) function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) @@ -14,6 +44,16 @@ function adapter.transform_request(raw_body) if req.messages then for _, msg in ipairs(req.messages) do msg.reasoning_content = nil + if msg.tool_call_id ~= nil then + msg.tool_call_id = safe_tool_id(msg.tool_call_id) + end + if type(msg.tool_calls) == "table" then + for _, tc in ipairs(msg.tool_calls) do + if type(tc) == "table" and tc.id ~= nil then + tc.id = safe_tool_id(tc.id) + end + end + end end end return json.encode(req) @@ -64,7 +104,7 @@ function adapter.transform_response(raw_body) local args_ok, args = pcall(json.decode, tc["function"].arguments) if not args_ok then args = {} end table.insert(tcs, { - id = tc.id, + id = safe_tool_id(tc.id), type = tc.type or "function", name = tc["function"].name, arguments = args @@ -129,6 +169,15 @@ function adapter.transform_stream_chunk(raw_chunk) unified.reasoning_content = delta.reasoning_content end if delta.tool_calls then + -- Sanitize on the way OUT too: an id this upstream happily minted (it + -- does not validate them) becomes a landmine once the client replays + -- it to a Claude upstream. Only the first fragment of a streamed call + -- carries an id; later argument fragments have none and are untouched. + for _, tc in ipairs(delta.tool_calls) do + if type(tc) == "table" and tc.id ~= nil then + tc.id = safe_tool_id(tc.id) + end + end unified.tool_calls = delta.tool_calls end return json.encode(unified) diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index 46f1feb..2a4f47d 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" "strings" "sync" "testing" @@ -1599,3 +1600,262 @@ func TestTraeTextToolCallRecovery(t *testing.T) { t.Errorf("plain answer finish_reason changed: %s", out) } } + +// TestToolIDSanitize locks the tool-call id sanitizer in both adapters that +// talk to Claude upstreams. Anthropic requires tool_use.id / +// tool_result.tool_use_id to match ^[a-zA-Z0-9_-]{1,64}$ and rejects the WHOLE +// request otherwise (REQUEST_BODY_INVALID / "Invalid tool use format"), while +// OpenAI has no such rule — so an OpenAI-compatible model can mint "bash:0" +// (observed from moonshotai/kimi-k3) and poison a client's history, killing +// every Claude slot on replay. tabitoken/扇贝 are Claude-behind-OpenAI, so +// openai.lua needs the same treatment as anthropic.lua. +func TestToolIDSanitize(t *testing.T) { + vm := NewVM(freshAdapterDir(t)) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + legal := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + long := strings.Repeat("a", 200) + + body := func(id string) string { + b, err := json.Marshal(map[string]interface{}{ + "model": "x", + "messages": []interface{}{ + map[string]interface{}{"role": "user", "content": "run whoami"}, + map[string]interface{}{"role": "assistant", "content": "", "tool_calls": []interface{}{ + map[string]interface{}{"id": id, "type": "function", + "function": map[string]interface{}{"name": "bash", "arguments": `{"command":"whoami"}`}}, + }}, + map[string]interface{}{"role": "tool", "tool_call_id": id, "content": "root"}, + }, + }) + if err != nil { + t.Fatal(err) + } + return string(b) + } + + // ---- anthropic: tool_use.id and tool_result.tool_use_id must agree ---- + for _, id := range []string{"bash:0", "call_ok_123", long, "toolu~sig1:AB+/=="} { + out, err := vm.Transform("anthropic", "transform_request", body(id)) + if err != nil { + t.Fatalf("anthropic transform %q: %v", id, err) + } + var r struct { + Messages []struct { + Content []struct { + Type string `json:"type"` + ID string `json:"id"` + ToolUseID string `json:"tool_use_id"` + } `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatalf("anthropic unmarshal %q: %v (%s)", id, err, out) + } + var useID, resID string + for _, m := range r.Messages { + for _, c := range m.Content { + switch c.Type { + case "tool_use": + useID = c.ID + case "tool_result": + resID = c.ToolUseID + } + } + } + if !legal.MatchString(useID) { + t.Errorf("anthropic %q: tool_use.id %q violates Anthropic's id pattern", id, useID) + } + if useID != resID { + t.Errorf("anthropic %q: tool_use.id %q != tool_result.tool_use_id %q (unpaired call)", id, useID, resID) + } + if legal.MatchString(id) && useID != id { + t.Errorf("anthropic %q: already-legal id must pass through untouched, got %q", id, useID) + } + } + + // ---- openai: same rewrite, and both sites must stay in sync ---- + for _, id := range []string{"bash:0", "call_ok_123", long} { + out, err := vm.Transform("openai", "transform_request", body(id)) + if err != nil { + t.Fatalf("openai transform %q: %v", id, err) + } + var r struct { + Messages []struct { + Role string `json:"role"` + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls"` + ToolCallID string `json:"tool_call_id"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatalf("openai unmarshal %q: %v (%s)", id, err, out) + } + var callID, resID string + for _, m := range r.Messages { + if len(m.ToolCalls) > 0 { + callID = m.ToolCalls[0].ID + } + if m.Role == "tool" { + resID = m.ToolCallID + } + } + if !legal.MatchString(callID) { + t.Errorf("openai %q: tool_calls[0].id %q still illegal for Claude-behind-OpenAI upstreams", id, callID) + } + if callID != resID { + t.Errorf("openai %q: tool_calls id %q != tool_call_id %q (unpaired call)", id, callID, resID) + } + if legal.MatchString(id) && callID != id { + t.Errorf("openai %q: already-legal id must pass through untouched, got %q", id, callID) + } + } + + // ---- distinct dirty ids must NOT collapse into one (digest suffix) ---- + seen := map[string]string{} + for _, id := range []string{"a:b", "a_b", "a-b", "a b", "a/b"} { + out, err := vm.Transform("anthropic", "transform_request", body(id)) + if err != nil { + t.Fatalf("collision probe %q: %v", id, err) + } + var r struct { + Messages []struct { + Content []struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatal(err) + } + got := "" + for _, m := range r.Messages { + for _, c := range m.Content { + if c.Type == "tool_use" { + got = c.ID + } + } + } + if prev, dup := seen[got]; dup { + t.Errorf("ids %q and %q both map to %q — a collision makes a duplicate tool_use", prev, id, got) + } + seen[got] = id + } + + // ---- determinism: same input, same output ---- + a, _ := vm.Transform("anthropic", "transform_request", body("bash:0")) + b, _ := vm.Transform("anthropic", "transform_request", body("bash:0")) + if a != b { + t.Errorf("sanitizer is not deterministic:\n%s\n%s", a, b) + } +} + +// TestToolIDSanitizeResponse locks the OUTBOUND half of the sanitizer. The +// request-side rewrite alone is not enough: an id minted by a permissive +// OpenAI-compatible upstream (moonshotai/kimi-k3 returns "bash:0") is handed to +// the client, stored in its history, and replayed forever after. Sanitizing on +// the way out means a poisoned id never enters a client session in the first +// place — both for non-streaming responses and for the first fragment of a +// streamed tool call (later argument fragments carry no id). +func TestToolIDSanitizeResponse(t *testing.T) { + vm := NewVM(freshAdapterDir(t)) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + legal := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + + // ---- openai non-streaming ---- + resp := `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[ + {"id":"bash:0","type":"function","function":{"name":"bash","arguments":"{\"command\":\"whoami\"}"}}]}, + "finish_reason":"tool_calls"}]}` + out, err := vm.Transform("openai", "transform_response", resp) + if err != nil { + t.Fatalf("openai transform_response: %v", err) + } + var r struct { + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls"` + } + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatalf("unmarshal: %v (%s)", err, out) + } + if len(r.ToolCalls) != 1 { + t.Fatalf("tool_calls lost: %s", out) + } + if !legal.MatchString(r.ToolCalls[0].ID) { + t.Errorf("non-stream response leaks illegal id %q to the client", r.ToolCalls[0].ID) + } + nonStreamID := r.ToolCalls[0].ID + + // ---- openai streaming: first fragment carries the id ---- + chunk := `{"choices":[{"delta":{"tool_calls":[ + {"index":0,"id":"bash:0","type":"function","function":{"name":"bash","arguments":""}}]}, + "finish_reason":null}]}` + out, err = vm.Transform("openai", "transform_stream_chunk", chunk) + if err != nil { + t.Fatalf("openai transform_stream_chunk: %v", err) + } + var c struct { + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls"` + } + if err := json.Unmarshal([]byte(out), &c); err != nil { + t.Fatalf("unmarshal chunk: %v (%s)", err, out) + } + if len(c.ToolCalls) != 1 { + t.Fatalf("stream tool_calls lost: %s", out) + } + if !legal.MatchString(c.ToolCalls[0].ID) { + t.Errorf("stream response leaks illegal id %q to the client", c.ToolCalls[0].ID) + } + // Streaming and non-streaming must agree, otherwise a client that mixes + // modes within one session produces unpaired tool calls. + if c.ToolCalls[0].ID != nonStreamID { + t.Errorf("stream id %q != non-stream id %q for the same input", c.ToolCalls[0].ID, nonStreamID) + } + + // ---- a later argument fragment has no id and must stay id-less ---- + argChunk := `{"choices":[{"delta":{"tool_calls":[ + {"index":0,"function":{"arguments":"{\"command\":\"whoami\"}"}}]},"finish_reason":null}]}` + out, err = vm.Transform("openai", "transform_stream_chunk", argChunk) + if err != nil { + t.Fatalf("arg fragment: %v", err) + } + if strings.Contains(out, `"id"`) { + t.Errorf("argument fragment gained an id (breaks index-based accumulation): %s", out) + } + + // ---- anthropic response side: tool_use id and stream start ---- + aResp := `{"content":[{"type":"tool_use","id":"toolu~sig1:AB+/==","name":"bash","input":{"command":"whoami"}}],"stop_reason":"tool_use"}` + out, err = vm.Transform("anthropic", "transform_response", aResp) + if err != nil { + t.Fatalf("anthropic transform_response: %v", err) + } + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatalf("unmarshal anthropic: %v (%s)", err, out) + } + if len(r.ToolCalls) != 1 || !legal.MatchString(r.ToolCalls[0].ID) { + t.Errorf("anthropic response leaks illegal id: %s", out) + } + + aChunk := `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu~sig1:AB+/==","name":"bash"}}` + out, err = vm.Transform("anthropic", "transform_stream_chunk", aChunk) + if err != nil { + t.Fatalf("anthropic stream chunk: %v", err) + } + if err := json.Unmarshal([]byte(out), &c); err != nil { + t.Fatalf("unmarshal anthropic chunk: %v (%s)", err, out) + } + if len(c.ToolCalls) != 1 || !legal.MatchString(c.ToolCalls[0].ID) { + t.Errorf("anthropic stream leaks illegal id: %s", out) + } +}