Files
ModelRouter/internal/core/device_harness_test.go
JianFeeeee 688dce88d1 refactor(rename)!: llmsproxy → modelrouter(module / 二进制 / 路径 / env)
产品名早已是 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/免费池的既有改动,另批提交)。
2026-10-26 22:28:08 +08:00

181 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"modelrouter/internal/lua"
)
// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。
func readBundledChannels(t *testing.T) (string, error) {
t.Helper()
return lua.ReadBundledPlugin("channels")
}
// deviceHarness 是 device-poll 决策表的可复用测试台:假上游(脚本化逐次响应)
// + channels 插件加载指定源码 + minimax 端点指到假上游。
// 行为测试(TestChannelsDevicePollBehavior)与变异测试共用它 —— 决策表只有
// 一份,两处不会漂移。
type deviceHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
step int32
// script 持续被 HTTP handler 原子读取
script atomic.Value // []scriptStep
}
type scriptStep struct {
http int
body string
}
func newDeviceHarness(t *testing.T, src string) *deviceHarness {
t.Helper()
h := &deviceHarness{t: t}
h.script.Store([]scriptStep{})
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/oauth2/token":
i := int(atomic.AddInt32(&h.step, 1)) - 1
script := h.script.Load().([]scriptStep)
w.Header().Set("Content-Type", "application/json")
if i >= len(script) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if script[i].http != 0 && script[i].http != 200 {
w.WriteHeader(script[i].http)
}
fmt.Fprint(w, script[i].body)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载: %v", err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "minimax",
"device": map[string]interface{}{
"code_url": h.ts.URL + "/oauth2/device/code",
"token_url": h.ts.URL + "/oauth2/token",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
// setScript 设置 token 端点的逐次响应脚本。`400 {...}` 形式表示非 200。
func (h *deviceHarness) setScript(steps ...string) {
var out []scriptStep
for _, b := range steps {
code := 200
switch {
case strings.HasPrefix(b, "400 "):
code, b = 400, b[4:]
case strings.HasPrefix(b, "403 "):
code, b = 403, b[4:]
}
out = append(out, scriptStep{http: code, body: b})
}
h.script.Store(out)
atomic.StoreInt32(&h.step, 0)
}
func (h *deviceHarness) begin() map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"minimax"}`,
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
func (h *deviceHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// runDecisionTable 跑完整 8 行决策表,全部符合预期返回 true。
// 这是行为契约的单一事实来源:行为测试用它断言通过;变异测试用它杀变异体。
func (h *deviceHarness) runDecisionTable() bool {
ok := func(cond bool) bool { return cond }
st := func(steps ...string) map[string]interface{} {
h.setScript(steps...)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
// 行1: 200+pending → 等
if j := st(`{"status":"pending"}`); j["state"] != "pending" {
return false
}
// 行2: 200+slow_down → 等 + interval+5
h.setScript(`{"status":"slow_down"}`)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv < 6 {
return false
}
// 行3: 200+denied → failed,message 区分于通用错误(杀"denied 不识别"变异体)
if j := st(`{"status":"denied"}`); j["state"] != "failed" {
return false
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "用户拒绝") {
return false
}
// 行4: 200+expired_token → failed,message 带过期字样
if j := st(`{"status":"expired_token"}`); j["state"] != "failed" {
return false
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "过期") {
return false
}
// 行5: 400+authorization_pending → 等
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
return false
}
// 行6: 403+slow_down → 等 + interval+5
h.setScript(`403 {"error":"slow_down"}`)
j = h.begin()
sid = j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv < 6 {
return false
}
// 行7: 200+access_token → completed(需先授权 add_source)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
h.setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`)
j = h.begin()
sid = j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "completed" || j2["account"] != "mm-user" {
return false
}
// 行8: 其它 error → failed 带文本
if j := st(`{"error":"server_error"}`); j["state"] != "failed" {
return false
}
return ok(true)
}