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/免费池的既有改动,另批提交)。
151 lines
4.5 KiB
Go
151 lines
4.5 KiB
Go
package gateway
|
|
|
|
// The /api/plugins/{name}/call/{fn} endpoint splits its path with string
|
|
// surgery, and the split point is easy to get wrong in a way that still
|
|
// "works": keeping the "call/" segment in the function name produced
|
|
// `does not export "call/collect_account"` — the plugin was correct, the router
|
|
// was not, and every call 502'd while the exports list looked perfectly fine.
|
|
//
|
|
// These tests drive the real router, so a refactor that reintroduces the
|
|
// off-by-one is caught here rather than in production.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"modelrouter/internal/config"
|
|
"modelrouter/internal/core"
|
|
)
|
|
|
|
// callPlugin exposes exactly one export plus a function that is NOT exported.
|
|
// If the exports gate is not honoured, secretFn is reachable and with it the
|
|
// plugin's private state — for a plugin holding an API key that means handing
|
|
// the key to anyone who can POST to the gateway.
|
|
const callPlugin = `
|
|
local P = {}
|
|
P.name = "calltest"
|
|
P.version = "1.0.0"
|
|
P.exports = { "add" }
|
|
P.secret = "do-not-leak"
|
|
function P.add(a)
|
|
return { result = (tonumber(a and a.x) or 0) + 1 }
|
|
end
|
|
function P.secretFn()
|
|
return { leaked = P.secret }
|
|
end
|
|
return P
|
|
`
|
|
|
|
func gatewayWithCallPlugin(t *testing.T) *Gateway {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.yaml")
|
|
body := "listen: :0\n" +
|
|
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
|
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
|
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
|
"gateway_keys:\n - sk-test\n"
|
|
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := core.NewFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("core: %v", err)
|
|
}
|
|
t.Cleanup(c.Close)
|
|
if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil {
|
|
t.Fatalf("load calltest: %v", err)
|
|
}
|
|
g, err := New(c)
|
|
if err != nil {
|
|
t.Fatalf("gateway: %v", err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodPost,
|
|
"/api/plugins/calltest/call/add", `{"x":41}`)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var out struct {
|
|
Result struct {
|
|
Result int `json:"result"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
|
}
|
|
// 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/"
|
|
// segment did not leak into the function name, and the payload arrived.
|
|
if out.Result.Result != 42 {
|
|
t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+
|
|
"function name or the argument table never reached Lua", out.Result.Result)
|
|
}
|
|
}
|
|
|
|
func TestPluginCallRefusesUndeclaredFunction(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
rr := doReq(t, g, http.MethodPost,
|
|
"/api/plugins/calltest/call/secretFn", `{}`)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("an unexported function must not be callable, got 200: %s",
|
|
rr.Body.String())
|
|
}
|
|
if strings.Contains(rr.Body.String(), "do-not-leak") {
|
|
t.Fatal("the refusal leaked the plugin's private state")
|
|
}
|
|
}
|
|
|
|
// A plugin that declares no exports at all has no callable surface. Reading the
|
|
// missing list as "allow everything" would turn every plugin into a remote
|
|
// evaluator the moment this endpoint ships.
|
|
func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
const bare = `
|
|
local P = {}
|
|
P.name = "bare"
|
|
P.version = "1.0.0"
|
|
function P.anything()
|
|
return { ok = true }
|
|
end
|
|
return P
|
|
`
|
|
if err := g.core.Plugins().LoadSource("bare", bare); err != nil {
|
|
t.Fatalf("load bare: %v", err)
|
|
}
|
|
rr := doReq(t, g, http.MethodPost,
|
|
"/api/plugins/bare/call/anything", `{}`)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s",
|
|
rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginCallIsAdminOnly(t *testing.T) {
|
|
g := gatewayWithCallPlugin(t)
|
|
// A real user-role key, not a bare one from config: sk-test/sk-user in the
|
|
// config both default to admin, so a test using them would pass the gate for
|
|
// the wrong reason and prove nothing.
|
|
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rr := doReqAs(t, g, rec.Key, http.MethodPost,
|
|
"/api/plugins/calltest/call/add", `{"x":1}`)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s",
|
|
rr.Code, rr.Body.String())
|
|
}
|
|
}
|