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:
JianFeeeee
2026-09-06 10:08:52 +08:00
parent b9944332ff
commit c6c3e0dcd7
2 changed files with 136 additions and 78 deletions

View File

@ -1677,41 +1677,44 @@ func TestToolIDSanitize(t *testing.T) {
}
}
// ---- 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
// ---- openai + agentrouter: same rewrite, and both sites must stay in sync ----
// agentrouter fronts claude-opus-4-8, so it inherits the same id rule.
for _, adapterName := range []string{"openai", "agentrouter"} {
for _, id := range []string{"bash:0", "call_ok_123", long} {
out, err := vm.Transform(adapterName, "transform_request", body(id))
if err != nil {
t.Fatalf("%s transform %q: %v", adapterName, id, err)
}
if m.Role == "tool" {
resID = m.ToolCallID
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("%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}$`)
// ---- openai non-streaming ----
// ---- openai + agentrouter 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 nonStreamID string
for _, adapterName := range []string{"openai", "agentrouter"} {
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":[
{"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)
for _, adapterName := range []string{"openai", "agentrouter"} {
out, err := vm.Transform(adapterName, "transform_stream_chunk", chunk)
if err != nil {
t.Fatalf("%s transform_stream_chunk: %v", adapterName, err)
}
if err := json.Unmarshal([]byte(out), &c); err != nil {
t.Fatalf("%s unmarshal chunk: %v (%s)", adapterName, err, out)
}
if len(c.ToolCalls) != 1 {
t.Fatalf("%s stream tool_calls lost: %s", adapterName, out)
}
if !legal.MatchString(c.ToolCalls[0].ID) {
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 ----
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)
for _, adapterName := range []string{"openai", "agentrouter"} {
out, err := vm.Transform(adapterName, "transform_stream_chunk", argChunk)
if err != nil {
t.Fatalf("%s arg fragment: %v", adapterName, err)
}
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 ----
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"}`
out, err = vm.Transform("anthropic", "transform_response", aResp)
out, err := vm.Transform("anthropic", "transform_response", aResp)
if err != nil {
t.Fatalf("anthropic transform_response: %v", err)
}