mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容, 已部署的机器不会因为顺序问题立刻失效。 - go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。 - `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、 `/usr/local/bin` 目标全部改为 modelrouter。 - 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与 `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。 - 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN / BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` → `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。 - 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退, 避免升级到一半的 profile 被判成"core binary missing"。 - 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、 旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。 - `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**, 否则会部署出一个"能启动但没有 key、没有源"的空实例。 - 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。 兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留, 它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。 本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、 `adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
package core
|
|
|
|
import (
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"modelrouter/internal/config"
|
|
)
|
|
|
|
// newTestConfig returns an in-memory config backed by temp files so
|
|
// NewFromConfig (store load, key/auto seeding, registry build) is exercised
|
|
// like production.
|
|
func newTestConfig(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-test"},
|
|
Sources: []config.Source{
|
|
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func newTestCore(t *testing.T, cfg *config.Config) *Core {
|
|
t.Helper()
|
|
c, err := NewFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("core: %v", err)
|
|
}
|
|
t.Cleanup(c.Close)
|
|
return c
|
|
}
|
|
|
|
func TestCleanScopesNilForEmpty(t *testing.T) {
|
|
if got := cleanScopes(nil); got != nil {
|
|
t.Fatalf("nil in must yield nil out, got %#v", got)
|
|
}
|
|
if got := cleanScopes([]config.ModelScope{{Model: ""}}); got != nil {
|
|
t.Fatalf("all-empty entries must yield nil, got %#v", got)
|
|
}
|
|
got := cleanScopes([]config.ModelScope{{Model: "m", Source: "undefined"}})
|
|
if len(got) != 1 || got[0].Source != "" || got[0].Model != "m" {
|
|
t.Fatalf("placeholder normalization wrong: %#v", got)
|
|
}
|
|
}
|
|
|
|
// TestCreateKeyWithoutScopeUnrestricted: a user key created without a model
|
|
// scope must be unrestricted in-process (nil), identical to what config.yaml
|
|
// omitempty produces after a restart — no 403 lock-out, no silent widening.
|
|
func TestCreateKeyWithoutScopeUnrestricted(t *testing.T) {
|
|
c := newTestCore(t, newTestConfig(t))
|
|
k, err := c.CreateKey("svc", "user", nil, "")
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
rec, ok := c.FindKey(k.Key)
|
|
if !ok {
|
|
t.Fatal("key not found")
|
|
}
|
|
if rec.Models != nil {
|
|
t.Fatalf("user key without scope must be unrestricted, got %#v", rec.Models)
|
|
}
|
|
}
|
|
|
|
// TestUpdateKeyPreservesScopeWhenOmitted: PUT /api/keys without a models field
|
|
// must keep the existing scope; only an explicit [] clears it.
|
|
func TestUpdateKeyPreservesScopeWhenOmitted(t *testing.T) {
|
|
c := newTestCore(t, newTestConfig(t))
|
|
k, err := c.CreateKey("svc", "user", []config.ModelScope{{Model: "gpt-4o"}}, "")
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
upd, err := c.UpdateKey(k.Key, "renamed", "", nil, "")
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if len(upd.Models) != 1 || upd.Models[0].Model != "gpt-4o" {
|
|
t.Fatalf("scope must survive an update without models, got %#v", upd.Models)
|
|
}
|
|
cleared, err := c.UpdateKey(k.Key, "", "", []config.ModelScope{}, "")
|
|
if err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
if cleared.Models != nil {
|
|
t.Fatalf("explicit empty list must clear the scope, got %#v", cleared.Models)
|
|
}
|
|
}
|
|
|
|
// TestSaveAutoRulesNormalizesModelCase: a rule whose model name differs only
|
|
// in case must be normalized to the exact configured spelling so cooldown,
|
|
// quota windows and the upstream model id all agree.
|
|
func TestSaveAutoRulesNormalizesModelCase(t *testing.T) {
|
|
c := newTestCore(t, newTestConfig(t))
|
|
if err := c.SaveAutoRules([]config.ModelScope{{Model: "GPT-4O", Source: "s1", Tier: 1}}); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
ch := c.AutoChain()
|
|
if ch == nil || len(ch.Tiers) == 0 || len(ch.Tiers[0].Slots) == 0 {
|
|
t.Fatalf("chain empty: %#v", ch)
|
|
}
|
|
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" {
|
|
t.Fatalf("slot model must be normalized to the config spelling, got %q", got)
|
|
}
|
|
if got := ch.Tiers[0].Slots[0].Source; got != "s1" {
|
|
t.Fatalf("slot source wrong: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentKeyMutation exercises the c.cfg.Keys lock under -race: key
|
|
// creation/lookup/deletion from many goroutines at once.
|
|
func TestConcurrentKeyMutation(t *testing.T) {
|
|
c := newTestCore(t, newTestConfig(t))
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 16; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
k, err := c.CreateKey("k", "user", nil, "")
|
|
if err == nil {
|
|
if rec, ok := c.FindKey(k.Key); ok && rec.Key != k.Key {
|
|
t.Errorf("wrong key returned")
|
|
}
|
|
c.UpdateKey(k.Key, "renamed", "", nil, "")
|
|
c.DeleteKey(k.Key)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|