mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
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
|
||
}
|