mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
部署到线上时用隔离实例(独立端口 18099 + 独立 config/runtime/adapter 目录) 发真实请求验证,抓到的第三个 bug。 ## bug:discovery 文档漏掉新 stage handlePluginUI 的响应里 stages 是**字面写死的三个**。加 chain_step 时只改了 lua.AllStages,没改这里,于是插件作者读 GET /api/ui-inject 会看到 ["request_start","routed","request_end"],**合理地得出结论:没有 chain_step 这个 stage**。stage 本身是注册好的、也确实在触发,只是没被声明。 改成从 lua.AllStages 派生——AllStages 是唯一定义顺序的地方,让它保持唯一。 ★ 而我原来的测试断言 `len(view.Stages) != 3`,**断言本身是 bug 的保护伞**: 它把"三个"固化成了期望值,于是新增第四个 stage 时测试全绿、bug 上线。 现在断言改为「与 AllStages 等长且逐项相同」,并显式要求 chain_step 在其中。 新增 stage 而忘了声明,这类问题会立刻红。 ## 顺带:.gitignore 补上临时构建目录 .probe/ 和 .build-work/ 是我调试时当 GOTMPDIR 和临时二进制用的,之前每轮 手工删,这轮差点提交进去 9.5MB 的二进制。 ## 部署验证留档 隔离实例跑真实 chat(158 prompt / 13 completion / 128 cache_hit),计费插件 算出 0.000688,与手算 (158-128)*1e-5 + 128*1e-5*0.1 + 13*2e-5 **逐位吻合**。 ★ 第一次手算我按全价算成 0.00184,一度以为插件算错了——查审计记录才看到 cache_hit_tokens。**算钱不对时先查输入再怀疑实现**,而我忘的恰是刚修的折扣。 ## 验证 351 个测试全绿;变异(stages 改回硬编码三个)被 TestUIInjectServesPluginUI 抓住,3 条断言同时红。
215 lines
7.1 KiB
Go
215 lines
7.1 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"llmsproxy/internal/lua"
|
|
)
|
|
|
|
var (
|
|
errPluginName = errors.New("plugin name must be non-empty and contain no path separator or dot")
|
|
errNoPluginDir = errors.New("no plugin_dir configured; set plugin_dir in config.yaml to enable plugins")
|
|
)
|
|
|
|
// Plugin management API.
|
|
//
|
|
// GET /api/plugins list loaded plugins + their hooks/UI/errors
|
|
// POST /api/plugins upload/replace one plugin (.lua), hot-applied
|
|
// DELETE /api/plugins/{name} remove a plugin
|
|
// GET /api/plugins/{name}/state the plugin's own published state
|
|
// PUT /api/plugins/{name}/state replace that state (admin only)
|
|
//
|
|
// Why state is a first-class endpoint: request_end is the LAST stage, so a
|
|
// hook's return value has no downstream consumer inside the gateway. A plugin
|
|
// that accumulates numbers (the billing plugin does exactly this) therefore
|
|
// keeps them in its own Lua state and serves them here, which is what its UI
|
|
// component fetches. This keeps plugin data clearly separated from the
|
|
// gateway's own stats — see the accounting note in docs/plugins.md: the
|
|
// gateway's quota accounting stays authoritative, a plugin only reports.
|
|
|
|
// handlePluginUI serves the merged UI extensions the WebUI injects at boot.
|
|
//
|
|
// It is a single GET (not per-plugin) because the browser needs ALL extensions
|
|
// before it can build the sidebar: a page contributed by one plugin and an
|
|
// element contributed by another land in the same payload, and fetching them
|
|
// separately would mean the sidebar has to be rebuilt as each arrives.
|
|
//
|
|
// Served to any authenticated role: the WebUI is authenticated before it asks,
|
|
// and a plugin's own widgets need to render for the user whose key they show.
|
|
func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
ps := g.core.Plugins()
|
|
if ps == nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}})
|
|
return
|
|
}
|
|
// AllStages, not a hand-written list: an earlier version enumerated the
|
|
// three stages literally here, so when chain_step was added it was silently
|
|
// missing from this response — a plugin author reading the discovery
|
|
// document would have believed the stage did not exist. Deriving it from
|
|
// the one place that defines the order is the whole point of having it.
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ui": ps.UI(),
|
|
"stages": pipelineStageNames(),
|
|
})
|
|
}
|
|
|
|
// stageNames returns the pipeline stage names in firing order, for the
|
|
// discovery payload and for tests.
|
|
func pipelineStageNames() []string {
|
|
out := make([]string, 0, len(lua.AllStages))
|
|
for _, s := range lua.AllStages {
|
|
out = append(out, string(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
|
ps := g.core.Plugins()
|
|
if ps == nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": []interface{}{}})
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/plugins")
|
|
path = strings.Trim(path, "/")
|
|
|
|
// /api/plugins/{name}/state
|
|
if strings.HasSuffix(path, "/state") {
|
|
name := strings.TrimSuffix(path, "/state")
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
|
|
return
|
|
}
|
|
g.handlePluginState(w, r, name)
|
|
return
|
|
}
|
|
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
|
|
if path == "" {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"plugins": ps.List(),
|
|
"hook_errors": ps.HookErrors(),
|
|
"plugin_dir": g.pluginDir(),
|
|
})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if err := g.installPlugin(body.Name, body.Code); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodDelete:
|
|
if err := g.removePlugin(path); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
case http.MethodGet:
|
|
// A GET on a specific plugin is almost always a client that meant to
|
|
// delete it but let fetch default to GET; name the verb.
|
|
writeError(w, http.StatusNotFound, "not_found",
|
|
"plugin source not exposed; use DELETE /api/plugins/"+path+" to remove it, "+
|
|
"or GET /api/plugins/"+path+"/state for its published state")
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// handlePluginState serves GET (read state) and PUT (replace state).
|
|
func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name string) {
|
|
ps := g.core.Plugins()
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// Any role may read: plugin state is reporting data (cost, counts),
|
|
// and the caller has already been authenticated. Admin-only would stop
|
|
// a user key's own billing widget from rendering.
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"plugin": name,
|
|
"state": ps.State(name),
|
|
})
|
|
case http.MethodPut:
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
var state interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if err := ps.SetState(name, state); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) pluginDir() string {
|
|
if c := g.core.Config(); c != nil {
|
|
return c.PluginDir
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// installPlugin writes a plugin to disk and hot-loads it. A syntax error is
|
|
// returned to the caller AND the file is left on disk so the operator can fix
|
|
// it, matching how adapters behave (the file is authoritative once present).
|
|
func (g *Gateway) installPlugin(name, code string) error {
|
|
if name == "" {
|
|
return errPluginName
|
|
}
|
|
if strings.ContainsAny(name, `/\.`) {
|
|
return errPluginName
|
|
}
|
|
dir := g.pluginDir()
|
|
if dir == "" {
|
|
return errNoPluginDir
|
|
}
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(code), 0644); err != nil {
|
|
return err
|
|
}
|
|
return g.core.Plugins().LoadSource(name, code)
|
|
}
|
|
|
|
func (g *Gateway) removePlugin(name string) error {
|
|
if g.pluginDir() == "" {
|
|
return errNoPluginDir
|
|
}
|
|
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
|
|
return g.core.Plugins().Unload(name)
|
|
}
|