mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
Merge feature/toolcall-id-sanitize: 一个上游的脏 tool_call id 不该拖垮整条 Claude 链
xinjianya/moonshotai/kimi-k3 会吐出 'bash:0' 这种 id。OpenAI 不校验,
Anthropic 校验 ^[a-zA-Z0-9_-]{1,64}$ 且整条请求直接 400。客户端会把它存进
历史并回放给所有源,于是 justwoker / tabitoken / 扇贝(都是 Claude 上游)
同时挂掉,AUTO 一路穿透四个 tier。
两个适配器、进出双向消毒;已合法的 id 逐字节透传;流式参数分片保持无 id。
This commit is contained in:
@ -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 = "" },
|
||||
} },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user