Files
ModelRouter/internal/lua/adapters/ollama.lua
JianFeeeee 2e3d5b79ad 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.
2026-08-31 10:23:35 +08:00

172 lines
5.9 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

local adapter = {}
adapter.name = "ollama"
adapter.version = "2.0.0"
adapter.endpoint = "/api/chat"
adapter.headers = {}
-- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} }
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
local ollama_req = {
model = req.model or "llama3",
stream = req.stream or false,
options = {
temperature = req.temperature or 0.7,
num_predict = req.max_tokens or 2048
}
}
-- 转换 messages 格式Ollama messages 支持 images base64 数组)
if req.messages then
local msgs = {}
for _, m in ipairs(req.messages) do
local text, images
if type(m.content) == "string" then
text, images = m.content, nil
else
text = ""
images = {}
for _, p in ipairs(m.content or {}) do
if p.type == "text" then
text = text .. (p.text or "")
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
if b64 then table.insert(images, b64) end
end
end
if #images == 0 then images = nil end
end
local msg = { role = m.role, content = text }
if images then msg.images = images end
table.insert(msgs, msg)
end
ollama_req.messages = msgs
end
return json.encode(ollama_req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok then return raw_body end
local p = resp.prompt_eval_count or 0
local c = resp.eval_count or 0
local unified = {
content = "",
finish_reason = resp.done_reason or "",
-- key must be token_usage to match Go's UnifiedResponse json tag
token_usage = { prompt = p, completion = c, total = p + c }
}
if resp.message then
unified.content = resp.message.content or ""
-- Non-streaming tool calls were previously dropped: the field was
-- initialized to an empty table and never filled, while
-- transform_stream_chunk handled them. A non-streaming agent turn thus
-- looked like a plain answer and the tool loop stopped.
if type(resp.message.tool_calls) == "table" and #resp.message.tool_calls > 0 then
local tcs = {}
for _, tc in ipairs(resp.message.tool_calls) do
local fn = tc["function"] or {}
-- Ollama sends arguments as an object already
local args = fn.arguments
if type(args) == "string" then
local aok, decoded = pcall(json.decode, args)
args = aok and decoded or {}
elseif type(args) ~= "table" then
args = {}
end
table.insert(tcs, {
id = tc.id or ("call_" .. #tcs),
type = tc.type or "function",
name = fn.name or "",
arguments = args
})
end
unified.tool_calls = tcs
-- Ollama reports done_reason "stop" alongside tool calls
unified.finish_reason = "tool_calls"
end
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- Ollama's terminal chunk (done=true) carries token counts but may omit
-- message; pass them through so the gateway emits real usage.
local uses = nil
if chunk.done then
local p = chunk.prompt_eval_count or 0
local c = chunk.eval_count or 0
if p > 0 or c > 0 then
uses = { prompt = p, completion = c, total = p + c }
end
end
-- Ollama done_reason -> OpenAI finish_reason ("length" 透传,其余归一 stop)
local finish = nil
if chunk.done then
if chunk.done_reason == "length" then
finish = "length"
else
finish = "stop"
end
end
if not chunk.message then
if uses ~= nil then
return json.encode({ content = "", done = true, finish_reason = finish, usage = uses })
end
return json.encode({ content = "", done = true, finish_reason = finish })
end
local unified = {
content = chunk.message.content or "",
done = chunk.done or false,
finish_reason = finish
}
if uses ~= nil then
unified.usage = uses
end
if chunk.message.reasoning_content then
unified.reasoning_content = chunk.message.reasoning_content
end
if chunk.message.tool_calls then
local tools = {}
for _, tc in ipairs(chunk.message.tool_calls) do
table.insert(tools, {
index = #tools,
id = tc.id or ("call_" .. #tools),
type = "function",
["function"] = {
name = tc["function"] and tc["function"].name or "",
arguments = tc["function"] and (tc["function"].arguments or "{}") or "{}"
}
})
end
unified.tool_calls = tools
end
return json.encode(unified)
end
-- 错误收敛Ollama 常见 {error:"..."} 字符串(新版本也有对象形态)
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
if type(resp.error) == "string" then return resp.error end
if type(resp.error) == "table" and type(resp.error.message) == "string" then
return resp.error.message
end
return nil
end
return adapter