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/免费池的既有改动,另批提交)。
181 lines
5.5 KiB
Go
181 lines
5.5 KiB
Go
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)
|
||
}
|