mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +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
|
||||
|
||||
164
internal/lua/channels_gemini_adapter_test.go
Normal file
164
internal/lua/channels_gemini_adapter_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user