mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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} 全绿。
165 lines
5.2 KiB
Go
165 lines
5.2 KiB
Go
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
|
||
}
|