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/免费池的既有改动,另批提交)。
238 lines
8.3 KiB
Go
238 lines
8.3 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"modelrouter/internal/lua"
|
||
)
|
||
|
||
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
|
||
//
|
||
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
|
||
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
|
||
// 而号里其实有钱。两者的**正确动作完全相反**:
|
||
//
|
||
// exhausted → 等额度重置
|
||
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
|
||
//
|
||
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
|
||
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
|
||
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
h.serveBalance(0, 0)
|
||
got := h.reason()
|
||
if got["reason"] != "exhausted" {
|
||
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
|
||
got["reason"], got["detail"])
|
||
}
|
||
})
|
||
|
||
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
h.serveBalanceError(500)
|
||
got := h.reason()
|
||
if got["reason"] != "unknown" {
|
||
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
|
||
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
|
||
}
|
||
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
|
||
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
|
||
}
|
||
})
|
||
|
||
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
got := h.reason()
|
||
if got["reason"] != "no_accounts" {
|
||
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
|
||
}
|
||
})
|
||
|
||
t.Run("有可用账号 → usable", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
h.serveBalance(100, 0)
|
||
got := h.reason()
|
||
if got["reason"] != "usable" {
|
||
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
|
||
}
|
||
if u, _ := got["usable"].(float64); int(u) != 1 {
|
||
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
|
||
}
|
||
})
|
||
|
||
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
// ⚠ 必须有**启用的账号**:判定的优先级是
|
||
// no_accounts → 无余额端点 → usable → unknown/exhausted
|
||
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
|
||
// 那是**正确**行为),把正确的实现判成了错。
|
||
h.addAccountOn("minimax", "m1")
|
||
// minimax 未声明 balance 端点
|
||
got := h.reasonOn("minimax")
|
||
if got["reason"] != "unknown" {
|
||
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
|
||
got["reason"], got["detail"])
|
||
}
|
||
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
|
||
t.Fatalf("说明必须点明无法判定,实得 %q", d)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
|
||
// (用户动作不同:解锁 vs 等重置)。
|
||
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
// total_fn 取包列表:只有永久积分(到期未知)
|
||
h.serveBalance(0, 50)
|
||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||
map[string]interface{}{"lock": true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := h.reason()
|
||
if got["reason"] != "locked" {
|
||
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
|
||
got["reason"], got["detail"])
|
||
}
|
||
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
|
||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||
map[string]interface{}{"lock": false}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
h.serveBalance(0, 50)
|
||
if got2 := h.reason(); got2["reason"] != "usable" {
|
||
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
|
||
}
|
||
}
|
||
|
||
// ── harness ──
|
||
|
||
type poolReasonHarness struct {
|
||
t *testing.T
|
||
ps *lua.Plugins
|
||
ts *httptest.Server
|
||
body atomic.Value
|
||
fail atomic.Bool
|
||
hasAcct atomic.Bool
|
||
}
|
||
|
||
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
|
||
t.Helper()
|
||
h := &poolReasonHarness{t: t}
|
||
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
|
||
h.body.Store(buddyPkgBody(0, 0))
|
||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if h.fail.Load() {
|
||
w.WriteHeader(500)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
fmt.Fprint(w, h.body.Load().(string))
|
||
}))
|
||
t.Cleanup(h.ts.Close)
|
||
|
||
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.ps = ps
|
||
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
|
||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "buddy",
|
||
"balance": map[string]interface{}{"url": h.ts.URL},
|
||
}); err != nil {
|
||
t.Logf("set_endpoints(balance) 未支持: %v", err)
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (h *poolReasonHarness) addAccount(id string) {
|
||
h.t.Helper()
|
||
h.addAccountOn("buddy", id)
|
||
}
|
||
|
||
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
|
||
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
|
||
// no_accounts —— 判据写错而非实现错)。
|
||
func (h *poolReasonHarness) addAccountOn(platform, id string) {
|
||
h.t.Helper()
|
||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||
"platform": platform, "account_id": id,
|
||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||
}); err != nil {
|
||
h.t.Fatalf("account_add(%s): %v", platform, err)
|
||
}
|
||
h.hasAcct.Store(true)
|
||
}
|
||
|
||
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
|
||
//
|
||
// ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts
|
||
// 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改
|
||
// 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。
|
||
// __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。
|
||
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
|
||
h.fail.Store(false)
|
||
h.body.Store(buddyPkgBody(expiring, permanent))
|
||
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
|
||
h.t.Fatalf("清 TTL 缓存: %v", err)
|
||
}
|
||
}
|
||
|
||
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
|
||
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
|
||
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
|
||
func buddyPkgBody(expiring, permanent int) string {
|
||
pkgs := []string{}
|
||
if expiring > 0 {
|
||
// 3 天到期(< 15 天窗口)
|
||
endMs := (time.Now().Unix() + 3*86400) * 1000
|
||
pkgs = append(pkgs, fmt.Sprintf(
|
||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
|
||
}
|
||
if permanent > 0 {
|
||
// DeductionEndTime=0(未知)⇒ 永久桶
|
||
pkgs = append(pkgs, fmt.Sprintf(
|
||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
|
||
}
|
||
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
|
||
}
|
||
|
||
func (h *poolReasonHarness) serveBalanceError(code int) {
|
||
h.fail.Store(true)
|
||
// 同上:清掉构造期缓存,否则"查询失败"用例会命中旧的成功缓存。
|
||
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
|
||
h.t.Fatalf("清 TTL 缓存: %v", err)
|
||
}
|
||
}
|
||
|
||
func (h *poolReasonHarness) reason() map[string]interface{} {
|
||
h.t.Helper()
|
||
return h.reasonOn("buddy")
|
||
}
|
||
|
||
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
|
||
h.t.Helper()
|
||
res, err := h.ps.Call("channels", "credits_pool_reason",
|
||
map[string]interface{}{"platform": platform})
|
||
if err != nil {
|
||
h.t.Fatalf("credits_pool_reason: %v", err)
|
||
}
|
||
return jsonOf(h.t, res)
|
||
}
|