diff --git a/internal/lua/adapters/agentrouter.lua b/internal/lua/adapters/agentrouter.lua index 48cdfe4..87747b5 100644 --- a/internal/lua/adapters/agentrouter.lua +++ b/internal/lua/adapters/agentrouter.lua @@ -11,6 +11,26 @@ adapter.headers = {} 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) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end @@ -19,6 +39,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) @@ -79,7 +109,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 @@ -138,6 +168,15 @@ function adapter.transform_stream_chunk(raw_chunk) unified.reasoning_content = delta.reasoning_content end 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 end if uses ~= nil then diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index 3e7bcd7..bf17cc4 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -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) }