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