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/免费池的既有改动,另批提交)。
325 lines
10 KiB
Go
325 lines
10 KiB
Go
package gateway
|
||
|
||
import (
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"modelrouter/internal/config"
|
||
"modelrouter/internal/core"
|
||
)
|
||
|
||
// 本文件锁定 R3 的两条不变量:
|
||
// 1. 自定义挂载只能落在允许的命名空间内
|
||
// 2. 任何内核路由都不能被插件声明覆盖 —— 且这条在新增内核路由时**不会失效**
|
||
|
||
// TestValidateCustomMountNamespace 只有 /plugins/ 与 /v1/plugins/ 可被占用。
|
||
func TestValidateCustomMountNamespace(t *testing.T) {
|
||
ok := []string{
|
||
"/plugins/auth/token",
|
||
"/plugins/auth",
|
||
"/v1/plugins/callback",
|
||
"/v1/plugins/callback/recv",
|
||
"/plugins/nested/deep/path",
|
||
}
|
||
for _, p := range ok {
|
||
if _, err := ValidateCustomMount(p); err != nil {
|
||
t.Errorf("合法挂载 %q 被拒: %v", p, err)
|
||
}
|
||
}
|
||
bad := []string{
|
||
"",
|
||
"plugins/x", // 没有前导斜杠
|
||
"/v1/chat/completions", // 影子内核路由(且不在允许命名空间)
|
||
"/api/anything", // 内核命名空间
|
||
"/callback", // 任意前缀
|
||
"/plugins/../etc", // 逃逸
|
||
"/plugins//double", // 空段
|
||
"/v1/plugins/../../x",
|
||
}
|
||
for _, p := range bad {
|
||
if _, err := ValidateCustomMount(p); err == nil {
|
||
t.Errorf("非法挂载 %q 被接受", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCustomMountCannotShadowKernelRoute 逐条核对内核保留前缀。
|
||
func TestCustomMountCannotShadowKernelRoute(t *testing.T) {
|
||
// 这些是要保护的公开接口;任何能覆盖它们的挂载都必须被拒。
|
||
for _, kernel := range []string{"/v1/chat/completions", "/v1/messages", "/v1/models"} {
|
||
// 尝试用允许命名空间里的路径去"贴近"内核路由 —— 例如
|
||
// /v1/plugins/../../v1/models(逃逸),必须被拒。
|
||
cand := "/v1/plugins/../../" + strings.TrimPrefix(kernel, "/")
|
||
if _, err := ValidateCustomMount(cand); err == nil {
|
||
t.Errorf("逃逸路径 %q 应被拒", cand)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestKernelRoutesAreAllCoveredByReservedPrefixes 是这道防线**不腐坏**的保证。
|
||
//
|
||
// 它从 server.go 里把路由 switch 的分支字符串刮出来,逐个检查是否被
|
||
// kernelReservedPrefixes 覆盖。将来有人新增一条内核路由却忘了更新保留表时,
|
||
// 这条测试会失败 —— 否则新路由会静默变成可被插件覆盖的。
|
||
func TestKernelRoutesAreAllCoveredByReservedPrefixes(t *testing.T) {
|
||
src, err := os.ReadFile("server.go")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 抓 switch 里的字面量路径:case r.URL.Path == "/x" / HasPrefix(..., "/x")
|
||
re := regexp.MustCompile(`r\.URL\.Path\s*==\s*"(/[^"]*)"`)
|
||
matches := re.FindAllStringSubmatch(string(src), -1)
|
||
if len(matches) == 0 {
|
||
t.Fatal("没有从 server.go 抓到任何路由字面量 —— 测试自身失效")
|
||
}
|
||
covered := func(p string) bool {
|
||
// "/" is the UI catch-all (serveUI). It is the default branch, not a
|
||
// path a plugin could shadow, and it is covered by the namespace rule:
|
||
// a custom mount must start with /plugins/ or /v1/plugins/, neither of
|
||
// which is "/".
|
||
if p == "/" {
|
||
return true
|
||
}
|
||
for _, res := range kernelReservedPrefixes {
|
||
if res == "/" {
|
||
continue
|
||
}
|
||
rp := strings.TrimRight(res, "/")
|
||
if p == rp || strings.HasPrefix(p, rp+"/") {
|
||
return true
|
||
}
|
||
}
|
||
// /api/plugins/... 由 pluginHTTPPrefix 与 /api/ 覆盖
|
||
if strings.HasPrefix(p, "/api/") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
for _, m := range matches {
|
||
p := m[1]
|
||
if !covered(p) {
|
||
t.Errorf("内核路由 %q 未被 kernelReservedPrefixes 覆盖 —— "+
|
||
"新增路由时必须同步更新保留表,否则插件可影子它", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestMountConflictDetectionBothDirections 冲突检测必须双向。
|
||
//
|
||
// 只查一个方向会让「先注册 /plugins/x,后注册 /plugins/x/sub」或反过来时的
|
||
// 一个被另一个静默吞掉。
|
||
func TestMountConflictDetectionBothDirections(t *testing.T) {
|
||
table := []RouteOwner{{Path: "/plugins/x", Owner: "plugin:a"}}
|
||
// 被包含
|
||
if _, conflict := FindMountConflict("/plugins/x/sub", table); !conflict {
|
||
t.Error("嵌套在已有挂载下的路径应判冲突")
|
||
}
|
||
// 包含
|
||
if _, conflict := FindMountConflict("/plugins", table); !conflict {
|
||
t.Error("包含已有挂载的路径应判冲突")
|
||
}
|
||
// 相同
|
||
if _, conflict := FindMountConflict("/plugins/x", table); !conflict {
|
||
t.Error("完全相同的路径应判冲突")
|
||
}
|
||
// 无关(前缀相似但不是路径段)
|
||
if _, conflict := FindMountConflict("/plugins/xy", table); conflict {
|
||
t.Error("/plugins/xy 与 /plugins/x 不是同一个路径段,不该判冲突")
|
||
}
|
||
}
|
||
|
||
// TestMountConflictNamesTheOwner 冲突信息必须点名持有者(否则不可行动)。
|
||
func TestMountConflictNamesTheOwner(t *testing.T) {
|
||
table := []RouteOwner{{Path: "/plugins/taken", Owner: "plugin:other"}}
|
||
owner, conflict := FindMountConflict("/plugins/taken/deep", table)
|
||
if !conflict {
|
||
t.Fatal("应判冲突")
|
||
}
|
||
if owner.Owner != "plugin:other" {
|
||
t.Errorf("应点名持有者 plugin:other, 实得 %q", owner.Owner)
|
||
}
|
||
}
|
||
|
||
// TestParseMountRef 解析两种命名空间。
|
||
func TestParseMountRef(t *testing.T) {
|
||
cases := []struct {
|
||
in, owner, rest string
|
||
ok bool
|
||
}{
|
||
{"/plugins/auth/token", "auth", "token", true},
|
||
{"/plugins/auth", "auth", "", true},
|
||
{"/v1/plugins/cb/recv", "cb", "recv", true},
|
||
{"/v1/plugins/cb", "cb", "", true},
|
||
{"/plugins/", "", "", false},
|
||
{"/plugins", "", "", false},
|
||
{"/other/x", "", "", false},
|
||
}
|
||
for _, c := range cases {
|
||
owner, rest, ok := parseMountRef(c.in)
|
||
if ok != c.ok || owner != c.owner || rest != c.rest {
|
||
t.Errorf("parseMountRef(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
||
c.in, owner, rest, ok, c.owner, c.rest, c.ok)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBuildRouteTableIncludesKernel 路由表含内核保留项(供冲突提示用)。
|
||
func TestBuildRouteTableIncludesKernel(t *testing.T) {
|
||
table := BuildRouteTable(
|
||
map[string]string{"/plugins/a": "alpha"},
|
||
map[string]string{"/v1/plugins/b": "beta"},
|
||
)
|
||
var hasKernel, hasPlugin, hasAdapter bool
|
||
for _, r := range table {
|
||
if r.Owner == "kernel" {
|
||
hasKernel = true
|
||
}
|
||
if r.Owner == "plugin:alpha" {
|
||
hasPlugin = true
|
||
}
|
||
if r.Owner == "adapter:beta" {
|
||
hasAdapter = true
|
||
}
|
||
}
|
||
if !hasKernel || !hasPlugin || !hasAdapter {
|
||
t.Errorf("路由表应含 kernel/plugin/adapter 三类: %+v", table)
|
||
}
|
||
}
|
||
|
||
// TestPluginServesCustomMountEndToEnd 是 R3 的端到端验收:
|
||
// 插件声明一个自定义对外挂载点,客户端按该路径访问,得到插件的应答。
|
||
func TestPluginServesCustomMountEndToEnd(t *testing.T) {
|
||
ts, key := messagesGateway(t)
|
||
// messagesGateway 用的 core 没有插件目录配置,这里直接建一个带插件的。
|
||
_ = ts
|
||
_ = key
|
||
|
||
dir := t.TempDir()
|
||
cfg := &config.Config{
|
||
Path: filepath.Join(dir, "config.yaml"),
|
||
AdapterDir: filepath.Join(dir, "adapters"),
|
||
PluginDir: filepath.Join(dir, "plugins"),
|
||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||
Listen: "127.0.0.1:0",
|
||
DefaultModel: "AUTO",
|
||
GatewayKeys: []string{"sk-mount-0001"},
|
||
Sources: []config.Source{{
|
||
Name: "s", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||
APIKey: "k", MaxConcurrent: 1,
|
||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||
}},
|
||
}
|
||
c, err := core.NewFromConfig(cfg)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer c.Close()
|
||
gw, err := New(c)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ts2 := httptest.NewServer(gw.Handler())
|
||
defer ts2.Close()
|
||
|
||
// 装一个声明自定义挂载的插件
|
||
code := `
|
||
local plugin = { name = "publicapi", version = "1.0" }
|
||
plugin.http = {
|
||
{ path = "publicapi/token", method = "GET", handler = "token",
|
||
roles = { "admin", "user" }, description = "对外 token 端点" },
|
||
}
|
||
function plugin.token(req)
|
||
return { status = 200, body = '{"token":"abc"}' }
|
||
end
|
||
return plugin
|
||
`
|
||
if err := c.Plugins().LoadSource("publicapi", code); err != nil {
|
||
t.Fatalf("load plugin: %v", err)
|
||
}
|
||
|
||
// 按自定义挂载访问
|
||
req, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
|
||
req.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("自定义挂载 status = %d: %s", resp.StatusCode, body)
|
||
}
|
||
if !strings.Contains(string(body), "abc") {
|
||
t.Errorf("自定义挂载未返回插件应答: %s", body)
|
||
}
|
||
|
||
// /v1/plugins/ 形式同样可用
|
||
req2, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
|
||
req2.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||
resp2, err := http.DefaultClient.Do(req2)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp2.Body.Close()
|
||
if resp2.StatusCode != 200 {
|
||
t.Errorf("/v1/plugins/ 形式 status = %d", resp2.StatusCode)
|
||
}
|
||
|
||
// 未声明的命名空间 404(不能因为落在允许命名空间就放行)
|
||
req3, _ := http.NewRequest("GET", ts2.URL+"/plugins/nobody/here", nil)
|
||
req3.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||
resp3, err := http.DefaultClient.Do(req3)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp3.Body.Close()
|
||
if resp3.StatusCode != 404 {
|
||
t.Errorf("未注册的挂载应 404, 实得 %d", resp3.StatusCode)
|
||
}
|
||
|
||
// 鉴权仍生效:无 key 时,
|
||
// - API 路径(/v1/plugins/…)应 401(isAPIPath 判定)
|
||
// - 浏览器路径(/plugins/…)应 302 跳登录页
|
||
req4, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
|
||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||
return http.ErrUseLastResponse
|
||
}}
|
||
resp4, err := noRedirect.Do(req4)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp4.Body.Close()
|
||
if resp4.StatusCode != 401 {
|
||
t.Errorf("无 key 访问 /v1/plugins/ 应 401, 实得 %d", resp4.StatusCode)
|
||
}
|
||
|
||
req5, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
|
||
resp5, err := noRedirect.Do(req5)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp5.Body.Close()
|
||
if resp5.StatusCode != 302 {
|
||
t.Errorf("无 key 访问 /plugins/ 应 302 跳登录, 实得 %d", resp5.StatusCode)
|
||
}
|
||
if loc := resp5.Header.Get("Location"); !strings.Contains(loc, "/login") {
|
||
t.Errorf("应跳转到 /login, 实得 %q", loc)
|
||
}
|
||
}
|
||
|
||
// TestCustomMountCannotInterceptKernelRoute 端到端确认内核路由不受影响:
|
||
// 即使有插件在用 /plugins/,/v1/chat/completions 与 /v1/models 仍归内核。
|
||
func TestCustomMountCannotInterceptKernelRoute(t *testing.T) {
|
||
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/messages", "/api/stats"} {
|
||
if _, err := ValidateCustomMount(p); err == nil {
|
||
t.Errorf("内核路由 %q 必须不能被声明为自定义挂载", p)
|
||
}
|
||
}
|
||
}
|