mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
feat(channels): Gemini 用插件自装的私有协议适配器(channels-gemini)
Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。
参考 gemini.ts 点名三件必须保留,我们此前**一件都没有**(gemini.lua 产的是
公开的 contents/parts 裸体、一个身份头都不发,端点还指着公开路径):
1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key /
x-goog-api-client(User-Agent/x-client-name/x-client-version/
x-machine-id/x-vscode-sessionid)
2. **流式刻意不带 Accept**(抓包一致)
3. **信封逐层字母序序列化**(上游按字节比对;Go 的 encoding/json 对 map
自动字母序,而我们用 json.encode,故须自己排——实现 sorted_json)
信封形状(私有端点要的不是裸 contents):
{model, project, request:{contents, systemInstruction, tools,
generationConfig, sessionId}, requestId, userAgent}
响应也是信封(candidates 嵌在 response 里)→ 必须**剥一层**,否则下游
拿不到 candidates,表现为"模型没回话"(静默失败)。
另加前导助手 gemini_response / gemini_stream_chunk(candidates/parts/
usageMetadata → 统一信封;STOP→stop 等 finishReason 映射)。
★ 踩到的两个坑:
- **Lua 长字符串里不能有字面 NUL 字节**(Python heredoc 写的 "\0" 变成了真
字节)→ Lua 报 "unfinished long string near '<eof>'",且报在文件末尾,
完全指不到现场。
- **本 Lua 构建不支持按位异或 `~`**(unexpected symbol near '~')→ FNV-1a
的 XOR 改用算术等价形式。参考自己明言 sessionId 的哈希本体未反推出来、
数值不与原版逐字相同,所以这里要的是**确定性 + 依赖维度正确**
(吃 project 与首条文本、不吃 model),不是数值一致。
- 另给三个自装适配器统一加**本地预编译护栏**:内核装载失败只回"源码无法
编译"不给行号;先 load 一次就能拿到行号(上面两个坑都是靠它定位的)。
⛔ 仍不做:gemini-sigstore(签名持久化,268 行)——推理走无状态适配器,
插件侧缓存会成为死代码。理由已记在 docs/channels-parity.md。
判据(internal/lua/channels_gemini_adapter_test.go):走真实路径断言
身份五头逐值、**反向断言** x-goog-api-key/x-goog-api-client/Accept 不得出现、
信封五个顶层字段齐全、顶层键字母序(解析式校验,不是字符串匹配)、
响应剥信封后 content/finish_reason/token_usage 都到位。
回归:internal/{lua,core,provider,gateway} 全绿。
This commit is contained in:
@ -346,6 +346,90 @@ function __prelude.openai_error(status, body)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- prelude.gemini_response(raw):Gemini 非流式响应 → 统一信封(字符串)。
|
||||
--
|
||||
-- 与 OpenAI 族的差别在**字段名**(candidates/content/parts、usageMetadata),
|
||||
-- 这是协议族差异而非厂商差异,归前导。
|
||||
function __prelude.gemini_response(raw)
|
||||
local resp = __prelude.decode_body(raw)
|
||||
if resp == nil then return raw end
|
||||
local unified = __prelude.unified_skeleton()
|
||||
local cands = resp.candidates
|
||||
if type(cands) == "table" and #cands > 0 then
|
||||
local c = cands[1]
|
||||
local text, tcs = "", {}
|
||||
local parts = (c.content and c.content.parts) or {}
|
||||
for _, p in ipairs(parts) do
|
||||
if type(p) == "table" then
|
||||
if type(p.text) == "string" then text = text .. p.text end
|
||||
if type(p.functionCall) == "table" then
|
||||
tcs[#tcs + 1] = {
|
||||
id = p.functionCall.name or ("call_" .. #tcs),
|
||||
type = "function",
|
||||
name = p.functionCall.name,
|
||||
arguments = p.functionCall.args or {},
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
unified.content = text
|
||||
if #tcs > 0 then unified.tool_calls = tcs end
|
||||
-- finishReason: STOP/MAX_TOKENS/… → 中立取值
|
||||
unified.finish_reason = __prelude.finish_reason(c.finishReason)
|
||||
end
|
||||
local u = resp.usageMetadata
|
||||
if type(u) == "table" then
|
||||
unified.token_usage = {
|
||||
prompt = u.promptTokenCount or 0,
|
||||
completion = u.candidatesTokenCount or 0,
|
||||
total = u.totalTokenCount or 0,
|
||||
}
|
||||
if u.cachedContentTokenCount then
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = u.cachedContentTokenCount }
|
||||
end
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- prelude.gemini_stream_chunk(raw):Gemini 流式分片 → 统一信封(字符串)。
|
||||
function __prelude.gemini_stream_chunk(raw)
|
||||
local resp = __prelude.decode_body(raw)
|
||||
if resp == nil then return "" end
|
||||
local cands = resp.candidates
|
||||
if type(cands) ~= "table" or #cands == 0 then
|
||||
-- 可能是只带 usageMetadata 的末段
|
||||
local u = resp.usageMetadata
|
||||
if type(u) == "table" then
|
||||
return json.encode({ done = false, usage = {
|
||||
prompt = u.promptTokenCount or 0,
|
||||
completion = u.candidatesTokenCount or 0,
|
||||
total = u.totalTokenCount or 0,
|
||||
} })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local c = cands[1]
|
||||
local text, tcs = "", {}
|
||||
for _, p in ipairs((c.content and c.content.parts) or {}) do
|
||||
if type(p) == "table" then
|
||||
if type(p.text) == "string" then text = text .. p.text end
|
||||
if type(p.functionCall) == "table" then
|
||||
tcs[#tcs + 1] = { index = #tcs, id = p.functionCall.name or "call",
|
||||
type = "function", name = p.functionCall.name,
|
||||
arguments = p.functionCall.args or {} }
|
||||
end
|
||||
end
|
||||
end
|
||||
local finish = c.finishReason
|
||||
local unified = { content = text, done = (finish ~= nil and finish ~= "") }
|
||||
if finish and finish ~= "" then unified.finish_reason = __prelude.finish_reason(finish) end
|
||||
if #tcs > 0 then
|
||||
for i, tc in ipairs(tcs) do tcs[i].index = i - 1 end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- prelude.derive_key:由 base 派生一个稳定的、长度受限的标识(如 client_session)。
|
||||
function __prelude.derive_key(base, n)
|
||||
n = n or 32
|
||||
|
||||
Reference in New Issue
Block a user