Files
ModelRouter/internal/lua/channels_gemini_adapter_test.go
JianFeeeee 8e2d384a7b 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} 全绿。
2026-10-13 23:42:04 +08:00

165 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}