mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
fix(adapters): stop dropping non-streaming tool calls (agent loops died on turn 2)
Four adapters handled tool_calls in transform_stream_chunk but lost them in
transform_response, so any NON-streaming tool-using conversation broke on its
second request: the client received finish_reason:"tool_calls" with no
tool_calls payload, replayed an assistant message whose function
name/arguments were empty, and the upstream rejected the next turn with
400 invalid tool_call function, function/name/arguments cannot be empty
The production audit trail shows 46 such failures on sensenova alone.
- sensenova.lua: forward message.tool_calls, decoding the arguments JSON string
into an object as the unified shape expects.
- gemini.lua: collect functionCall parts from candidates[].content.parts. Also
correct finish_reason, since Gemini reports "STOP" even when it emitted a
function call and clients keyed on it treat that as a finished answer.
- ollama.lua: the field was initialized to an empty table and never filled;
fill it and likewise correct done_reason "stop" -> "tool_calls".
trae is a different failure with the same symptom: trae-local-api's OpenAI
endpoint (/v1/chat/completions, src/server.js:353) never reads the request's
`tools` array — only its Anthropic endpoint does — so the relayed model is never
told the tool schema and instead PRINTS a <tool_call>{...}</tool_call> block into
content, leaving message.tool_calls null and finish_reason "stop". An OpenAI
client sees an ordinary completion and its agent loop ends mid-conversation.
trae.lua now recovers the structured call from that text, strips the block from
user-visible content, and corrects finish_reason. Both tag spellings
(<tool_call>/<toolcall>, the latter is what the same codebase's Anthropic prompt
asks for) and all three argument key names (arguments/params/input) are accepted.
This is a defensive fallback: fixing the upstream shim to honour `tools` remains
the real fix, since the model still guesses parameter names.
Tests: TestNonStreamToolCallsPreserved covers all ten OpenAI-shaped adapters,
TestGeminiNonStreamToolCalls and TestOllamaNonStreamToolCalls cover their native
shapes, TestTraeTextToolCallRecovery covers both tag spellings, prose around the
block, and asserts a plain text answer never gains tool_calls.
Verified end-to-end against mock upstreams reproducing each shape: a full
two-round agent loop (tool call -> tool result -> final answer) now completes for
both the structured and the text-emitted variants.
This commit is contained in:
@ -25,6 +25,78 @@ function adapter.transform_request(raw_body)
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- parse_text_tool_calls extracts tool calls that an upstream emitted as PLAIN
|
||||
-- TEXT instead of using the OpenAI tool_calls field.
|
||||
--
|
||||
-- trae-local-api's OpenAI endpoint (/v1/chat/completions) does not read the
|
||||
-- request's `tools` array at all, so the relayed model is never told the tool
|
||||
-- schema; it falls back to printing
|
||||
-- <tool_call>
|
||||
-- {"name": "get_weather", "arguments": {"location": "北京"}}
|
||||
-- </tool_call>
|
||||
-- into message.content, leaves message.tool_calls null, and reports
|
||||
-- finish_reason="stop". A client following the OpenAI contract therefore never
|
||||
-- sees a tool call: the agent loop terminates unexpectedly mid-conversation
|
||||
-- (and a hand-written replay produces an empty function name next turn).
|
||||
--
|
||||
-- Both tag spellings are accepted: the same codebase's Anthropic endpoint
|
||||
-- instructs models to emit <toolcall>, and models mix the two. Key names vary
|
||||
-- too (arguments / params / input), so all are tried.
|
||||
--
|
||||
-- Returns (tool_calls_array_or_nil, content_with_blocks_removed).
|
||||
local function parse_text_tool_calls(content)
|
||||
if type(content) ~= "string" or content == "" then return nil, content end
|
||||
if not (content:find("<tool_call", 1, true) or content:find("<toolcall", 1, true)) then
|
||||
return nil, content
|
||||
end
|
||||
|
||||
local tcs = {}
|
||||
local idx = 0
|
||||
|
||||
local function collect(pattern)
|
||||
for payload in content:gmatch(pattern) do
|
||||
local ok, obj = pcall(json.decode, payload)
|
||||
if ok and type(obj) == "table" then
|
||||
-- some models wrap it as {"function":{"name":..,"arguments":..}}
|
||||
local fn = obj["function"]
|
||||
local name = obj.name or (type(fn) == "table" and fn.name) or nil
|
||||
if name then
|
||||
local args = obj.arguments or obj.params or obj.input
|
||||
if args == nil and type(fn) == "table" then
|
||||
args = fn.arguments or fn.params
|
||||
end
|
||||
if type(args) == "string" then
|
||||
local aok, decoded = pcall(json.decode, args)
|
||||
args = aok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
idx = idx + 1
|
||||
table.insert(tcs, {
|
||||
id = obj.id or ("call_text_" .. idx),
|
||||
type = "function",
|
||||
name = name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- `<tag ...>` allows attributes; %s* handles the "</tool_call >" spacing
|
||||
-- that trae-local-api's own prompt example uses.
|
||||
collect("<tool_call[^>]*>%s*(.-)%s*</tool_call%s*>")
|
||||
collect("<toolcall[^>]*>%s*(.-)%s*</toolcall%s*>")
|
||||
|
||||
if #tcs == 0 then return nil, content end
|
||||
|
||||
-- drop the blocks from user-visible content; keep any surrounding prose
|
||||
local stripped = content:gsub("<tool_call[^>]*>%s*.-%s*</tool_call%s*>", "")
|
||||
stripped = stripped:gsub("<toolcall[^>]*>%s*.-%s*</toolcall%s*>", "")
|
||||
stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return tcs, stripped
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- trae-local-api 偶发在非流式请求中返回 SSE 格式数据,
|
||||
-- 表现为多个 data: {...} 行或混合了 reasoning_chunk 等。
|
||||
@ -71,15 +143,21 @@ function adapter.transform_response(raw_body)
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
if type(ch.message.tool_calls) == "table" and #ch.message.tool_calls > 0 then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
local fn = tc["function"] or {}
|
||||
local args = fn.arguments
|
||||
if type(args) == "string" then
|
||||
local args_ok, decoded = pcall(json.decode, args)
|
||||
args = args_ok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
name = fn.name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
@ -87,6 +165,22 @@ function adapter.transform_response(raw_body)
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
|
||||
-- Fallback: trae-local-api's OpenAI endpoint drops the request's
|
||||
-- `tools` array entirely, so the relayed model is never told the tool
|
||||
-- schema and instead PRINTS a <tool_call>{...}</tool_call> block into
|
||||
-- content, leaving message.tool_calls null and finish_reason="stop".
|
||||
-- A client following the OpenAI contract then sees a normal completion
|
||||
-- and its agent loop terminates mid-conversation. Recover the
|
||||
-- structured call so the loop can continue.
|
||||
if unified.tool_calls == nil then
|
||||
local recovered, cleaned = parse_text_tool_calls(unified.content)
|
||||
if recovered then
|
||||
unified.tool_calls = recovered
|
||||
unified.content = cleaned
|
||||
unified.finish_reason = "tool_calls"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
|
||||
Reference in New Issue
Block a user