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/免费池的既有改动,另批提交)。
235 lines
7.9 KiB
Go
235 lines
7.9 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"modelrouter/internal/lua"
|
||
)
|
||
|
||
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
|
||
//
|
||
// 参考语义(raccoon-oauth.ts 点名):
|
||
//
|
||
// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code
|
||
// ② 轮询 POST /login_with_qrcode_code {qrcode_code}
|
||
// → data.status ∈ pending / logging / canceled / success
|
||
// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死)
|
||
// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status)
|
||
type raccoonHarness struct {
|
||
t *testing.T
|
||
ps *lua.Plugins
|
||
ts *httptest.Server
|
||
polls int32
|
||
step int32 // 第几次轮询开始 success
|
||
gotCode string
|
||
// ★ 2026-10-20 新增:IAM 建码流程。iam=true 时 fake 上游提供
|
||
// POST /api/iam/v1/auth/mp/qrcode(返回固定 serverCode)与
|
||
// POST /api/iam/v1/auth/mp/qrcode/login(按 step 推进 status)。
|
||
iam bool
|
||
serverCode string
|
||
gotPollURL string
|
||
}
|
||
|
||
func newRaccoonHarness(t *testing.T) *raccoonHarness {
|
||
t.Helper()
|
||
h := &raccoonHarness{t: t, step: 3}
|
||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
// ── IAM 建码流程(上游 2026-10 起的现行流程)──
|
||
if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode") {
|
||
if !h.iam {
|
||
w.WriteHeader(404)
|
||
return
|
||
}
|
||
fmt.Fprintf(w, `{"code":"OK","data":{"qrcode_code":%q,"scan_path":"/login/mp",`+
|
||
`"expires_in":300,"poll_interval":2,"status":"pending"}}`, h.serverCode)
|
||
return
|
||
}
|
||
if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode/login") {
|
||
h.gotPollURL = r.URL.Path
|
||
h.polls++
|
||
if !h.iam {
|
||
w.WriteHeader(404)
|
||
return
|
||
}
|
||
if h.polls < atomic.LoadInt32(&h.step) {
|
||
fmt.Fprint(w, `{"code":"OK","data":{"status":"logging","poll_interval":2}}`)
|
||
return
|
||
}
|
||
fmt.Fprint(w, `{"code":"OK","data":{"status":"success","access_token":"RC-AT-IAM",`+
|
||
`"token_type":"Bearer"}}`)
|
||
return
|
||
}
|
||
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
|
||
h.polls++
|
||
if h.polls < atomic.LoadInt32(&h.step) {
|
||
fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`)
|
||
return
|
||
}
|
||
fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`)
|
||
return
|
||
}
|
||
w.WriteHeader(404)
|
||
}))
|
||
t.Cleanup(h.ts.Close)
|
||
|
||
c := kernelTestCore(t)
|
||
h.ps = c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "raccoon",
|
||
"qr": map[string]interface{}{
|
||
"api_base": h.ts.URL,
|
||
"auth_prefix": "/api/web/auth/v1",
|
||
"poll_path": "/login_with_qrcode_code",
|
||
"login_page": "/login/mp",
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("set_endpoints: %v", err)
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (h *raccoonHarness) begin() (map[string]interface{}, string) {
|
||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
h.t.Fatal(err)
|
||
}
|
||
j := out.JSON.(map[string]interface{})
|
||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||
}
|
||
|
||
func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
|
||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
h.t.Fatal(err)
|
||
}
|
||
return out.JSON.(map[string]interface{})
|
||
}
|
||
|
||
// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。
|
||
func TestRaccoonQrPollBehavior(t *testing.T) {
|
||
h := newRaccoonHarness(t)
|
||
|
||
t.Run("行1 服务端建码失败时退回本地code并给出可用页面URL", func(t *testing.T) {
|
||
// ★ 2026-10-20:上游 /login/mp 已 404,登录落地页改为 /login(实测 200,
|
||
// 页面内含「微信扫码」面板并接受 code 预置)。故断言改为 /login。
|
||
atomic.StoreInt32(&h.polls, 0)
|
||
j, _ := h.begin()
|
||
qr, _ := j["qr_url"].(string)
|
||
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "/login?") {
|
||
t.Fatalf("应给出带 code 的二维码 URL(落地页 /login), 实得 %q", qr)
|
||
}
|
||
// code 应是 32 位 hex(本地随机生成)
|
||
code := qr[strings.Index(qr, "code=")+5:]
|
||
if i := strings.IndexAny(code, "&"); i > 0 {
|
||
code = code[:i]
|
||
}
|
||
if len(code) != 32 {
|
||
t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code)
|
||
}
|
||
})
|
||
|
||
t.Run("行2 logging保持pending", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.polls, 0)
|
||
atomic.StoreInt32(&h.step, 99)
|
||
_, sid := h.begin()
|
||
for i := 0; i < 3; i++ {
|
||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("行3 success且带token才完成", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.polls, 0)
|
||
atomic.StoreInt32(&h.step, 2)
|
||
_, sid := h.begin()
|
||
h.pollOnce(sid)
|
||
st := h.pollOnce(sid)
|
||
if st["state"] != "completed" {
|
||
t.Fatalf("success+token 应 completed, 实得 %v", st)
|
||
}
|
||
v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon")
|
||
if !ok || !strings.Contains(v, "RC-AT-1") {
|
||
t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v)
|
||
}
|
||
})
|
||
|
||
t.Run("行4 canceled是终态失败", func(t *testing.T) {
|
||
hh := newRaccoonHarness(t)
|
||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`)
|
||
})
|
||
hh.ps.SetSecret("channels", "channels:raccoon", "")
|
||
_, sid := hh.begin()
|
||
if st := hh.pollOnce(sid); st["state"] != "failed" {
|
||
t.Fatalf("canceled 应 failed, 实得 %v", st["state"])
|
||
}
|
||
})
|
||
|
||
t.Run("行6 IAM 建码:用服务端 code 且轮询走 IAM 端点", func(t *testing.T) {
|
||
// 上游现行流程:POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用 scan_path
|
||
// (已废弃的 /login/mp 被换成 /login)→ 轮询 /auth/mp/qrcode/login。
|
||
// 旧实现是本地造码+旧端点,服务端根本没有该会话 ⇒ 扫码永远 pending。
|
||
hh := newRaccoonHarness(t)
|
||
hh.iam = true
|
||
hh.serverCode = "qr-01a156a0-1111-2222-3333-444455556666"
|
||
hh.step = 2
|
||
j, sid := hh.begin()
|
||
qr, _ := j["qr_url"].(string)
|
||
if !strings.Contains(qr, "code="+hh.serverCode) {
|
||
t.Fatalf("必须使用**服务端**下发的 qrcode_code, 实得 %q", qr)
|
||
}
|
||
if strings.Contains(qr, "/login/mp") {
|
||
t.Fatalf("不能再用已 404 的 /login/mp, 实得 %q", qr)
|
||
}
|
||
if !strings.Contains(qr, "&source=platform") {
|
||
t.Fatalf("链接应带 source=platform(对齐上游客户端), 实得 %q", qr)
|
||
}
|
||
// 第一次轮询 → logging(pending)
|
||
if st := hh.pollOnce(sid); st["state"] != "pending" {
|
||
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
|
||
}
|
||
// 第二次轮询 → success + token → completed
|
||
if st := hh.pollOnce(sid); st["state"] != "completed" {
|
||
t.Fatalf("IAM success+token 应 completed, 实得 %v", st["state"])
|
||
}
|
||
v, ok, _ := hh.ps.GetSecret("channels", "channels:raccoon")
|
||
if !ok || !strings.Contains(v, "RC-AT-IAM") {
|
||
t.Fatalf("凭据应含 RC-AT-IAM, 实得 %q", v)
|
||
}
|
||
if !strings.Contains(hh.gotPollURL, "/api/iam/v1/auth/mp/qrcode/login") {
|
||
t.Fatalf("轮询必须打 IAM 端点, 实得 %q", hh.gotPollURL)
|
||
}
|
||
})
|
||
|
||
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
|
||
hh := newRaccoonHarness(t)
|
||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`)
|
||
})
|
||
_, sid := hh.begin()
|
||
st := hh.pollOnce(sid)
|
||
if st["state"] != "pending" {
|
||
t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
|
||
}
|
||
})
|
||
}
|