mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
插件 = plugin_dir 下的单个 .lua 文件,做两件事:挂请求流水线的钩子、在启动时
贡献 WebUI 界面(整页或往现有页面追加组件)。两者独立。
## 流水线 stage(三个)
request_start 已解析鉴权、未选源
routed 已选定 (source, model)、未发往上游
request_end 每请求恰好一次,带最终计量
request_end 挂在 gateway.writeRec——四条入口路径(直连/AUTO × 流式/非流式)的
唯一汇合点:既不漏(流式 token 只有流结束才知道)也不重。
## 计费插件(plugins/billing.lua,默认 seed,开箱可用)
源 / 模型 / 密钥三个维度定价。token 价优先级 keys > models > default;per_request
固定价是**叠加**的(生图模型可以既算 token 又收固定费)。单位是 USD/单 token,
即各家 provider 的公布口径。累计 total / by_source / by_model / by_key / by_day。
失败请求保留 token 费用、丢弃固定费(可经 count_failures 翻转)。
界面 = 一个独立页 + 状态页顶部一块总开销 tile。
## 一个明确的设计边界
计费插件**只报表,不执法**。网关自己的配额会计(stats.go,入口强制)才是限额
权威,插件不参与任何路由/配额决策。两套独立会计若对不上,比一套功能略少的
更糟。
## ★ 中途改掉的一个根本设计错误
最初让插件复用适配器的**弹性 worker 池**(多状态)。这对适配器是对的(它们无
状态),对插件是错的:计费插件往 plugin.state 累加,多状态意味着总量被劈成
几份;而 SetState 写价格只写进其中一个 worker,钩子恰好跑到另一个时**所有请求
按 0 计费**。改为**单状态 + 互斥锁**。代价写进文档:钩子必须短、同步、不阻塞,
卡住的钩子会卡住所有插件的钩子。
这个 bug 是测试逼出来的——先写了 SetState+Fire 的用例,数字全是 0 才挖出来。
另一个连带缺陷:只带 prices 的 PUT 会整体替换 state,把累计量清零。改为
prices/state 分离——prices 是配置、state 是历史,改价不动账。
## 撞到的三个 Lua 绑定的坑(都写进注释)
- SetGlobal **会 pop 栈**:连着调两次,第二次从空栈取,赋成 nil
- GetField 索引越界是 **SIGABRT 整个进程**,不是 panic,recover 救不了
- Call(nargs, n) **不接受函数索引**,它调的是 nargs 个参数正下方那个;
传索引会调到参数上("attempt to call a table value")
另外 GetField/SetField 用绝对索引,SetTop(0) 之后必须重取。
## 错误隔离
钩子 error() 不影响转发:捕获 → 记进 hook_errors → 跳下一个插件。适配器出错
会让源进冷却,插件出错**零惩罚**——插件是可选功能。/api/plugins 的 hook_errors
让"坏掉的插件"可见而不是静默消失。
## 界面注入
GET /api/ui-inject 一次返回所有插件的扩展(侧栏需要全部 page 才能建好)。
WebUI 在首次 render **之前** await 注入:先插 HTML 再重建 <script> 让它执行
(innerHTML/template 插入的 script 不会执行,这正是要的效果——避免脚本跑在
自己 DOM 之前)。注入失败不影响仪表盘。
browser 侧 pluginAPI 暴露 fetchState / postState / onTabShown。
## 文档
docs/plugins.md —— 快速上手、加载与热更新、三个 stage 的完整字段表、界面扩展、
状态与 HTTP API、运行时约束(单状态/异常隔离/内置函数)、计费插件的定价与
计费策略、排错表、与适配器的对比表。
## 判据(328 个测试全绿,插件相关 33 个)
- 计费断言的是**具体金额**(0.00625 / 0.0402 / 0.0075…),不是"能加载"
- 4 个变异都红:钩子异常不隔离 / prices 清空累计 / 忽略 key 优先级 /
毫秒时间戳不换算
- UI 侧 6 个判据把注入顺序、script 执行时机、pluginAPI 名称、tab 路由、
anchor 四种形式、失败非致命全钉住
- 鉴权:state 读任意角色、写仅 admin
204 lines
6.6 KiB
Go
204 lines
6.6 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
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ui": ps.UI(),
|
|
"stages": []string{
|
|
string(lua.StageRequestStart),
|
|
string(lua.StageRouted),
|
|
string(lua.StageRequestEnd),
|
|
},
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|