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 表达。
222 lines
7.7 KiB
Go
222 lines
7.7 KiB
Go
package core
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"llmsproxy/internal/config"
|
|
"llmsproxy/internal/scheduler"
|
|
)
|
|
|
|
// Per-key AUTO chains let an admin give one user their own scheduling chain
|
|
// while every other key keeps the global one. These tests pin the three
|
|
// properties that make that safe:
|
|
//
|
|
// 1. a key WITH its own chain gets it;
|
|
// 2. a key WITHOUT one inherits the global chain (backward compatible);
|
|
// 3. the override is actually persisted, so it survives a restart.
|
|
//
|
|
// A test that only checked (1) would pass even if AutoChainFor ignored the key
|
|
// and always returned the global chain whenever the two happened to be equal,
|
|
// so (2) uses deliberately *different* chains.
|
|
|
|
func perKeyTestConfig(t *testing.T) *config.Config {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
return &config.Config{
|
|
Path: filepath.Join(dir, "config.yaml"),
|
|
AdapterDir: filepath.Join(dir, "adapters"),
|
|
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
|
Listen: "127.0.0.1:0",
|
|
DefaultModel: "AUTO",
|
|
GatewayKeys: []string{"sk-gw-admin"},
|
|
Sources: []config.Source{
|
|
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
|
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
|
|
},
|
|
// Global chain points at gpt-4o.
|
|
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
|
|
Keys: []config.GWKey{
|
|
{Key: "sk-gw-admin", Role: "admin"},
|
|
{Key: "sk-gw-inherits", Role: "user", Name: "inherits"},
|
|
// This key's own chain points at a DIFFERENT model, so returning
|
|
// the global chain by mistake is detectable.
|
|
{Key: "sk-gw-own", Role: "user", Name: "own",
|
|
Auto: []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func firstSlotModel(t *testing.T, c *Core, key string) string {
|
|
t.Helper()
|
|
ch, _ := c.AutoChainFor(key)
|
|
if ch == nil || len(ch.Tiers) == 0 {
|
|
t.Fatalf("key %s: no chain tiers", key)
|
|
}
|
|
slots := ch.Tiers[0].Slots
|
|
if len(slots) == 0 {
|
|
t.Fatalf("key %s: tier 1 has no slots", key)
|
|
}
|
|
return slots[0].Model
|
|
}
|
|
|
|
// A key that declares its own chain must be scheduled by it, not the global.
|
|
func TestAutoChainForUsesKeyOwnChain(t *testing.T) {
|
|
c := newTestCore(t, perKeyTestConfig(t))
|
|
|
|
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o-mini" {
|
|
t.Fatalf("key with own chain must use it, got %q (want gpt-4o-mini)", got)
|
|
}
|
|
// Sanity: the global chain really is different, so the assertion above
|
|
// cannot pass by accident.
|
|
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
|
|
t.Fatalf("admin must use global chain, got %q (want gpt-4o)", got)
|
|
}
|
|
}
|
|
|
|
// A key with no own chain inherits the global one. This is what keeps every
|
|
// pre-existing deployment working with no config change.
|
|
func TestAutoChainForInheritsGlobalWhenUnset(t *testing.T) {
|
|
c := newTestCore(t, perKeyTestConfig(t))
|
|
|
|
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o" {
|
|
t.Fatalf("key without own chain must inherit global, got %q (want gpt-4o)", got)
|
|
}
|
|
}
|
|
|
|
// A key that is not in the key table at all (e.g. a seed key) must also
|
|
// inherit the global chain rather than erroring or returning nil.
|
|
func TestAutoChainForUnknownKeyInheritsGlobal(t *testing.T) {
|
|
c := newTestCore(t, perKeyTestConfig(t))
|
|
|
|
ch, ok := c.AutoChainFor("sk-gw-never-configured")
|
|
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
|
t.Fatalf("unknown key must fall back to global chain, got ok=%v chain=%v", ok, ch)
|
|
}
|
|
}
|
|
|
|
// The per-key chain must survive a save/reload round trip: an admin configuring
|
|
// a chain in the WebUI and restarting the gateway must not silently lose it.
|
|
func TestSaveKeyAutoPersistsAndApplies(t *testing.T) {
|
|
cfg := perKeyTestConfig(t)
|
|
c := newTestCore(t, cfg)
|
|
|
|
if err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
|
|
{Model: "gpt-4o-mini", Source: "s1", Tier: 1},
|
|
}); err != nil {
|
|
t.Fatalf("SaveKeyAuto: %v", err)
|
|
}
|
|
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o-mini" {
|
|
t.Fatalf("saved chain must apply immediately, got %q", got)
|
|
}
|
|
|
|
// Reload from disk exactly like a restart does.
|
|
reloaded, err := config.Load(cfg.Path)
|
|
if err != nil {
|
|
t.Fatalf("reload config: %v", err)
|
|
}
|
|
c2 := newTestCore(t, reloaded)
|
|
if got := firstSlotModel(t, c2, "sk-gw-inherits"); got != "gpt-4o-mini" {
|
|
t.Fatalf("per-key chain must survive restart, got %q", got)
|
|
}
|
|
// The other key must be unaffected by its neighbour's override.
|
|
if got := firstSlotModel(t, c2, "sk-gw-admin"); got != "gpt-4o" {
|
|
t.Fatalf("override must not leak to other keys, got %q", got)
|
|
}
|
|
}
|
|
|
|
// Clearing the override must return the key to the global chain, both in
|
|
// memory and after a restart. This is what the WebUI's "use global" toggle
|
|
// does, so a stale shadowing entry would silently pin the user to their old
|
|
// chain forever.
|
|
func TestSaveKeyAutoClearRestoresGlobal(t *testing.T) {
|
|
cfg := perKeyTestConfig(t)
|
|
c := newTestCore(t, cfg)
|
|
|
|
if err := c.SaveKeyAuto("sk-gw-own", nil); err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o" {
|
|
t.Fatalf("cleared key must inherit global again, got %q", got)
|
|
}
|
|
|
|
reloaded, err := config.Load(cfg.Path)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
c2 := newTestCore(t, reloaded)
|
|
if got := firstSlotModel(t, c2, "sk-gw-own"); got != "gpt-4o" {
|
|
t.Fatalf("clear must survive restart, got %q", got)
|
|
}
|
|
}
|
|
|
|
// Saving an unknown key must fail loudly rather than creating a shadow entry
|
|
// that no request can ever match.
|
|
func TestSaveKeyAutoUnknownKeyFails(t *testing.T) {
|
|
c := newTestCore(t, perKeyTestConfig(t))
|
|
|
|
if err := c.SaveKeyAuto("sk-gw-nope", []config.ModelScope{{Model: "gpt-4o", Source: "s1"}}); err == nil {
|
|
t.Fatal("SaveKeyAuto on unknown key must return an error")
|
|
}
|
|
}
|
|
|
|
// Per-key quota validation must run, exactly like the model scope path: an
|
|
// override is another place an operator can write a bad budget.
|
|
func TestSaveKeyAutoRejectsBadQuota(t *testing.T) {
|
|
c := newTestCore(t, perKeyTestConfig(t))
|
|
|
|
err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
|
|
{Model: "gpt-4o", Source: "s1", TokenQuota: -5},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("negative token quota in a per-key auto chain must be rejected")
|
|
}
|
|
}
|
|
|
|
// Source changes must recompile per-key chains. A key whose chain names a
|
|
// model that does not exist yet compiles to an empty chain (BuildChain drops
|
|
// slots it cannot resolve — sending the request into a black hole would be
|
|
// worse), and must start working as soon as the source appears.
|
|
func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
|
|
cfg := perKeyTestConfig(t)
|
|
cfg.Keys = append(cfg.Keys, config.GWKey{
|
|
Key: "sk-gw-late", Role: "user", Name: "late",
|
|
Auto: []config.ModelScope{{Model: "new-model", Source: "s2", Tier: 1}},
|
|
})
|
|
c := newTestCore(t, cfg)
|
|
|
|
// s2 does not exist yet, so the chain has no usable slot.
|
|
if ch, _ := c.AutoChainFor("sk-gw-late"); chainSlotsOf(ch) != 0 {
|
|
t.Fatalf("chain naming an unknown model must compile empty, got %d slots",
|
|
chainSlotsOf(ch))
|
|
}
|
|
// The other keys are unaffected by the broken one.
|
|
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
|
|
t.Fatalf("broken per-key chain must not affect others, got %q", got)
|
|
}
|
|
|
|
// Add the source; the per-key chain must pick it up without a restart.
|
|
cfg.Sources = append(cfg.Sources, config.Source{
|
|
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
|
|
Models: []config.Model{{ID: "new-model"}},
|
|
})
|
|
if err := c.Reload(); err != nil {
|
|
t.Fatalf("Reload: %v", err)
|
|
}
|
|
if got := firstSlotModel(t, c, "sk-gw-late"); got != "new-model" {
|
|
t.Fatalf("per-key chain must resolve after the source is added, got %q", got)
|
|
}
|
|
}
|
|
|
|
// chainSlotsOf counts usable slots, tolerating a nil chain.
|
|
func chainSlotsOf(ch *scheduler.Chain) int {
|
|
if ch == nil {
|
|
return 0
|
|
}
|
|
n := 0
|
|
for _, tn := range ch.Tiers {
|
|
n += len(tn.Slots)
|
|
}
|
|
return n
|
|
}
|