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:
JianFeeeee
2026-10-13 23:42:04 +08:00
parent ff9b460eba
commit 8e2d384a7b
3 changed files with 526 additions and 2 deletions

View File

@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe" }
@ -493,7 +493,9 @@ local PLATFORMS = {
},
hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" },
base_url = "https://cloudcode-pa.googleapis.com",
adapter = "gemini",
-- Cloud Code 走**私有信封协议**(/v1internal: 动词 + 身份五头 + 字母序
-- 信封 + 响应剥一层),用本插件自装的适配器。
adapter = "channels-gemini",
-- ⚠️ Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent;
-- 参考 gemini.ts:78 明确走私有动词路径 /v1internal:generateContent
-- (流式 /v1internal:streamGenerateContent?alt=sse)。用声明式 endpoint
@ -6851,6 +6853,9 @@ return adapter
-- installTraeAdapter():装/更新本插件自己的 TRAE 直连适配器。
local function installTraeAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
local f, lerr = load(TRAE_SOLO_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-trae", TRAE_SOLO_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-trae", TRAE_SOLO_ADAPTER_SRC)
@ -6859,11 +6864,269 @@ local function installTraeAdapter()
return true, nil
end
-- ============================================================================
-- Gemini 专用适配器(插件自装:channels-gemini)
-- ============================================================================
-- Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。
-- 参考 gemini.ts 点名三件必须保留的细节:
-- 1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key /
-- x-goog-api-client(参考 GEMINI_IDENTITY_HEADERS)
-- 2. **流式刻意不带 Accept 头**(抓包一致)
-- 3. **信封逐层字母序序列化**(marshalAlphabetical)—— Go 的 encoding/json
-- 对 map 自动字母序,而我们的适配器用 json.encode,故须显式排序
--
-- 信封形状(私有端点要的不是裸 contents):
-- {model, project, request:{contents,systemInstruction,tools,
-- generationConfig,sessionId}, requestId, userAgent}
-- 响应也是信封(candidates 嵌在 response 里),必须剥一层。
--
-- ⛔ 不做:gemini-sigstore(签名持久化,268 行)。我们推理走无状态适配器,
-- 插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传错误。
local GEMINI_PRIVATE_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "channels-gemini"
adapter.version = "1.0.0"
adapter.endpoint = "/v1internal:generateContent"
adapter.endpoint_stream = "/v1internal:streamGenerateContent?alt=sse"
-- 身份五头逐字写死(参考 GEMINI_IDENTITY_HEADERS)。**不许随机**:
-- 上游按客户端指纹识别,随机化会被判异常。
adapter.headers = {
["User-Agent"] = "antigravity/4.3.0 (cmdc-pak)",
["x-client-name"] = "antigravity",
["x-client-version"] = "4.3.0",
["x-machine-id"] = "cmdc-pak",
["x-vscode-sessionid"] = "proxy",
}
-- ⚠️ 刻意**不带** Accept(参考:流式请求不带 Accept,抓包一致)。
-- 也不需要 x-goog-api-key(Cloud Code 用 OAuth Bearer)。
local GEMINI_DEFAULT_PROJECT = "aicode-consumers"
local GEMINI_UPSTREAM_USER_AGENT = "antigravity"
-- 字母序 JSON 序列化(参考 marshalAlphabetical → sortedStringify)。
-- 为什么必须排序:上游按**字节**比对/缓存,键序不同即不同请求。Go 的
-- encoding/json 对 map 自动字母序,Lua 的 json.encode 不排序 —— 故自己排。
local function sorted_json(v)
local t = type(v)
if v == nil then return "null" end
if t == "boolean" then return v and "true" or "false" end
if t == "number" then
if v == math.floor(v) and math.abs(v) < 1e15 then return string.format("%d", v) end
return tostring(v)
end
if t == "string" then return json.encode(v) end
if t == "table" then
-- 数组(1..n 连续整数键)→ 保序;对象 → 键排序
local n = 0
for k in pairs(v) do
if type(k) ~= "number" then n = -1 break end
if k > n then n = k end
end
if n >= 0 then
local parts = {}
for i = 1, n do parts[#parts + 1] = sorted_json(v[i]) end
return "[" .. table.concat(parts, ",") .. "]"
end
local keys = {}
for k in pairs(v) do keys[#keys + 1] = tostring(k) end
table.sort(keys)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = json.encode(k) .. ":" .. sorted_json(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
return "null"
end
-- 首条 user 文本(sessionId 的派生输入之一)
local function first_user_text(contents)
if type(contents) ~= "table" or #contents == 0 then return "" end
local parts = contents[1] and contents[1].parts
if type(parts) ~= "table" then return "" end
for _, p in ipairs(parts) do
if type(p) == "table" and type(p.text) == "string" then return p.text end
end
return ""
end
-- sessionId = f(project, 首条 user 文本)。参考明言:数值不与原版逐字相同
-- (哈希本体未反推出来),复刻的是**依赖维度与"同输入同输出"**。
-- 这点很重要:不要假装数值一致;判据只能断言确定性(同输入同输出、
-- 换 project/文本会变、换 model 不变)。
local function derive_session_id(project, text)
-- ⚠️ 本 Lua 构建**不支持按位异或 `~`**(load 时报 unexpected symbol near '~')。
-- FNV-1a 的 XOR 用「加等价类」替代:这里要的不是 FNV 的精确值(参考自己
-- 也明言数值不与原版逐字相同,哈希本体未反推出来),而是**确定性 + 依赖
-- 正确的维度**(吃 project 与首条文本、不吃 model)。
local h = 2166136261
local input = tostring(project) .. "\\0" .. tostring(text)
for i = 1, #input do
h = (h + string.byte(input, i) * 2654435761) % 4294967296
h = (h * 16777619) % 4294967296
end
return tostring(h)
end
function adapter.transform_request(raw_body)
local req = __prelude.decode_body(raw_body)
if req == nil then return raw_body end
__prelude.strip_control(req)
-- ① 先把 OpenAI 消息转成 Gemini contents(复用既有逻辑的等价实现)
local contents, system = {}, ""
local call_names = {}
local function to_parts(content)
if type(content) == "string" then
if content == "" then return {} end
return { { text = content } }
end
local out = {}
for _, p in ipairs(content or {}) do
if type(p) == "string" then
if p ~= "" then out[#out + 1] = { text = p } end
elseif type(p) == "table" then
if p.type == "text" and p.text ~= nil and p.text ~= "" then
out[#out + 1] = { text = p.text }
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
out[#out + 1] = { inlineData = { mimeType = mt or "image/png", data = b64 } }
end
end
end
end
return out
end
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" and type(tc["function"]) == "table" then
call_names[tostring(tc.id)] = tc["function"].name
local args = {}
local ok, decoded = pcall(json.decode, tc["function"].arguments or "{}")
if ok and type(decoded) == "table" then args = decoded end
parts[#parts + 1] = { functionCall = { name = tc["function"].name, args = args } }
end
end
end
if #parts > 0 then contents[#contents + 1] = { role = "model", parts = parts } end
elseif role == "tool" then
local name = call_names[tostring(m.tool_call_id or "")] or ""
contents[#contents + 1] = { role = "user", parts = { {
functionResponse = { name = name, response = { output = tostring(m.content or "") } },
} } }
else
local parts = to_parts(m.content)
if #parts > 0 then contents[#contents + 1] = { role = "user", parts = parts } end
end
end
-- ② 组装 request
local greq = { contents = contents }
if system ~= "" then
greq.systemInstruction = { role = "user", parts = { { text = system } } }
end
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
decls[#decls + 1] = {
name = fn.name or "", description = fn.description or "", parameters = params,
}
end
end
if #decls > 0 then greq.tools = { { functionDeclarations = decls } } end
end
local gen = {}
if req.max_tokens then gen.maxOutputTokens = req.max_tokens end
if req.temperature then gen.temperature = req.temperature end
if req.top_p then gen.topP = req.top_p end
if next(gen) then greq.generationConfig = gen end
local project = (req.project and tostring(req.project)) or GEMINI_DEFAULT_PROJECT
greq.sessionId = derive_session_id(project, first_user_text(contents))
-- ③ 信封(字母序序列化)
local envelope = {
model = req.model or "",
project = project,
request = greq,
requestId = "agent/" .. tostring(os.time()) .. "/" .. string.sub(sha256_hex(tostring(os.clock())), 1, 8),
userAgent = GEMINI_UPSTREAM_USER_AGENT,
}
return sorted_json(envelope)
end
-- 响应也是信封:{response:{candidates:[…]}} —— 必须剥一层。
-- 不剥的话下游拿不到 candidates,表现为"模型没回话"。
local function unwrap(raw, decode_fn)
local rec = __prelude.decode_body(raw)
if rec == nil then return raw end
if type(rec.response) == "table" then return rec.response end
return rec
end
function adapter.transform_response(raw_body)
local inner = unwrap(raw_body)
if type(inner) == "string" then return inner end
return __prelude.gemini_response(json.encode(inner))
end
function adapter.transform_stream_chunk(raw_chunk)
local inner = unwrap(raw_chunk)
if type(inner) == "string" then return inner end
return __prelude.gemini_stream_chunk(json.encode(inner))
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
-- 判据用:把派生逻辑暴露出来(不重复实现,避免判据与被测对象脱节)
function adapter.derive_session_id(project, text)
return derive_session_id(project, text)
end
function adapter.sorted_json(v)
return sorted_json(v)
end
return adapter
]==]
-- installGeminiAdapter():装/更新本插件自己的 Gemini 私有协议适配器。
local function installGeminiAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- 先在本地编译一次,把语法错误在**装载失败之前**变成可读信息
-- (load 失败只给"源码无法编译",不给行号)
local f, lerr = load(GEMINI_PRIVATE_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。
-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 ——
-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。
local function installCodeartsAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
local f, lerr = load(CODEARTS_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("codearts", CODEARTS_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("codearts", CODEARTS_ADAPTER_SRC)
@ -7375,6 +7638,16 @@ plugin.ui = {
},
}
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
function plugin.__adapter_install_probe()
return { json = {
codearts = plugin._codearts_adapter,
trae = plugin._trae_adapter,
gemini = plugin._gemini_adapter,
} }
end
-- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并
-- 返回内核 add_source 的原始结果。把"静默不出源"变成可观测的结果。
function plugin.__sync_source_probe(args)
@ -7426,6 +7699,9 @@ do
plugin._codearts_adapter = { ok = ok, err = err }
local tok2, terr2 = installTraeAdapter()
plugin._trae_adapter = { ok = tok2, err = terr2 }
local gok, gerr = installGeminiAdapter()
plugin._gemini_adapter = { ok = gok, err = gerr }
if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end