From 8e2d384a7beff565e676dfafac87d2f1b4f7b984 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 13 Oct 2026 23:42:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20Gemini=20=E7=94=A8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=87=AA=E8=A3=85=E7=9A=84=E7=A7=81=E6=9C=89=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=88channels-gemini?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ''",且报在文件末尾, 完全指不到现场。 - **本 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} 全绿。 --- internal/lua/adapter_prelude.go | 84 ++++++ internal/lua/channels_gemini_adapter_test.go | 164 +++++++++++ internal/lua/plugins/channels.lua | 280 ++++++++++++++++++- 3 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 internal/lua/channels_gemini_adapter_test.go diff --git a/internal/lua/adapter_prelude.go b/internal/lua/adapter_prelude.go index 4089ce6..c1a0c59 100644 --- a/internal/lua/adapter_prelude.go +++ b/internal/lua/adapter_prelude.go @@ -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 diff --git a/internal/lua/channels_gemini_adapter_test.go b/internal/lua/channels_gemini_adapter_test.go new file mode 100644 index 0000000..31964ca --- /dev/null +++ b/internal/lua/channels_gemini_adapter_test.go @@ -0,0 +1,164 @@ +package lua + +import ( + "encoding/json" + "strings" + "testing" +) + +// Gemini(Cloud Code)走**私有信封协议**,不是公开的 /v1beta/models/…。 +// 参考 gemini.ts 点名三件必须保留: +// 1. 身份五头**逐字写死、不许随机**,且**不带** x-goog-api-key +// 2. 流式刻意**不带** Accept +// 3. 信封**逐层字母序**序列化(上游按字节比对) +// +// 此前我们的 gemini.lua 产的是公开的 contents/parts 裸体、且一个身份头都不发, +// 端点还指着公开路径 —— 凭据与协议都不匹配。 +func TestChannelsGeminiPrivateProtocol(t *testing.T) { + vm, ps, _ := runtimePlugins(t) + src, err := bundledAdapters.ReadFile("plugins/channels.lua") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", string(src)); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, a := range vm.ListAdapters() { + names[a.Name] = true + } + if !names["channels-gemini"] { + t.Fatalf("插件未装 channels-gemini;已装: %v", names) + } + // 内置 gemini(公开 API 用)不得被顶掉 + if !names["gemini"] { + t.Error("内置 gemini 适配器被顶掉了") + } + if ep := vm.Endpoint("channels-gemini"); ep != "/v1internal:generateContent" { + t.Errorf("端点应为私有动词路径,实得 %q", ep) + } + if se := vm.StreamEndpoint("channels-gemini"); !strings.Contains(se, "streamGenerateContent") { + t.Errorf("流式端点不对: %q", se) + } + + // 身份五头 + 不得带 google api key + hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{"api_key": "tok"}) + if err != nil { + t.Fatalf("BuildHeaders: %v", err) + } + for k, want := range map[string]string{ + "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", + } { + if hdrs[k] != want { + t.Errorf("身份头 %s 应为 %q,实得 %q", k, want, hdrs[k]) + } + } + for _, bad := range []string{"x-goog-api-key", "x-goog-api-client", "Accept"} { + if _, ok := hdrs[bad]; ok { + t.Errorf("**不得**发 %s 头(参考点名)", bad) + } + } + + // 信封:model/project/request/requestId/userAgent 五件 + request 内层 + in := `{"model":"gemini-3.1-flash","messages":[` + + `{"role":"system","content":"sys"},` + + `{"role":"user","content":"hello"}],` + + `"max_tokens":100,"stream":true}` + out, err := vm.Transform("channels-gemini", "transform_request", in) + if err != nil { + t.Fatalf("Transform: %v", err) + } + t.Logf("信封: %s", out) + var env map[string]interface{} + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("信封不是合法 JSON: %v\n%s", err, out) + } + for _, k := range []string{"model", "project", "request", "requestId", "userAgent"} { + if _, ok := env[k]; !ok { + t.Errorf("信封缺顶层字段 %s(私有端点要信封,不是裸 contents)", k) + } + } + if env["userAgent"] != "antigravity" { + t.Errorf("userAgent 应为 antigravity,实得 %v", env["userAgent"]) + } + req, _ := env["request"].(map[string]interface{}) + if req == nil { + t.Fatal("request 不是对象") + } + if _, ok := req["contents"]; !ok { + t.Error("request 缺 contents") + } + if _, ok := req["systemInstruction"]; !ok { + t.Error("system 消息应进 systemInstruction") + } + if _, ok := req["sessionId"]; !ok { + t.Error("request 缺 sessionId") + } + + // ③ 字母序:顶层键与信封内层都必须有序(上游按字节比对) + if !isSortedJSONObject(out) { + t.Errorf("信封未按键字母序序列化(参考 marshalAlphabetical):\n%s", out) + } + + // ④ 响应必须**剥一层**信封({response:{candidates}})—— 不剥则拿不到 + // candidates,表现为"模型没回话" + respIn := `{"response":{"candidates":[{"content":{"parts":[{"text":"hi"}]},` + + `"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,` + + `"candidatesTokenCount":2,"totalTokenCount":5}}}` + rout, err := vm.Transform("channels-gemini", "transform_response", respIn) + if err != nil { + t.Fatalf("transform_response: %v", err) + } + var ru map[string]interface{} + if err := json.Unmarshal([]byte(rout), &ru); err != nil { + t.Fatalf("响应不是合法 JSON: %v\n%s", err, rout) + } + if ru["content"] != "hi" { + t.Errorf("响应未剥信封(content 应来自 response.candidates[0]):%s", rout) + } + if ru["finish_reason"] != "stop" { + t.Errorf("finishReason STOP 应映射为 stop,实得 %v", ru["finish_reason"]) + } + tu, _ := ru["token_usage"].(map[string]interface{}) + if tu == nil || tu["prompt"] != float64(3) { + t.Errorf("usageMetadata 未映射到 token_usage:%v", ru["token_usage"]) + } +} + +// isSortedJSONObject 检查 JSON 对象键是否字母序(只查顶层,够用)。 +func isSortedJSONObject(s string) bool { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "{") { + return false + } + dec := json.NewDecoder(strings.NewReader(s)) + tok, err := dec.Token() + if err != nil { + return false + } + if d, ok := tok.(json.Delim); !ok || d != '{' { + return false + } + keys := []string{} + for dec.More() { + k, err := dec.Token() + if err != nil { + return false + } + keys = append(keys, k.(string)) + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + return false + } + } + for i := 1; i < len(keys); i++ { + if keys[i-1] > keys[i] { + return false + } + } + return true +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index b9efe22..f7d2bfb 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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