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:
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user