package core import ( "strings" "testing" "llmsproxy/internal/lua" "fmt" "net/http" "net/http/httptest" ) // channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。 // 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。 func poolCore(t *testing.T) (*Core, *lua.Plugins) { 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) } _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false) return c, ps } // listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。 func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} { t.Helper() out, err := c.Invoke(nil, "list_sources", map[string]interface{}{}) if err != nil { t.Fatalf("list_sources: %v", err) } if ms, ok := out.([]map[string]interface{}); ok { return ms } raw, _ := out.([]interface{}) var srcs []map[string]interface{} for _, s := range raw { if m, ok := s.(map[string]interface{}); ok { srcs = append(srcs, m) } } return srcs } func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} { t.Helper() out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body}) if err != nil { t.Fatalf("invoke %s: %v", fn, err) } return out.JSON.(map[string]interface{}) } // asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组 // 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。 func asArr(v interface{}) []interface{} { switch a := v.(type) { case []interface{}: return a case []string: out := make([]interface{}, len(a)) for i, s := range a { out[i] = s } return out } return nil } func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) { for _, sm := range srcs { if name, _ := sm["name"].(string); strings.Contains(name, needle) { return sm, true } } return nil, false } // TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。 func TestChannelsAccountPoolBehavior(t *testing.T) { c, ps := poolCore(t) t.Run("账号增删改", func(t *testing.T) { m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`) if m["ok"] != true { t.Fatalf("account_add: %v", m) } aid, _ := m["account_id"].(string) if aid == "" { t.Fatalf("account_add 应返回 account_id: %v", m) } m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) accts := asArr(m["accounts"]) if len(accts) != 1 { t.Fatalf("应有1个账号: %d", len(accts)) } a := accts[0].(map[string]interface{}) if a["nickname"] != "我的号" || a["enabled"] != true { t.Fatalf("账号字段: %v", a) } invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`) m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" { t.Fatalf("改名失败") } invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`) m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) if n := len(asArr(m["accounts"])); n != 0 { t.Fatalf("删除后应为0, 实得 %d", n) } }) t.Run("模型开关同步上游源", func(t *testing.T) { m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`) aid, _ := m["account_id"].(string) // ★ 一个通道 = 一个源:「加账号」同步出的是**通道级**源 `channels-`, // 源名里**不含**账号 id(这正是本次重构的目的 —— 换号对客户端透明, // 用户书签里的 `源:模型` 不会因为换号而失效)。 // 故这里按平台名找源,不按 aid 找。 sm, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax") if !ok { t.Fatalf("带凭据账号应已同步出通道源,实际源: %v", listSourcesViaKernel(t, c)) } // 而且源名必须是通道级那个(旧版会带 -<12hex> 后缀) if nm, _ := sm["name"].(string); nm != "channels-minimax" { t.Fatalf("通道源名应为 channels-minimax,实得 %q(账号 id=%s)", nm, aid) } // 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐 // 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**: // 关掉一个模型,源里就少一个。 all := invoke(t, ps, "model_list", `{"platform":"minimax"}`) total := len(asArr(all["models"])) if n := len(asArr(sm["models"])); n != total { t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n) } invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) // 同样按平台名找(源名不含账号 id) sm, _ = srcHasAccount(listSourcesViaKernel(t, c), "minimax") if n := len(asArr(sm["models"])); n != total-1 { t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n) } invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`) m = invoke(t, ps, "provider_status", `{}`) for _, s := range asArr(m["statuses"]) { x := s.(map[string]interface{}) if x["id"] == "minimax" && x["closed"] != true { t.Fatalf("全关模型后应 closed=true: %v", x) } } invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`) }) t.Run("enabled开关同步上游源", func(t *testing.T) { m := invoke(t, ps, "account_list", `{"platform":"minimax"}`) accts := asArr(m["accounts"]) if len(accts) == 0 { t.Fatal("上个用例应留有账号") } aid := accts[0].(map[string]interface{})["id"].(string) // ★ 语义已变(一个通道 = 一个源),断言按「通道源是否存在」判,不按数量差。 // // 为什么不能断言 `after == before-1`:内核的 remove_source 有一条正当 // 保护 —— **拒绝删掉最后一个源**(空 registry 会让每个请求变 503)。 // 本夹具只有这一个源,撤源请求会被这条保护挡住,数量差恒为 0, // 于是断言无论实现对错都失败。生产上有 20+ 源不会撞这条保护, // 但判据不能依赖"恰好有别的源"这种环境事实。 if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok { t.Fatal("前置:minimax 通道源应存在") } invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`) // 停用唯一账号 ⇒ 该通道无可用号 ⇒ 插件**尝试**撤源。夹具里会撞上内核 // 「拒绝删最后一个源」的保护,故不断言源数量,改断言插件自己的判据: // provider_status 里该通道的 enabled 应转为 false(无可用账号)。 // ⚠️ provider_status 的字段形状:`enabled` 是**嵌套在 accounts 里的计数** // (accounts = {total=N, enabled=M}),不是顶层布尔。顶层只有 `closed` // (表示"该平台所有模型都被关掉")。写断言前必须看清形状 —— // 我第一版按顶层 `enabled == false` 判,永远不成立。 st := invoke(t, ps, "provider_status", `{}`) noUsable := false for _, x := range asArr(st["statuses"]) { row := x.(map[string]interface{}) if row["id"] != "minimax" { continue } if acc, ok := row["accounts"].(map[string]interface{}); ok { if n, _ := acc["enabled"].(float64); n == 0 { noUsable = true } } } if !noUsable { t.Fatalf("停用唯一账号后,minimax 的 accounts.enabled 应为 0(无可用账号)") } invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`) if n := len(listSourcesViaKernel(t, c)); n < 1 { t.Fatalf("启用账号后应重建源,实际源数 %d", n) } }) t.Run("供应商整体开关", func(t *testing.T) { invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`) if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok { t.Fatal("关闭渠道后不应有 minimax 源") } m := invoke(t, ps, "provider_status", `{}`) for _, s := range asArr(m["statuses"]) { x := s.(map[string]interface{}) if x["id"] == "minimax" && x["closed"] != true { t.Fatalf("关闭后应 closed: %v", x) } } invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`) if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok { t.Fatal("开启渠道后应有 minimax 源") } }) t.Run("备份导出含凭据", func(t *testing.T) { invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`) exp := invoke(t, ps, "backup_export", `{}`) payload, _ := exp["payload"].(map[string]interface{}) if payload == nil { t.Fatal("backup_export 应返回 payload") } creds, _ := payload["credentials"].(map[string]interface{}) found := false for _, v := range creds { if v == "CL-KEY" { found = true } } if !found { t.Fatalf("备份应含凭据明文: %v", creds) } }) } // TestChannelsExtendedRPCBehavior:对齐参考剩余 RPC 面(credits.status/balances、 // account.reset/retest、model.setDisabledMany/clearDead、provider 排序、 // gateway/backup.status、usage.badgePreference、onboarding)。 func TestChannelsExtendedRPCBehavior(t *testing.T) { _, ps := poolCore(t) t.Run("credits_balances_无凭据报错_有凭据解析", func(t *testing.T) { m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"b1"}`) aid, _ := m["account_id"].(string) j := invoke(t, ps, "credits_balances", `{"platform":"minimax"}`) var row map[string]interface{} for _, a := range asArr(j["accounts"]) { x := a.(map[string]interface{}) if x["accountId"] == aid { row = x } } if row == nil { t.Fatal("balances 应含该账号") } if row["balance"] != nil || row["error"] == nil { t.Fatalf("无凭据应 error 而非编造余额: %v", row) } }) t.Run("credits_status_无端点平台如实null", func(t *testing.T) { m := invoke(t, ps, "account_add", `{"platform":"opencode","nickname":"oc1"}`) aid, _ := m["account_id"].(string) j := invoke(t, ps, "credits_status", `{"platform":"opencode"}`) for _, a := range asArr(j["accounts"]) { x := a.(map[string]interface{}) if x["accountId"] == aid && x["status"] != nil { t.Fatalf("opencode 无状态端点应 null: %v", x) } } }) t.Run("account_reset_clears", func(t *testing.T) { invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"r1"}`) j := invoke(t, ps, "account_reset", `{"platform":"minimax","account_id":"__all__"}`) if j["ok"] != true { t.Fatalf("resetAll: %v", j) } }) t.Run("model_set_disabled_many", func(t *testing.T) { invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":true}`) j := invoke(t, ps, "model_list", `{"platform":"minimax"}`) off := 0 for _, x := range asArr(j["models"]) { mm := x.(map[string]interface{}) if mm["disabled"] == true { off++ } } if off != 2 { t.Fatalf("批量关2模型后应2个disabled, 实得 %d", off) } invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`) }) // model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的 // "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。 // // 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上 // 会删掉可用模型(参考文件头有实测教训),已弃用。 // 新语义的完整判据在 channels_dead_model_test.go。 t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) { // 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么) j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) if j["ok"] != true { t.Fatalf("model_clear_dead 应成功: %v", j) } if n, _ := j["removed"].(float64); n != 0 { t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"]) } // 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`) off := 0 for _, m := range asArr(ml["models"]) { if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true { off++ } } if off != 1 { t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off) } invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`) }) t.Run("provider_order", func(t *testing.T) { invoke(t, ps, "provider_set_order", `{"order":["minimax","trae","bogus"]}`) j := invoke(t, ps, "provider_get_order", `{}`) order := asArr(j["order"]) if len(order) != 2 { t.Fatalf("bogus 应被过滤, 实得 %v", order) } if order[0] != "minimax" { t.Fatalf("顺序应保留: %v", order) } }) t.Run("backup_status", func(t *testing.T) { invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"s1"}`) j := invoke(t, ps, "backup_status", `{}`) if n, _ := j["accounts"].(float64); n < 1 { t.Fatalf("backup_status 应统计账号: %v", j) } }) t.Run("badge_preference_非法值拒绝", func(t *testing.T) { j := invoke(t, ps, "usage_badge_preference", `{"preference":"bogus"}`) if j["error"] == nil { t.Fatalf("非法偏好应拒绝: %v", j) } j = invoke(t, ps, "usage_badge_preference", `{"preference":"credits"}`) if j["preference"] != "credits" { t.Fatalf("合法偏好应保存: %v", j) } }) t.Run("onboarding_不支持平台如实报", func(t *testing.T) { j := invoke(t, ps, "onboarding_status", `{"platform":"minimax"}`) if j["supported"] != false { t.Fatalf("minimax 无新人任务应 supported=false: %v", j) } }) t.Run("permanent_lock_读写", func(t *testing.T) { j := invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy","locked":true}`) if j["locked"] != true { t.Fatalf("锁定应生效: %v", j) } j = invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy"}`) if j["locked"] != true { t.Fatalf("只读应回显锁定: %v", j) } }) } // TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考 // checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后 // 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。 func TestBuddyBillingHeadersMatchReference(t *testing.T) { _, ps := poolCore(t) var got map[string]string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = map[string]string{} for k := range r.Header { got[k] = r.Header.Get(k) } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"data":{"totalCredit":42}}`) })) defer ts.Close() // 把 buddy 的余额/签到端点指到本地假上游 invoke(t, ps, "set_endpoints", fmt.Sprintf( `{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL)) m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`) if m["ok"] != true { t.Fatalf("account_add: %v", m) } invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) if got == nil { t.Fatal("余额请求未发出") } // 参考 checkinHeaders 的必备身份头(缺一个上游就 401) want := map[string]string{ "Authorization": "Bearer BD-AT", "X-Domain": "copilot.tencent.com", "X-Product": "SaaS", "X-Product-Code": "codebuddy", "User-Agent": "CodeBuddyIDE/1.106.1", } for k, v := range want { if got[k] != v { t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v) } } } // TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径: // 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同, // 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain —— // 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。 func TestBuddyBalanceParseDoubleNested(t *testing.T) { _, ps := poolCore(t) // 两个有效包 + 一个失效包(ExpiredTime 在 2020 年) body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[ {"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"}, {"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"}, {"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"} ]}}}}` ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, body) })) defer ts.Close() invoke(t, ps, "set_endpoints", fmt.Sprintf( `{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL)) invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`) j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) var row map[string]interface{} for _, a := range asArr(j["accounts"]) { row = a.(map[string]interface{}) } bal, _ := row["balance"].(map[string]interface{}) if bal == nil { t.Fatalf("应解析出余额, 实得 error=%v", row["error"]) } // 100.5 + 55.17 = 155.67(失效包的 500 不计入) if got, _ := bal["total"].(float64); got != 155.67 { t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"]) } // 单层 data(缺 Response 层)必须判为解析失败,不能当 0 body = `{"code":0,"data":{"TotalDosage":609}}` j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) for _, a := range asArr(j["accounts"]) { row = a.(map[string]interface{}) } if row["balance"] != nil { t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row) } }