Files
ModelRouter/internal/lua/adapters/gemini.lua
JianFeeeee a2e1adc2d8 fix(gemini): endpoint 自相矛盾导致预置模板必失败
gemini.lua 里 adapter.endpoint = "/v1/models",而它自己的注释写的是
  POST /v1/models/{model}:generateContent
两者矛盾,而 Go 侧是静态拼接(provider.URL = base_url + endpoint),拼不出
模型名。预置模板 "Google Gemini"(base_url=.../v1beta)于是会 POST 到
  https://generativelanguage.googleapis.com/v1beta/v1/models
既多一段 /v1,又缺 :generateContent——那是 Gemini 的模型**列表**端点,
对 POST 返 405。所以任何用户从模板建这个源,拿到的都是必定失败的源。

实测确认影响范围:线上 21 个源里没有 gemini(openai×15 / trae / sensenova /
opencodezen / deepseek / anthropic / agentrouter),所以是潜伏缺陷。

修法:endpoint 改成模板 `/v1beta/models/{model}:generateContent`,新增
provider.ChatURL(model, stream):
  - 用 **PathEscape** 替换 {model}——模型 id 进的是 URL 路径,不转义的话
    一个 "/" 就会静默指向另一个资源(判据里用 RequestURI 而非 URL.Path
    断言,因为后者是解码后的,看不出 %2F);
  - 流式把 ":generateContent" 换成 ":streamGenerateContent"(同一个路径、
    不同动词,也在路径里)。替换刻意只认这个精确后缀,免得别的适配器
    仅仅提到这个词就被改写;
  - source 自己设的 endpoint: 仍然优先,模板被整体跳过。
Chat / ChatStream / probeChat 三处调用点改为传本次请求真实的 model——AUTO
按槽位把 req.Model 钉死,所以 URL 必须跟随**请求**的模型,用源默认模型会让
多模型源每次都打同一个(还记到别的模型的账上)。

判定静态 endpoint 的其他 10 个适配器零影响(TestNonGeminiEndpointsAreUntouched)。

顺带:Stats 的 mutex 不是可重入的,导出方法自己加锁、*Locked 后缀要求调用
方持锁。持锁调导出方法会死锁——我的探针真卡死过一次(直到 10 分钟超时)。
补上 LOCKING 注释,并加判据把这条规则钉住(含一个 20 秒上限的行为判据,
让未来的重构撞死锁时快速失败而不是拖满整个套件)。
2026-10-01 23:37:17 +08:00

312 lines
12 KiB
Lua
Raw 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 = "gemini"
adapter.version = "2.0.0"
-- The Go layer builds the request URL as base_url + endpoint, statically
-- (see provider.URL). Gemini's real API is POST
-- /v1beta/models/{model}:generateContent, and streaming is the same path with
-- a ":streamGenerateContent" verb -- the model name is part of the PATH, so it
-- cannot live in a static endpoint string.
--
-- "{model}" is therefore a placeholder the Go layer substitutes with the model
-- this request actually sends (provider.urlFor substitutes it; see
-- provider.go). Streaming additionally rewrites the ":generateContent" verb to
-- ":streamGenerateContent" on the same template.
--
-- Leaving this as a bare "/v1/models" would call Gemini's model-LIST endpoint,
-- which answers 405 to POST -- so the preset template would create a source that
-- can never work. A source that overrides `endpoint:` bypasses the template
-- entirely and must then spell the whole path itself.
adapter.endpoint = "/v1beta/models/{model}:generateContent"
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