mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
- 新增唯一公开接口表 `modelrouter`(sdk_version=1.0):http / secrets / kernel / adapters / plugin_dir。成员与内部桥是**同一对象**而非副本 —— 复制会让凭据保险库分叉(插件写 A、内核读 B)。 - `llmsproxy` 与 `__llmsproxy_*` 降为同对象兼容别名,只为已装插件继续可用。 仓库内 4 个插件(channels / billing / quota / usage)全部改用 `modelrouter.*`:此前它们直接依赖内核私有全局,内核无法在不炸插件的前提下 改实现(重命名、加守卫、换实现都算破坏性变更)。 - 插件不再由网关安装:`SeedBundled()` 变彻底 no-op,`core.New` 不再调用; `ReadBundledPlugin` 改为按源文件定位(脱离 cwd),只服务测试与开发工具。 - 桌面端删除 `BUILTIN_PLUGINS` 与 `syncBundledPluginsOnUpgrade`:升级不再 删除、改名或覆盖用户插件文件。插件是运维资产,不是内核资源。 - `Builtin` 不再靠"源码与仓库示例逐字节相同"推断,插件一律视为外部文件。 - 判据:SDK 存在/版本/形状、别名与内部桥同一张表、适配器 VM 拿不到 SDK、 经 SDK 真实写读凭据并驱动内核动作、GUI 升级不动插件文件、release 包不含插件。 说明:channels.lua / quota.lua 同时带有此前的渠道与额度改动(含 OpenCode 免费池 监控),与 SDK 改名同处一个文件,无法文件级拆分,故一并纳入。
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 := ReadBundledPlugin("channels")
|
||
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
|
||
}
|