mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
Merge feature/agentrouter-id-sanitize: 补上最后一个服务 Claude 的适配器
全仓审计发现 agentrouter 也暴露 claude-opus-4-8,却是唯一没有 id 消毒的 Claude 适配器。补齐后,所有含 claude/opus/sonnet 模型的源(qijiar、toter、 juziai、agentrouter、justwoker、api456、tabitoken)全部走消毒路径; 测试新增三适配器结果一致性断言。
This commit is contained in:
@ -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
|
||||
|
||||
@ -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