mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
Three related forwarding defects found by auditing every adapter with a
tool-calling replay (assistant turn with content:[] + tool_calls).
1) content:[] -> content:{} (all 12 openai-adapter sources, plus
deepseek/trae/sensenova/agentrouter/github/groq/kimicode/mistral)
Lua adapters json.decode the request and re-encode it, and an empty Lua
table is indistinguishable from an empty JSON array — the encoder emits
{} for both. Agent clients serialise a tool-calling assistant turn with
no text as content:[], so every pass-through adapter rewrote it to
content:{} — not valid OpenAI (content is string|array|null). Verified
against a live upstream: content:[] produced "400 invalid arguments"
while content:"" was accepted.
Fixed once at the decode boundary (types.ChatMessage.UnmarshalJSON):
empty-array content normalises to "" and an empty tool_calls array is
dropped, so every adapter — including future ones — sees a valid shape.
2) gemini dropped tool_calls and never emitted functionCall /
functionResponse; the tool role also stayed as an invalid role inside
contents and system was not moved to systemInstruction.
3) ollama copied only role/content, dropping tool_calls and the call
attribution entirely (it needs tool_name, not tool_call_id).
Test: TestAdaptersPreserveToolCalls asserts, for every adapter, that the
call id (or function name where the wire format has no id), the function
name, the tool result and the trailing user turn all survive, plus a
negative control for plain text.
216 lines
8.0 KiB
Lua
216 lines
8.0 KiB
Lua
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} }
|
||
-- 工具调用必须一并翻译:Ollama 的 assistant 消息用 tool_calls(arguments 是对象
|
||
-- 而非 JSON 字符串),工具结果用 tool 角色 + tool_name。此前这里只复制了
|
||
-- role/content,助手那轮的调用和 tool 消息的归属全部丢失,模型看到无来源的
|
||
-- 工具结果就只能反复重发同一个调用。
|
||
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 = {}
|
||
local call_names = {} -- tool_call_id -> 函数名
|
||
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 type(p) == "table" then
|
||
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
|
||
end
|
||
if #images == 0 then images = nil end
|
||
end
|
||
local msg = { role = m.role, content = text }
|
||
if images then msg.images = images end
|
||
|
||
-- 助手轮的工具调用:arguments 转成对象
|
||
if type(m.tool_calls) == "table" and #m.tool_calls > 0 then
|
||
local tcs = {}
|
||
for _, tc in ipairs(m.tool_calls) do
|
||
if type(tc) == "table" then
|
||
local fn = tc["function"] or {}
|
||
local args = fn.arguments
|
||
if type(args) == "string" and args ~= "" then
|
||
local aok, decoded = pcall(json.decode, args)
|
||
args = aok and decoded or {}
|
||
elseif type(args) ~= "table" then
|
||
args = {}
|
||
end
|
||
if tc.id ~= nil then call_names[tc.id] = fn.name or "" end
|
||
table.insert(tcs, {
|
||
["function"] = { name = fn.name or "", arguments = args }
|
||
})
|
||
end
|
||
end
|
||
if #tcs > 0 then msg.tool_calls = tcs end
|
||
end
|
||
|
||
-- 工具结果:Ollama 用 tool_name 标识归属(不认 tool_call_id)
|
||
if m.role == "tool" then
|
||
local name = call_names[m.tool_call_id] or m.name or ""
|
||
if m.tool_call_id ~= nil then
|
||
msg.tool_call_id = m.tool_call_id
|
||
end
|
||
if name ~= "" then msg.tool_name = name end
|
||
end
|
||
|
||
table.insert(msgs, msg)
|
||
end
|
||
ollama_req.messages = msgs
|
||
end
|
||
|
||
-- tools 透传(Ollama 的 shape 与 OpenAI 一致)
|
||
if type(req.tools) == "table" and #req.tools > 0 then
|
||
ollama_req.tools = req.tools
|
||
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
|