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/免费池的既有改动,另批提交)。
232 lines
8.2 KiB
Go
232 lines
8.2 KiB
Go
// Package gateway — 对外挂载点(R3 / Phase 6)
|
||
//
|
||
// 用户需求:插件与适配器可以注册**对外服务的端口**。
|
||
//
|
||
// 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。
|
||
// 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由),
|
||
// 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰
|
||
// 需要这个(例如给上游回调用的固定路径 /v1/plugins/<name>/callback)。
|
||
//
|
||
// 本文件放开这一点,同时把当初那条限制的**三个理由**逐条补回来:
|
||
//
|
||
// 1. 可 grep / 可整体撤销 → 每条自定义挂载都必须落在 /plugins/ 或
|
||
// /v1/plugins/ 命名空间内(不接受任意前缀)
|
||
// 2. 不能影子内核路由 → 与内核保留路由表做前缀冲突检测,撞了直接拒绝
|
||
// 3. 鉴权默认关闭 → 沿用 HTTPHandler 的 roles/keys/public 语义
|
||
package gateway
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
|
||
"modelrouter/internal/lua"
|
||
)
|
||
|
||
// allowedMountPrefixes are the only namespaces a plugin/adapters may claim.
|
||
//
|
||
// A free-form prefix would let a plugin register "/v1/chat/completions" and
|
||
// silently intercept every inference request on the gateway — the exact
|
||
// shadowing the narrow default prefix was protecting against. Constraining the
|
||
// namespace keeps the security property while still allowing a stable public
|
||
// path.
|
||
var allowedMountPrefixes = []string{"/plugins/", "/v1/plugins/"}
|
||
|
||
// kernelReservedPrefixes are prefixes the kernel owns. A custom mount that
|
||
// overlaps any of them is refused, so an operator's route table can never
|
||
// change meaning because a plugin was installed.
|
||
//
|
||
// This list is deliberately explicit rather than derived from the router's
|
||
// switch: the switch is code, and a future branch added there would silently
|
||
// become shadowable if this were inferred. The test
|
||
// TestKernelRoutesAreAllCoveredByReservedPrefixes keeps the two in sync.
|
||
var kernelReservedPrefixes = []string{
|
||
"/v1/chat/completions",
|
||
"/v1/messages",
|
||
"/v1/images/generations",
|
||
"/v1/models",
|
||
"/api/",
|
||
"/login",
|
||
"/ui",
|
||
"/",
|
||
}
|
||
|
||
// ValidateCustomMount checks that a plugin-declared mount point is allowed.
|
||
//
|
||
// Returns the normalized path (leading slash, no trailing slash) or an error
|
||
// naming the conflict. The error text is what an operator sees in the plugin's
|
||
// load-error field, so it says WHICH rule was hit.
|
||
func ValidateCustomMount(p string) (string, error) {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
return "", fmt.Errorf("empty mount path")
|
||
}
|
||
if !strings.HasPrefix(p, "/") {
|
||
return "", fmt.Errorf("mount path %q must start with /", p)
|
||
}
|
||
if strings.Contains(p, "..") || strings.Contains(p, "//") {
|
||
return "", fmt.Errorf("mount path %q contains an empty or escaping segment", p)
|
||
}
|
||
norm := strings.TrimRight(p, "/")
|
||
if norm == "" {
|
||
norm = "/"
|
||
}
|
||
|
||
// Rule 1: must live in one of the allowed namespaces.
|
||
ok := false
|
||
for _, pre := range allowedMountPrefixes {
|
||
if strings.HasPrefix(norm+"/", pre) {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
return "", fmt.Errorf("mount path %q must be under %s",
|
||
norm, strings.Join(allowedMountPrefixes, " or "))
|
||
}
|
||
|
||
// Rule 2: must not overlap a kernel route.
|
||
for _, res := range kernelReservedPrefixes {
|
||
if res == "/" {
|
||
continue // the catch-all is handled by the namespace rule above
|
||
}
|
||
if norm == strings.TrimRight(res, "/") || strings.HasPrefix(norm, res) {
|
||
return "", fmt.Errorf("mount path %q collides with the kernel route %q", norm, res)
|
||
}
|
||
}
|
||
return norm, nil
|
||
}
|
||
|
||
// RouteOwner describes one occupied route, for conflict reporting.
|
||
type RouteOwner struct {
|
||
Path string
|
||
Owner string // "kernel", "plugin:name", "adapter:name"
|
||
}
|
||
|
||
// BuildRouteTable assembles the set of paths a plugin/adapters must not take.
|
||
// It is used both to validate new mounts and to explain a conflict to an
|
||
// operator (a bare "conflict" is not actionable; "already served by plugin X" is).
|
||
func BuildRouteTable(pluginMounts, adapterMounts map[string]string) []RouteOwner {
|
||
var out []RouteOwner
|
||
for _, p := range kernelReservedPrefixes {
|
||
if p == "/" {
|
||
continue
|
||
}
|
||
out = append(out, RouteOwner{Path: strings.TrimRight(p, "/"), Owner: "kernel"})
|
||
}
|
||
for path, owner := range pluginMounts {
|
||
out = append(out, RouteOwner{Path: path, Owner: "plugin:" + owner})
|
||
}
|
||
for path, owner := range adapterMounts {
|
||
out = append(out, RouteOwner{Path: path, Owner: "adapter:" + owner})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||
return out
|
||
}
|
||
|
||
// FindMountConflict reports whether candidate overlaps an occupied route and, if
|
||
// so, who holds it.
|
||
//
|
||
// Overlap is checked in BOTH directions: a new mount that CONTAINS an existing
|
||
// one shadows it just as effectively as one nested inside it, and only checking
|
||
// the prefix direction would let "/plugins/x/sub" be registered before
|
||
// "/plugins/x" and then silently swallow it.
|
||
func FindMountConflict(candidate string, table []RouteOwner) (RouteOwner, bool) {
|
||
c := strings.TrimRight(candidate, "/")
|
||
for _, r := range table {
|
||
rp := strings.TrimRight(r.Path, "/")
|
||
if rp == "" {
|
||
continue
|
||
}
|
||
if c == rp || strings.HasPrefix(c, rp+"/") || strings.HasPrefix(rp, c+"/") {
|
||
return r, true
|
||
}
|
||
}
|
||
return RouteOwner{}, false
|
||
}
|
||
|
||
// handleCustomMount dispatches a request to a plugin that claimed a custom mount
|
||
// path (/plugins/<name>/… or /v1/plugins/<name>/…).
|
||
//
|
||
// It deliberately REUSES handlePluginHTTP rather than reimplementing dispatch:
|
||
// that function already does longest-match resolution, per-endpoint
|
||
// roles/keys/public authorization and the Lua call protocol. Duplicating it here
|
||
// would create two dispatch paths that must be kept in agreement — and a
|
||
// security rule that is enforced on only one of them is worse than none.
|
||
//
|
||
// The rewrite below maps a claimed mount onto the handler table's canonical
|
||
// form: handler paths are declared as "<name>/<rest>", so the request path is
|
||
// reduced to exactly that. Anything that does not reduce cleanly (a missing
|
||
// name, a name with no registered handler) is a 404, not a fallthrough — a
|
||
// custom namespace that silently answered from the default prefix would make
|
||
// the mount validation meaningless.
|
||
func (g *Gateway) handleCustomMount(w http.ResponseWriter, r *http.Request) {
|
||
owner, rest, ok := parseMountRef(r.URL.Path)
|
||
if !ok || owner == "" {
|
||
writeError(w, http.StatusNotFound, "not_found", "malformed plugin mount path")
|
||
return
|
||
}
|
||
ps := g.core.Plugins()
|
||
if ps == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
||
return
|
||
}
|
||
// Resolve which handler owns this mount, so an unclaimed namespace is a 404.
|
||
handlers := ps.HTTPHandlers()
|
||
candidate := owner
|
||
if rest != "" {
|
||
candidate = owner + "/" + rest
|
||
}
|
||
if !mountClaimed(handlers, candidate) {
|
||
writeError(w, http.StatusNotFound, "not_found",
|
||
"no plugin serves "+r.URL.Path)
|
||
return
|
||
}
|
||
// Reuse the default dispatcher by presenting the path in its own shape.
|
||
r2 := r.Clone(r.Context())
|
||
r2.URL.Path = pluginHTTPPrefix + candidate
|
||
g.handlePluginHTTP(w, r2)
|
||
}
|
||
|
||
// mountClaimed reports whether any registered handler's path is a prefix of the
|
||
// candidate (longest-match semantics: /plugins/auth/token is served by a
|
||
// handler declared as "auth").
|
||
func mountClaimed(handlers []lua.HTTPHandler, candidate string) bool {
|
||
for i := range handlers {
|
||
h := strings.Trim(handlers[i].Path, "/")
|
||
if h == "" {
|
||
continue
|
||
}
|
||
if candidate == h || strings.HasPrefix(candidate, h+"/") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// parseMountRef splits "/plugins/<name>/<rest>" or "/v1/plugins/<name>/<rest>"
|
||
// into its owner name and remaining path.
|
||
//
|
||
// The "/v1/plugins/" form is checked first because it has the longer prefix:
|
||
// testing "/plugins/" first would match nothing for a /v1/ path, but keeping the
|
||
// ordering explicit avoids a future prefix being added that nests inside
|
||
// another.
|
||
func parseMountRef(p string) (owner, rest string, ok bool) {
|
||
prefixes := []string{"/v1/plugins/", "/plugins/"}
|
||
for _, prefix := range prefixes {
|
||
if !strings.HasPrefix(p, prefix) {
|
||
continue
|
||
}
|
||
trimmed := strings.TrimPrefix(p, prefix)
|
||
if trimmed == "" {
|
||
return "", "", false
|
||
}
|
||
if i := strings.Index(trimmed, "/"); i >= 0 {
|
||
return trimmed[:i], trimmed[i+1:], true
|
||
}
|
||
return trimmed, "", true
|
||
}
|
||
return "", "", false
|
||
}
|