mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
fix(agentrouter): sanitize tool-call ids — it fronts Claude too
Full-repo audit after the anthropic/openai fix: agentrouter exposes
claude-opus-4-8, so it inherits Anthropic's tool id rule
^[a-zA-Z0-9_-]{1,64}$ and rejects the whole request on a violation, exactly
like justwoker/tabitoken/扇贝. It was the only remaining adapter serving Claude
models without the sanitizer, so a client that had picked up a dirty id (e.g.
"bash:0" from moonshotai/kimi-k3) would still lose every turn here.
Same shape as the other two — inbound tool_calls[].id + tool_call_id, outbound
non-streaming ids and the first streamed fragment — and the test now asserts
all three adapters rewrite an identical input identically, so a client mixing
sources within one session cannot end up with unpaired tool calls.
Audit result: every source exposing a claude/opus/sonnet model (qijiar, toter,
juziai, agentrouter, justwoker, api456, tabitoken) now routes through a
sanitizing adapter.
This commit is contained in:
@ -11,6 +11,26 @@ adapter.headers = {}
|
|||||||
|
|
||||||
local default_ua = "QwenCode/0.2.0 (linux; x64)"
|
local default_ua = "QwenCode/0.2.0 (linux; x64)"
|
||||||
|
|
||||||
|
-- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams
|
||||||
|
-- enforce Anthropic's tool id rule ^[a-zA-Z0-9_-]{1,64}$ by rejecting the WHOLE
|
||||||
|
-- request. Plain OpenAI does not, so an id minted by a permissive model (e.g.
|
||||||
|
-- "bash:0" from moonshotai/kimi-k3) is replayed here by the client and kills
|
||||||
|
-- every turn. See anthropic.lua / openai.lua for the same helper: Lua adapters
|
||||||
|
-- have no shared prelude, so each carries its own copy.
|
||||||
|
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
|
||||||
|
|
||||||
function adapter.transform_request(raw_body)
|
function adapter.transform_request(raw_body)
|
||||||
local ok, req = pcall(json.decode, raw_body)
|
local ok, req = pcall(json.decode, raw_body)
|
||||||
if not ok then return raw_body end
|
if not ok then return raw_body end
|
||||||
@ -19,6 +39,16 @@ function adapter.transform_request(raw_body)
|
|||||||
if req.messages then
|
if req.messages then
|
||||||
for _, msg in ipairs(req.messages) do
|
for _, msg in ipairs(req.messages) do
|
||||||
msg.reasoning_content = nil
|
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
|
||||||
end
|
end
|
||||||
return json.encode(req)
|
return json.encode(req)
|
||||||
@ -79,7 +109,7 @@ function adapter.transform_response(raw_body)
|
|||||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||||
if not args_ok then args = {} end
|
if not args_ok then args = {} end
|
||||||
table.insert(tcs, {
|
table.insert(tcs, {
|
||||||
id = tc.id,
|
id = safe_tool_id(tc.id),
|
||||||
type = tc.type or "function",
|
type = tc.type or "function",
|
||||||
name = tc["function"].name,
|
name = tc["function"].name,
|
||||||
arguments = args
|
arguments = args
|
||||||
@ -138,6 +168,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
if delta.tool_calls then
|
if delta.tool_calls then
|
||||||
|
-- Sanitize outbound too: a dirty id must never enter a client session,
|
||||||
|
-- because the client replays it to every other source. Only the first
|
||||||
|
-- fragment of a streamed call carries an id; later argument fragments
|
||||||
|
-- have none and must stay id-less for index-based accumulation.
|
||||||
|
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
|
unified.tool_calls = delta.tool_calls
|
||||||
end
|
end
|
||||||
if uses ~= nil then
|
if uses ~= nil then
|
||||||
|
|||||||
@ -1677,41 +1677,44 @@ func TestToolIDSanitize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- openai: same rewrite, and both sites must stay in sync ----
|
// ---- openai + agentrouter: same rewrite, and both sites must stay in sync ----
|
||||||
for _, id := range []string{"bash:0", "call_ok_123", long} {
|
// agentrouter fronts claude-opus-4-8, so it inherits the same id rule.
|
||||||
out, err := vm.Transform("openai", "transform_request", body(id))
|
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||||
if err != nil {
|
for _, id := range []string{"bash:0", "call_ok_123", long} {
|
||||||
t.Fatalf("openai transform %q: %v", id, err)
|
out, err := vm.Transform(adapterName, "transform_request", body(id))
|
||||||
}
|
if err != nil {
|
||||||
var r struct {
|
t.Fatalf("%s transform %q: %v", adapterName, id, err)
|
||||||
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" {
|
var r struct {
|
||||||
resID = m.ToolCallID
|
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("%s unmarshal %q: %v (%s)", adapterName, 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("%s %q: tool_calls[0].id %q still illegal for Claude-behind-OpenAI upstreams", adapterName, id, callID)
|
||||||
|
}
|
||||||
|
if callID != resID {
|
||||||
|
t.Errorf("%s %q: tool_calls id %q != tool_call_id %q (unpaired call)", adapterName, id, callID, resID)
|
||||||
|
}
|
||||||
|
if legal.MatchString(id) && callID != id {
|
||||||
|
t.Errorf("%s %q: already-legal id must pass through untouched, got %q", adapterName, id, callID)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1771,72 +1774,88 @@ func TestToolIDSanitizeResponse(t *testing.T) {
|
|||||||
|
|
||||||
legal := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
legal := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
// ---- openai non-streaming ----
|
// ---- openai + agentrouter non-streaming ----
|
||||||
resp := `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[
|
resp := `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[
|
||||||
{"id":"bash:0","type":"function","function":{"name":"bash","arguments":"{\"command\":\"whoami\"}"}}]},
|
{"id":"bash:0","type":"function","function":{"name":"bash","arguments":"{\"command\":\"whoami\"}"}}]},
|
||||||
"finish_reason":"tool_calls"}]}`
|
"finish_reason":"tool_calls"}]}`
|
||||||
out, err := vm.Transform("openai", "transform_response", resp)
|
var nonStreamID string
|
||||||
if err != nil {
|
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||||
t.Fatalf("openai transform_response: %v", err)
|
out, err := vm.Transform(adapterName, "transform_response", resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s transform_response: %v", adapterName, err)
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &r); err != nil {
|
||||||
|
t.Fatalf("%s unmarshal: %v (%s)", adapterName, err, out)
|
||||||
|
}
|
||||||
|
if len(r.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("%s tool_calls lost: %s", adapterName, out)
|
||||||
|
}
|
||||||
|
if !legal.MatchString(r.ToolCalls[0].ID) {
|
||||||
|
t.Errorf("%s non-stream response leaks illegal id %q to the client", adapterName, r.ToolCalls[0].ID)
|
||||||
|
}
|
||||||
|
if nonStreamID != "" && r.ToolCalls[0].ID != nonStreamID {
|
||||||
|
t.Errorf("%s rewrote %q differently than another adapter (%q) — a client mixing sources gets unpaired calls",
|
||||||
|
adapterName, r.ToolCalls[0].ID, nonStreamID)
|
||||||
|
}
|
||||||
|
nonStreamID = r.ToolCalls[0].ID
|
||||||
}
|
}
|
||||||
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 ----
|
// ---- streaming: first fragment carries the id (both adapters) ----
|
||||||
chunk := `{"choices":[{"delta":{"tool_calls":[
|
chunk := `{"choices":[{"delta":{"tool_calls":[
|
||||||
{"index":0,"id":"bash:0","type":"function","function":{"name":"bash","arguments":""}}]},
|
{"index":0,"id":"bash:0","type":"function","function":{"name":"bash","arguments":""}}]},
|
||||||
"finish_reason":null}]}`
|
"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 {
|
var c struct {
|
||||||
ToolCalls []struct {
|
ToolCalls []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"tool_calls"`
|
} `json:"tool_calls"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(out), &c); err != nil {
|
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||||
t.Fatalf("unmarshal chunk: %v (%s)", err, out)
|
out, err := vm.Transform(adapterName, "transform_stream_chunk", chunk)
|
||||||
}
|
if err != nil {
|
||||||
if len(c.ToolCalls) != 1 {
|
t.Fatalf("%s transform_stream_chunk: %v", adapterName, err)
|
||||||
t.Fatalf("stream tool_calls lost: %s", out)
|
}
|
||||||
}
|
if err := json.Unmarshal([]byte(out), &c); err != nil {
|
||||||
if !legal.MatchString(c.ToolCalls[0].ID) {
|
t.Fatalf("%s unmarshal chunk: %v (%s)", adapterName, err, out)
|
||||||
t.Errorf("stream response leaks illegal id %q to the client", c.ToolCalls[0].ID)
|
}
|
||||||
}
|
if len(c.ToolCalls) != 1 {
|
||||||
// Streaming and non-streaming must agree, otherwise a client that mixes
|
t.Fatalf("%s stream tool_calls lost: %s", adapterName, out)
|
||||||
// modes within one session produces unpaired tool calls.
|
}
|
||||||
if c.ToolCalls[0].ID != nonStreamID {
|
if !legal.MatchString(c.ToolCalls[0].ID) {
|
||||||
t.Errorf("stream id %q != non-stream id %q for the same input", c.ToolCalls[0].ID, nonStreamID)
|
t.Errorf("%s stream response leaks illegal id %q to the client", adapterName, 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("%s stream id %q != non-stream id %q for the same input", adapterName, c.ToolCalls[0].ID, nonStreamID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- a later argument fragment has no id and must stay id-less ----
|
// ---- a later argument fragment has no id and must stay id-less ----
|
||||||
argChunk := `{"choices":[{"delta":{"tool_calls":[
|
argChunk := `{"choices":[{"delta":{"tool_calls":[
|
||||||
{"index":0,"function":{"arguments":"{\"command\":\"whoami\"}"}}]},"finish_reason":null}]}`
|
{"index":0,"function":{"arguments":"{\"command\":\"whoami\"}"}}]},"finish_reason":null}]}`
|
||||||
out, err = vm.Transform("openai", "transform_stream_chunk", argChunk)
|
for _, adapterName := range []string{"openai", "agentrouter"} {
|
||||||
if err != nil {
|
out, err := vm.Transform(adapterName, "transform_stream_chunk", argChunk)
|
||||||
t.Fatalf("arg fragment: %v", err)
|
if err != nil {
|
||||||
}
|
t.Fatalf("%s arg fragment: %v", adapterName, err)
|
||||||
if strings.Contains(out, `"id"`) {
|
}
|
||||||
t.Errorf("argument fragment gained an id (breaks index-based accumulation): %s", out)
|
if strings.Contains(out, `"id"`) {
|
||||||
|
t.Errorf("%s argument fragment gained an id (breaks index-based accumulation): %s", adapterName, out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- anthropic response side: tool_use id and stream start ----
|
// ---- anthropic response side: tool_use id and stream start ----
|
||||||
|
var r struct {
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
}
|
||||||
aResp := `{"content":[{"type":"tool_use","id":"toolu~sig1:AB+/==","name":"bash","input":{"command":"whoami"}}],"stop_reason":"tool_use"}`
|
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)
|
out, err := vm.Transform("anthropic", "transform_response", aResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("anthropic transform_response: %v", err)
|
t.Fatalf("anthropic transform_response: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user