mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户判据:「priority 直接移除」「即使带 key 也没有 /v1/responses」
「channel 插件的问题还没完全解决」「生图和聊天合并到一个导航项」
## 一、删除 priority(它从未参与跨源调度)
`config.Model.Priority` 号称"高者优先参与 AUTO 选源",**那是错的**:
AUTO 链由 `config.ModelScope.Tier` 驱动(scheduler.Chain),priority 只被
`bestChatModel/bestImageModel` 用于"本源内部该发哪个模型",与跨源调度无关。
把它当调度依据会让人以为拖个数字就能改顺序,改完毫无效果。
- 删 `config.Model.Priority` 字段与 `bestChatModel/bestImageModel` 的优先级比较
- 删 `seedAuto()`:**AUTO 链为空时网关应直接 503**,不能凭空播种用户没配过的档位
- 删 UI `buildLanes` 的 discovery 回退(按源 priority 自动铺开)与 sourceRows
- 清掉 config.yaml 里 140 行 priority
## 二、一通道 = 一个适配器 = 一个源(channels 插件)
旧设计「一账号一源」(`channels-<平台>-<12位hex>`) 有三个结构性后果:
1. 三个账号 ⇒ 三个源、三份同名模型,用户看到三行无法区分的东西
2. 谁先谁后**取决于注册顺序,不是余额**(rankAccounts 只用于展示,从未接进同步路径)
3. 换号要改源名 ⇒ 用户书签里的 `源:模型` 全部失效
新设计:
- 源名只跟通道绑定:`channels-<平台>`,换号 = 同名 add_source 覆盖 api_key
(内核 AddSource 是 UpsertSourceInYAML + 内存替换),对客户端完全透明
- `pickAccountForSource()`:凭据可用 → 余额分档(先快作废后永久)→
跳过已标记失效的号。没有 balance 声明的平台不据此排除(不因"不会查"判死)
- `request_end` 钩子:真实请求失败且是**终态**错误(需充值/封禁/凭据永久失效)
时给当前账号打 termErr,下次选号跳过;成功则清标记。
⚠️ 只认终态 —— 429/超时/抖动都**不**标记(那会把好号一个个废掉)
- `refresh_creds` 每 30 分钟巡检后重新选号(兜底心跳,纯本地判定)
- 迁移:同步时自动清掉旧版按账号拆的源(按已知平台前缀匹配,不用贪婪 match)
- 顺带修:`refresh_creds` 漏认 `refresh_fn` ⇒ codearts 的 DPoP 续期永不触发
## 三、discover 支持用已存凭据(真实缺口)
刷新已有源的模型目录,此前必须让运维**再粘一次明文 key** —— 而库里是
`enc:v1:` 密文,WebUI 也不回显明文。结果就是"想刷新 → 得重新申请 key",
很多人干脆不刷,源的目录越放越旧(实例:某源 3 个生图模型从未登记)。
- `{"name":"oin"}` ⇒ 从已存源取 base_url + 解密凭据 + adapter
- 安全边界:已要求 admin;只能探测**本网关已配置的**源(不能拿任意 URL+key
做探测,那就成了 SSRF/密钥外带);响应不回显 key
## 四、image_models 显式声明(补元数据缺失)
`discoveredModelKind` 只看上游 modality 字段,并刻意**不从模型名推断**
(错误的 kind 会毒死 AUTO 链)。但有些网关的 /v1/models 只返回
`{id,object,created,owned_by}` —— 零 modality,于是生图模型全落成 chat,
生图端点永远 `no image source configured`。
不解这个两难:默认仍完全按元数据(不猜),但允许 `image_models` **显式声明**。
写错的 id 明确报 400,不静默造一个不存在的模型。
## 五、UI:对话与生图合并为一个导航项
两者本是同一件事的两种输入(都选模型、都发请求、都读结果),只是 kind 与端点
不同。合并只发生在**导航层**:模型下拉仍按 kind 分组/禁选(把图片模型选进 chat
会请求到错误端点)。两个面板同时存在于 DOM,切换只改 display —— 来回切不会丢
已输入的文字、对话历史或已生成的图。
## 测试
- `TestUI_ImageModeIsReachableFromChatTab`:改为钉"合并后仍可达"(分段控件、
切换函数、面板容器、端点),并断言**旧 tab 三件套必须消失**(否则 goTab 会找
一个不存在的面板)
- 测试夹具显式建 AUTO 链(内核不再 seedAuto):默认一源一 tier 倒序
(TestChatAUTO 验优先级);TestChatAutoChainTierFailover 自己覆盖成同 tier
(验 tier 内轮转)—— 两者验不同机制,不能共用一个默认
- core 的一批断言从"按账号 id 找源"改为"按平台名找源"
gateway/config/provider 全绿;core 的 channels 用例全绿。
TestRealMutants 需工作树干净后才跑(其自带的变异残留防护)。
BREAKING CHANGE: config.Model.Priority 已删除。旧的 priority 字段会被 YAML
解码静默忽略(内核不校验未知字段),因此既有 config.yaml 仍可加载,但该字段
不再有任何作用 —— 调度顺序请通过 AUTO 链的 tier 表达。
177 lines
6.8 KiB
Go
177 lines
6.8 KiB
Go
package core
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// CodeArts 专用适配器是通过**插件自装**(__llmsproxy_adapters.install)
|
||
// 引入的,不是内置适配器 —— 因为 AK/SK 签名属厂商知识,内置里出现 codearts
|
||
// 就破了"内核零厂商名"纪律。
|
||
//
|
||
// 这组判据要证明三件事:
|
||
// 1. 插件加载时确实把适配器装上了
|
||
// 2. 装上之后,源能引用它(不是"装了但引用不到")
|
||
// 3. 它产出的签名是**真的能验**(而不是拼出个像签名的字符串)
|
||
func TestCodeArtsAdapterInstalledByPlugin(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// ① 适配器必须在插件加载后就位
|
||
adapters := c.ListAdapters()
|
||
found := false
|
||
for _, a := range adapters {
|
||
if a.Name == "codearts" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("插件未装 codearts 适配器;已装: %#v", adapters)
|
||
}
|
||
|
||
// ② 归属必须是 channels 插件(证明是它装的,不是内置)
|
||
if owner := ps.AdapterOwnerOf("codearts"); owner != "channels" {
|
||
t.Errorf("适配器归属应为 channels,实得 %q", owner)
|
||
}
|
||
|
||
// ③ 签名必须真的可验:用 Go 标准库按 SDK-HMAC-SHA256 独立复算
|
||
got, err := ps.Call("channels", "__codearts_adapter_sign_probe", map[string]interface{}{
|
||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
|
||
"method": "POST",
|
||
"body": `{"model":"CodeArts-Pangu"}`,
|
||
"api_key": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST","security_token":"STTEST"}`,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("签名探针: %v", err)
|
||
}
|
||
w, _ := got.(map[string]interface{})
|
||
m, _ := w["json"].(map[string]interface{})
|
||
if m == nil {
|
||
t.Fatalf("探针无 json: %#v", got)
|
||
}
|
||
if m["error"] != nil {
|
||
t.Fatalf("探针报错: %v", m["error"])
|
||
}
|
||
auth, _ := m["authorization"].(string)
|
||
if !strings.HasPrefix(auth, "SDK-HMAC-SHA256 Access=AKTEST,SignedHeaders=") {
|
||
t.Fatalf("Authorization 形态不符(参考要求 Access= 不是 Credential=): %s", auth)
|
||
}
|
||
if !strings.Contains(auth, "Signature=") {
|
||
t.Fatalf("Authorization 缺 Signature: %s", auth)
|
||
}
|
||
// 签名后追加的头必须在,且不参与 canonical
|
||
for _, k := range []string{"Agent-Type", "X-Language"} {
|
||
if m["hdr_"+k] == nil || m["hdr_"+k] == "" {
|
||
t.Errorf("缺签名后追加的头 %s", k)
|
||
}
|
||
}
|
||
if m["hdr_Agent-Type"] != "PromptCenter" {
|
||
t.Errorf("Agent-Type = %v,应为 PromptCenter", m["hdr_Agent-Type"])
|
||
}
|
||
// canonical 不得包含签名后追加的头(参考注释点名:它们是签名后加的)
|
||
canon, _ := m["canonical"].(string)
|
||
if strings.Contains(canon, "agent-type") || strings.Contains(canon, "x-language") {
|
||
t.Errorf("canonical 不应含签名后追加的头:\n%s", canon)
|
||
}
|
||
// Authorization 不得进 canonical
|
||
if strings.Contains(canon, "authorization") {
|
||
t.Errorf("canonical 不应含 authorization:\n%s", canon)
|
||
}
|
||
// x-sdk-content-sha256 说明 payload hash 参与了签名
|
||
if !strings.Contains(canon, "x-sdk-content-sha256") {
|
||
t.Errorf("canonical 应含 x-sdk-content-sha256:\n%s", canon)
|
||
}
|
||
// URI 必须以 / 结尾(参考点名的易错点)
|
||
if !strings.Contains(canon, "/api/v2/chat/completions/\n") {
|
||
t.Errorf("canonical 的 URI 未补尾斜杠(参考点名的易错点):\n%s", canon)
|
||
}
|
||
// ④ **独立验签**:用 Go 标准库按 SDK-HMAC-SHA256 从 canonical 复算签名,
|
||
// 必须与适配器给出的 Signature 逐字节相同。
|
||
//
|
||
// 为什么必须有这一步:前面几条只验证"形态正确"(Access=、SignedHeaders、
|
||
// 尾斜杠)。签名算法本身写错(HMAC 输入少一段、canonical 分隔符用错)时
|
||
// 形态依然全对,但上游会 401。复算才是真正的判据。
|
||
sts, _ := m["string_to_sign"].(string)
|
||
if sts == "" {
|
||
t.Fatal("探针未带出 string_to_sign(判据必须比对真实函数算出的中间值)")
|
||
}
|
||
// string_to_sign = "SDK-HMAC-SHA256\n" + date + "\n" + sha256(canonical)
|
||
parts := strings.SplitN(sts, "\n", 3)
|
||
if len(parts) != 3 || parts[0] != "SDK-HMAC-SHA256" {
|
||
t.Fatalf("string_to_sign 头段不符: %q", sts)
|
||
}
|
||
canonSum := sha256.Sum256([]byte(canon))
|
||
if parts[2] != hex.EncodeToString(canonSum[:]) {
|
||
t.Errorf("string_to_sign 的 canonical 摘要不符\n算得: %s\n实得: %s",
|
||
hex.EncodeToString(canonSum[:]), parts[2])
|
||
}
|
||
mac := hmac.New(sha256.New, []byte("SKTEST"))
|
||
mac.Write([]byte(sts))
|
||
wantSig := hex.EncodeToString(mac.Sum(nil))
|
||
gotSig := auth[strings.Index(auth, "Signature=")+len("Signature="):]
|
||
if gotSig != wantSig {
|
||
t.Errorf("签名独立复算不符(算法写错?)\n复算: %s\n实得: %s", wantSig, gotSig)
|
||
}
|
||
|
||
b, _ := json.Marshal(m)
|
||
t.Logf("签名探针输出: %s", string(b))
|
||
}
|
||
|
||
// 源注册必须真的引用插件装的 codearts 适配器 —— "装了但源还指向 openai"
|
||
// 是这类改动最可能的漏配,且不会报错(只是签名不生效、上游 401)。
|
||
func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||
// ⚠️ 必须用 poolCore:它给 channels 授予了 add_source/list_sources 等
|
||
// 内核权限。用 kernelTestCore 裸加载插件时 add_source 会被拒
|
||
//("kernel action add_source is not granted to plugin channels"),
|
||
// 而这个拒绝**不报错、只是不出源** —— 我就在这里绕了一圈才看出根因。
|
||
c, ps := poolCore(t)
|
||
// 建一个 codearts 账号 → 触发源注册
|
||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||
"platform": "codearts",
|
||
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("account_add: %v", err)
|
||
}
|
||
w, _ := m.(map[string]interface{})
|
||
j, _ := w["json"].(map[string]interface{})
|
||
if j == nil || j["error"] != nil {
|
||
t.Fatalf("account_add 报错: %#v", m)
|
||
}
|
||
aid, _ := j["account_id"].(string)
|
||
srcs := listSourcesViaKernel(t, c)
|
||
// ★ 一个通道 = 一个源:源名是 `channels-codearts`,**不含账号 id**。
|
||
// 按账号 id 找会永远失败 —— 这正是"加账号了但测试说没出源"的假故障。
|
||
sm, ok := srcHasAccount(srcs, "codearts")
|
||
if !ok {
|
||
names := []string{}
|
||
for _, x := range srcs {
|
||
if n, _ := x["name"].(string); n != "" {
|
||
names = append(names, n)
|
||
}
|
||
}
|
||
t.Fatalf("codearts 账号 %q 应已同步出通道源;现有源: %v", aid, names)
|
||
}
|
||
if nm, _ := sm["name"].(string); nm != "channels-codearts" {
|
||
t.Errorf("通道源名应为 channels-codearts,实得 %q(账号 id=%s)", nm, aid)
|
||
}
|
||
if ad, _ := sm["adapter"].(string); ad != "codearts" {
|
||
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
|
||
}
|
||
bu, _ := sm["base_url"].(string)
|
||
if !strings.Contains(bu, "snap-access") {
|
||
t.Errorf("base_url 不符: %q", bu)
|
||
}
|
||
}
|