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/免费池的既有改动,另批提交)。
236 lines
8.1 KiB
Go
236 lines
8.1 KiB
Go
package core
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"modelrouter/internal/lua"
|
||
)
|
||
|
||
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
|
||
//
|
||
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
|
||
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
|
||
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
|
||
// (codearts 10 + trae 2 全是占位)。
|
||
//
|
||
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
|
||
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
|
||
// ① 默认 dry_run:必须**不删**任何东西
|
||
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
preJ := jsonOf(t, pre)
|
||
if preJ["dry_run"] != true {
|
||
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
|
||
}
|
||
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
|
||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
|
||
}
|
||
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
|
||
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
|
||
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
|
||
}
|
||
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
|
||
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
|
||
}
|
||
|
||
// ② 真执行:删无凭据的,**保留有凭据的**
|
||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||
map[string]interface{}{"dry_run": false}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
|
||
}
|
||
// ★ 关键:留下的必须是**有凭据的那个**
|
||
left := accountIDs(t, h, "codearts")
|
||
if len(left) != 1 || left[0] != "withcred" {
|
||
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
|
||
}
|
||
// 有凭据的账号其凭据也必须仍在(不能被误删)
|
||
if !h.hasCredential("withcred") {
|
||
t.Fatal("有凭据账号的凭据被误删了")
|
||
}
|
||
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
|
||
}
|
||
|
||
// 指定平台时只动那个平台,别的不碰。
|
||
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
|
||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
|
||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||
t.Fatalf("codearts 应剩 1,实得 %d", got)
|
||
}
|
||
if got := len(accountIDs(t, h, "trae")); got != 2 {
|
||
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
|
||
}
|
||
t.Log("✓ 平台范围隔离正确")
|
||
}
|
||
|
||
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
|
||
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
res, err := h.ps.Call("channels", "account_clear_credentialless",
|
||
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
j := jsonOf(t, res)
|
||
if j["error"] == nil {
|
||
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
|
||
}
|
||
// 且什么都没删
|
||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
|
||
}
|
||
}
|
||
|
||
// ── harness ──
|
||
|
||
type accountCleanupHarness struct {
|
||
t *testing.T
|
||
ps *lua.Plugins
|
||
}
|
||
|
||
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
|
||
t.Helper()
|
||
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)
|
||
}
|
||
h := &accountCleanupHarness{t: t, ps: ps}
|
||
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
|
||
for _, id := range []string{"empty1", "empty2", "empty3"} {
|
||
h.add("codearts", id, "")
|
||
}
|
||
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
|
||
h.add("trae", "tempty1", "")
|
||
h.add("trae", "tempty2", "")
|
||
return h
|
||
}
|
||
|
||
func (h *accountCleanupHarness) add(platform, id, cred string) {
|
||
h.t.Helper()
|
||
args := map[string]interface{}{"platform": platform, "account_id": id}
|
||
if cred != "" {
|
||
args["credential"] = cred
|
||
}
|
||
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
|
||
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
|
||
}
|
||
}
|
||
|
||
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
|
||
h.t.Helper()
|
||
res, err := h.ps.Call("channels", "account_list",
|
||
map[string]interface{}{"platform": "codearts"})
|
||
if err != nil {
|
||
h.t.Fatal(err)
|
||
}
|
||
for _, a := range accountsOf(h.t, res) {
|
||
if a["id"] == accountID {
|
||
return a["has_credential"] == true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
|
||
func jsonOf(t *testing.T, got interface{}) map[string]interface{} {
|
||
t.Helper()
|
||
m, ok := got.(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("返回值不是对象: %#v", got)
|
||
}
|
||
j, ok := m["json"].(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("返回值缺 json 层: %#v", m)
|
||
}
|
||
return j
|
||
}
|
||
|
||
// accountsOf 取 account_list 返回的账号数组。
|
||
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
|
||
t.Helper()
|
||
j := jsonOf(t, got)
|
||
arr, _ := j["accounts"].([]interface{})
|
||
out := make([]map[string]interface{}, 0, len(arr))
|
||
for _, e := range arr {
|
||
if m, ok := e.(map[string]interface{}); ok {
|
||
out = append(out, m)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// accountIDs 列某平台的账号 id。
|
||
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
|
||
t.Helper()
|
||
res, err := h.ps.Call("channels", "account_list",
|
||
map[string]interface{}{"platform": platform})
|
||
if err != nil {
|
||
t.Fatalf("account_list(%s): %v", platform, err)
|
||
}
|
||
var out []string
|
||
for _, a := range accountsOf(t, res) {
|
||
if id, ok := a["id"].(string); ok {
|
||
out = append(out, id)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
|
||
//
|
||
// 反例(实测踩到,我自己写的):照其它 handler 写
|
||
//
|
||
// `if args.platform == nil then args = req end`
|
||
//
|
||
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
|
||
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
|
||
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
|
||
//
|
||
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
|
||
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
|
||
h := newAccountCleanupHarness(t)
|
||
|
||
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
|
||
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
|
||
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != 200 {
|
||
t.Fatalf("status=%d body=%v", res.Status, res.Body)
|
||
}
|
||
j, _ := res.JSON.(map[string]interface{})
|
||
if j["dry_run"] == true {
|
||
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
|
||
}
|
||
if j["deleted"] == nil {
|
||
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
|
||
}
|
||
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
|
||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
|
||
}
|
||
if got := len(accountIDs(t, h, "trae")); got != 0 {
|
||
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
|
||
}
|
||
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
|
||
}
|