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.
297 lines
11 KiB
Lua
297 lines
11 KiB
Lua
local adapter = {}
|
||
|
||
adapter.name = "gemini"
|
||
adapter.version = "2.0.0"
|
||
adapter.endpoint = "/v1/models"
|
||
adapter.headers = {}
|
||
|
||
-- Gemini API: POST /v1/models/{model}:generateContent
|
||
-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX
|
||
--
|
||
-- 请求方向必须把 OpenAI 的工具调用翻译成 Gemini 的 functionCall /
|
||
-- functionResponse,否则 agent 回放的历史里助手那一轮的调用会凭空消失,
|
||
-- 紧随其后的工具结果就成了「无来源」的孤立结果,模型只能反复重发同一个调用。
|
||
function adapter.transform_request(raw_body)
|
||
local ok, req = pcall(json.decode, raw_body)
|
||
if not ok then return raw_body end
|
||
|
||
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts
|
||
local function to_parts(content)
|
||
if type(content) == "string" then
|
||
if content == "" then return {} end
|
||
return { { text = content } }
|
||
end
|
||
local parts = {}
|
||
for _, p in ipairs(content or {}) do
|
||
if type(p) == "string" then
|
||
if p ~= "" then table.insert(parts, { text = p }) end
|
||
elseif type(p) == "table" then
|
||
if p.type == "text" then
|
||
if p.text ~= nil and p.text ~= "" then
|
||
table.insert(parts, { text = p.text })
|
||
end
|
||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||
if b64 then
|
||
table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } })
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return parts
|
||
end
|
||
|
||
local contents = {}
|
||
local system = ""
|
||
local call_names = {} -- tool_call_id -> 函数名(functionResponse 只认名字)
|
||
|
||
for _, m in ipairs(req.messages or {}) do
|
||
local role = m.role or "user"
|
||
|
||
if role == "system" then
|
||
if type(m.content) == "string" then
|
||
system = system .. m.content .. "\n"
|
||
end
|
||
|
||
elseif role == "assistant" then
|
||
local parts = to_parts(m.content)
|
||
if type(m.tool_calls) == "table" then
|
||
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(parts, {
|
||
functionCall = { name = fn.name or "", args = args }
|
||
})
|
||
end
|
||
end
|
||
end
|
||
-- Gemini 不接受空的 parts 数组;无可发送内容的轮次直接跳过
|
||
if #parts > 0 then
|
||
table.insert(contents, { role = "model", parts = parts })
|
||
end
|
||
|
||
elseif role == "tool" then
|
||
local name = call_names[m.tool_call_id] or m.name or ""
|
||
local text = type(m.content) == "string" and m.content or ""
|
||
table.insert(contents, {
|
||
role = "user",
|
||
parts = { { functionResponse = {
|
||
name = name,
|
||
response = { content = text },
|
||
} } }
|
||
})
|
||
|
||
else
|
||
local parts = to_parts(m.content)
|
||
if #parts > 0 then
|
||
table.insert(contents, { role = "user", parts = parts })
|
||
end
|
||
end
|
||
end
|
||
|
||
local gemini_req = {
|
||
contents = contents,
|
||
generationConfig = {
|
||
temperature = req.temperature or 0.7,
|
||
maxOutputTokens = req.max_tokens or 4096,
|
||
}
|
||
}
|
||
|
||
if system ~= "" then
|
||
gemini_req.systemInstruction = { parts = { { text = system } } }
|
||
end
|
||
|
||
-- tools -> functionDeclarations
|
||
if type(req.tools) == "table" then
|
||
local decls = {}
|
||
for _, t in ipairs(req.tools) do
|
||
if type(t) == "table" and type(t["function"]) == "table" then
|
||
local fn = t["function"]
|
||
local params = fn.parameters
|
||
if type(params) ~= "table" then params = { type = "object", properties = {} } end
|
||
table.insert(decls, {
|
||
name = fn.name or "",
|
||
description = fn.description or "",
|
||
parameters = params,
|
||
})
|
||
end
|
||
end
|
||
if #decls > 0 then
|
||
gemini_req.tools = { { functionDeclarations = decls } }
|
||
end
|
||
end
|
||
|
||
if req.stream then
|
||
gemini_req.stream = true
|
||
end
|
||
|
||
return json.encode(gemini_req)
|
||
end
|
||
|
||
-- Gemini 的 endpoint 动态拼接:/v1/models/{model}:generateContent
|
||
function adapter.transform_response(raw_body)
|
||
local ok, resp = pcall(json.decode, raw_body)
|
||
if not ok then return raw_body end
|
||
|
||
local unified = {
|
||
content = "",
|
||
finish_reason = "",
|
||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||
}
|
||
|
||
if resp.usageMetadata then
|
||
unified.token_usage.prompt = resp.usageMetadata.promptTokenCount or 0
|
||
unified.token_usage.completion = resp.usageMetadata.candidatesTokenCount or 0
|
||
unified.token_usage.total = resp.usageMetadata.totalTokenCount or 0
|
||
-- Gemini reports context-cache reads as cachedContentTokenCount;
|
||
-- normalize into OpenAI-standard prompt_tokens_details.cached_tokens
|
||
-- so clients and the audit trail see the hit count. Emitted even when
|
||
-- 0 so a reported miss stays distinguishable from "not reported".
|
||
if resp.usageMetadata.cachedContentTokenCount ~= nil then
|
||
unified.token_usage.prompt_tokens_details = {
|
||
cached_tokens = resp.usageMetadata.cachedContentTokenCount
|
||
}
|
||
end
|
||
end
|
||
|
||
if resp.candidates and #resp.candidates > 0 then
|
||
local cand = resp.candidates[1]
|
||
local tools = {}
|
||
if cand.content and cand.content.parts then
|
||
for _, part in ipairs(cand.content.parts) do
|
||
if part.text then
|
||
unified.content = unified.content .. part.text
|
||
elseif part.functionCall then
|
||
-- Non-streaming tool calls used to be dropped here while
|
||
-- transform_stream_chunk handled them, so a non-streaming
|
||
-- agent turn looked like a plain text answer and the tool
|
||
-- loop died. Gemini's args are already an object.
|
||
local args = part.functionCall.args
|
||
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(tools, {
|
||
id = part.functionCall.id or ("call_" .. #tools),
|
||
type = "function",
|
||
name = part.functionCall.name or "",
|
||
arguments = args
|
||
})
|
||
end
|
||
end
|
||
end
|
||
if cand.finishReason then
|
||
unified.finish_reason = cand.finishReason
|
||
end
|
||
if #tools > 0 then
|
||
unified.tool_calls = tools
|
||
-- Gemini reports finishReason "STOP" even when it emitted a
|
||
-- functionCall; clients keyed on finish_reason would treat that as
|
||
-- a completed answer and never run the tool.
|
||
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
|
||
|
||
-- Gemini attaches usageMetadata to the final chunk (alongside or after
|
||
-- candidates). Keys map to Go's TokenUsage json tags (prompt/...).
|
||
local uses = nil
|
||
if type(chunk.usageMetadata) == "table" then
|
||
local p = chunk.usageMetadata.promptTokenCount or 0
|
||
local c = chunk.usageMetadata.candidatesTokenCount or 0
|
||
local t = chunk.usageMetadata.totalTokenCount or 0
|
||
if p > 0 or c > 0 or t > 0 then
|
||
uses = { prompt = p, completion = c, total = t }
|
||
-- Emit details whenever the field is present, even at 0, so a
|
||
-- reported cache miss stays distinguishable from "not reported".
|
||
if chunk.usageMetadata.cachedContentTokenCount ~= nil then
|
||
uses.prompt_tokens_details = {
|
||
cached_tokens = chunk.usageMetadata.cachedContentTokenCount
|
||
}
|
||
end
|
||
end
|
||
end
|
||
|
||
if not chunk.candidates or #chunk.candidates == 0 then
|
||
if uses ~= nil then
|
||
return json.encode({ usage = uses, done = false })
|
||
end
|
||
return ""
|
||
end
|
||
local cand = chunk.candidates[1]
|
||
-- Gemini finishReason -> OpenAI finish_reason
|
||
local finish = nil
|
||
if cand.finishReason ~= nil then
|
||
if cand.finishReason == "MAX_TOKENS" then
|
||
finish = "length"
|
||
elseif cand.finishReason == "SAFETY" or cand.finishReason == "RECITATION"
|
||
or cand.finishReason == "BLOCKLIST" then
|
||
finish = "content_filter"
|
||
else
|
||
finish = "stop"
|
||
end
|
||
end
|
||
local unified = { content = "", done = (finish ~= nil), finish_reason = finish }
|
||
local reasoning = ""
|
||
local tools = {}
|
||
if cand.content and cand.content.parts then
|
||
for _, part in ipairs(cand.content.parts) do
|
||
if part.text then
|
||
unified.content = (unified.content or "") .. part.text
|
||
elseif part.reasoning_content then
|
||
reasoning = reasoning .. part.reasoning_content
|
||
elseif part.functionCall then
|
||
table.insert(tools, {
|
||
index = #tools,
|
||
id = part.functionCall.id or ("call_" .. #tools),
|
||
type = "function",
|
||
["function"] = {
|
||
name = part.functionCall.name or "",
|
||
arguments = part.functionCall.args or "{}"
|
||
}
|
||
})
|
||
end
|
||
end
|
||
end
|
||
if reasoning ~= "" then unified.reasoning_content = reasoning end
|
||
if #tools > 0 then unified.tool_calls = tools end
|
||
if uses ~= nil then
|
||
unified.usage = uses
|
||
end
|
||
return json.encode(unified)
|
||
end
|
||
|
||
-- 错误收敛:Gemini REST 信封 {error:{code, message, status}}
|
||
function adapter.transform_error(status, body)
|
||
local ok, resp = pcall(json.decode, body)
|
||
if not ok or type(resp) ~= "table" then return nil end
|
||
local e = resp.error
|
||
if type(e) == "table" then
|
||
if type(e.message) == "string" then
|
||
if type(e.status) == "string" then
|
||
return e.status .. ": " .. e.message
|
||
end
|
||
return e.message
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
return adapter
|