mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
规则此前只能写在 config.yaml 里,重启才生效。现在 admin 可通过 API 增删改, 规则写回同一份 config.yaml、重新编译、注入 billing 插件,立即生效。 ## 为什么单独开一套端点 GET/POST/PUT/DELETE /api/plugins/billing/rules 价格虽然存在插件 state 里,但它是**可评审的配置**,不是用户数据。走通用 /state 会让任意 admin key 顺手把累计账目一起重置。这里服务端掌管形状: 校验 → 落盘 → 重编译 → 注入,运维只编辑规则,插件只存数字,两者永不在同一 个 payload 里混。 "运维输入什么,config.yaml 就存什么"是刻意的:下周发现的计费错误,必须能 追溯到一个可评审的文件,而不是插件 sidecar 里的一坨 blob。 ## 路由必须前置拦截 /api/plugins/billing/rules 会被 /api/plugins/ 通配路由吞掉并 404,必须在 handlePluginsAPI 之前判断。 ## 两个真实缺陷(判据抓到的,不是想出来的) 1. **保存顺序反了**:先 cfg.Save() 再赋值 cfg.BillingDSL,于是每次编辑都 "成功",写回的配置里却没有 billing 段——运维的编辑在重启后消失,而 API 响应里什么异常都没有。现在先赋值、先编译(编译失败则整体回滚,不留半应用 状态)、最后落盘。 2. **GET /state 不返回生效价格**:编辑器无法显示当前真正在用的价目表,只能从 配置重建——而配置可能早已与实际漂移。新增 Plugins.Prices(),编辑页显示的 就是计费真正在用的那张表。 ## 宽松编译 Compile 启动时对"URL 匹配不到任何 source"直接报错是对的(静默不计费更糟)。 但编辑器要允许存草稿:正在新建的源、正在改的 URL 不该把人堵死。新增 CompileOpts(lenient):宽松模式下该规则**留在配置里**(可评审、可恢复), 只是不进编译结果,并由 API 返回 warning 明确报出来。 ## 判据(5 条 + 9 个变异) CRUD、同 URL 重复添加必须替换而非追加(两条规则会因"先匹配先生效"而让第一条 静默失效)、未知 URL 必须警告、非法规则被拒且不落盘、admin 限制、YAML 往返 不丢价格字符串、注入的价格必须是每 token 量级(防 per-million/per-token 差 1e6 倍)。 变异验证抓出判据两处无效断言:删掉落盘、删掉注入,GET 响应都照样回显内存里 的规则,判据全绿。补了「重读磁盘配置」和「读插件实际生效价格」两条才抓住。 过程中还发现一个测试工具自身的坑:某个变异改法导致 Go 编译失败 (declared and not used),grep 匹配不到 "--- FAIL",于是被我误读成"判据漏放"。 改用可编译的变异写法后确认该变异确实被捕获。**判据报错先怀疑判据和工具。**
285 lines
9.5 KiB
Go
285 lines
9.5 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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(),
|
|
"on_disk": ps.OnDisk(),
|
|
"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
|
|
}
|
|
|
|
// /api/plugins/{name} — source read (GET), enable/disable (PUT), delete.
|
|
// Multi-segment paths (…/state) are handled earlier and never reach here.
|
|
if !strings.Contains(path, "/") {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// Reading the SOURCE (not the runtime state) is what an editor
|
|
// needs; the state endpoint is /state and returns accumulated data
|
|
// instead. Mixing the two would make a "save what I read"
|
|
// round-trip impossible.
|
|
code, err := g.readPluginSource(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"name": path, "code": code,
|
|
"enabled": g.core.Plugins().Enabled(path),
|
|
})
|
|
case http.MethodPut:
|
|
// Enable / disable. The intent rides in the body rather than the
|
|
// verb, because "enable" and "disable" are the same resource and
|
|
// PUT /{name} is the one the docs advertise.
|
|
var body struct {
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if body.Enabled == nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", `body must be {"enabled":true|false}`)
|
|
return
|
|
}
|
|
if err := g.core.Plugins().SetEnabled(path, *body.Enabled); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true, "name": path, "enabled": *body.Enabled,
|
|
})
|
|
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})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeError(w, http.StatusNotFound, "not_found", "unknown plugin sub-resource: "+path)
|
|
}
|
|
|
|
// readPluginSource returns a plugin's file contents for the editor.
|
|
func (g *Gateway) readPluginSource(name string) (string, error) {
|
|
if err := validPluginName(name); err != nil {
|
|
return "", err
|
|
}
|
|
if g.pluginDir() == "" {
|
|
return "", errNoPluginDir
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(g.pluginDir(), name+".lua"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("no plugin source named %q", name)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// validPluginName rejects anything that could escape the plugin directory.
|
|
// Shared by install / remove / read so the check cannot drift between them.
|
|
func validPluginName(name string) error {
|
|
if name == "" || strings.ContainsAny(name, `/\.`) {
|
|
return errPluginName
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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.
|
|
// prices rides along: the billing rules editor must show the table
|
|
// that is actually in effect, not a reconstruction from config. It is
|
|
// absent for plugins with no prices (every plugin but billing).
|
|
payload := map[string]interface{}{
|
|
"plugin": name,
|
|
"state": ps.State(name),
|
|
}
|
|
if pr := ps.Prices(name); pr != nil {
|
|
payload["prices"] = pr
|
|
}
|
|
writeJSON(w, http.StatusOK, payload)
|
|
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 err := validPluginName(name); err != nil {
|
|
return err
|
|
}
|
|
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 err := validPluginName(name); err != nil {
|
|
return err
|
|
}
|
|
if g.pluginDir() == "" {
|
|
return errNoPluginDir
|
|
}
|
|
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
|
|
return g.core.Plugins().Unload(name)
|
|
}
|