From 24e00cdc507d884fd5fe2b6b3e32b4bb75284f66 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 11 Oct 2026 10:47:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E6=8F=92=E4=BB=B6=E5=8F=AF?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AF=B9=E5=A4=96?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E7=82=B9=EF=BC=88R3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户需求:插件与适配器可以注册对外服务的端口。 现状是插件端点被固定在 /api/plugins/http/ 下。那条限制有三个理由 (可 grep / 可整体撤销 / 不能影子内核路由),放开时必须逐条补回来: - 命名空间约束:只接受 /plugins/ 与 /v1/plugins/ - 内核路由冲突检测:ValidateCustomMount + FindMountConflict(双向) - 鉴权默认关闭:沿用 HTTPHandler 的 roles/keys/public 语义 新增 internal/gateway/mount.go: - ValidateCustomMount:命名空间 + 冲突 + 逃逸校验,错误点名冲突对象 - FindMountConflict:**双向**检测(只查一边会让 /plugins/x 与 /plugins/x/sub 互相静默吞掉) - BuildRouteTable / RouteOwner:冲突提示点名持有者("plugin:other") - handleCustomMount:**复用** handlePluginHTTP 而不是重写一遍分发 —— 重写会造出两条必须保持一致的分发路径,而只在一处生效的安全规则 比没有更糟 关键防线:TestKernelRoutesAreAllCoveredByReservedPrefixes 从 server.go 刮出路由 switch 的字面量,逐个核对是否被 kernelReservedPrefixes 覆盖 —— 将来新增内核路由却忘记更新保留表时,这条测试会失败。否则新路由会静默 变成可被插件覆盖的。 测试 9 个:命名空间/影子内核路由/保留表同步/双向冲突/冲突点名/挂载解析/ 路由表构成/端到端(自定义挂载 200 + /v1/plugins 形式 + 未注册 404 + 无 key 401 或 302)/内核路由不可拦截。 --- internal/gateway/mount.go | 231 +++++++++++++++++++++++ internal/gateway/mount_test.go | 324 +++++++++++++++++++++++++++++++++ internal/gateway/server.go | 6 + 3 files changed, 561 insertions(+) create mode 100644 internal/gateway/mount.go create mode 100644 internal/gateway/mount_test.go diff --git a/internal/gateway/mount.go b/internal/gateway/mount.go new file mode 100644 index 0000000..d7d9635 --- /dev/null +++ b/internal/gateway/mount.go @@ -0,0 +1,231 @@ +// Package gateway — 对外挂载点(R3 / Phase 6) +// +// 用户需求:插件与适配器可以注册**对外服务的端口**。 +// +// 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。 +// 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由), +// 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰 +// 需要这个(例如给上游回调用的固定路径 /v1/plugins//callback)。 +// +// 本文件放开这一点,同时把当初那条限制的**三个理由**逐条补回来: +// +// 1. 可 grep / 可整体撤销 → 每条自定义挂载都必须落在 /plugins/ 或 +// /v1/plugins/ 命名空间内(不接受任意前缀) +// 2. 不能影子内核路由 → 与内核保留路由表做前缀冲突检测,撞了直接拒绝 +// 3. 鉴权默认关闭 → 沿用 HTTPHandler 的 roles/keys/public 语义 +package gateway + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "llmsproxy/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//… or /v1/plugins//…). +// +// 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 "/", 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//" or "/v1/plugins//" +// 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 +} diff --git a/internal/gateway/mount_test.go b/internal/gateway/mount_test.go new file mode 100644 index 0000000..dcded1b --- /dev/null +++ b/internal/gateway/mount_test.go @@ -0,0 +1,324 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/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) + } + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 32bc1d4..0dcc5f6 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -252,6 +252,12 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { // Before the generic plugin router: these paths belong to a PLUGIN, not to // the plugin manager, and "http" is a valid sub-resource name otherwise. g.handlePluginHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/plugins/") || strings.HasPrefix(r.URL.Path, "/v1/plugins/"): + // Custom mounts a plugin or adapter claimed (R3). Checked AFTER every + // kernel route so a claim can never shadow one — ValidateCustomMount + // refuses the overlap at load time, and this ordering is the second line + // of defence for a table that somehow slipped through. + g.handleCustomMount(w, r) case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"): g.handlePluginsAPI(w, r) case r.URL.Path == "/api/chat":