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

@ -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

View File

@ -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
}

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