mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 15:07:51 +00:00
feat(plugin): Lua 插件机制 + 计费插件 + 插件文档
插件 = 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
This commit is contained in:
@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
@ -414,6 +415,7 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
Type: "chat",
|
||||
OK: false,
|
||||
}
|
||||
g.fireStart(ctx, &req, "chat", "AUTO", len(req.Messages), len(req.Tools))
|
||||
// quotaExhausted reports a slot whose token window has been used up;
|
||||
// exhausted slots are dropped from scheduling without penalty.
|
||||
quotaExhausted := func(sl *scheduler.Slot) bool {
|
||||
@ -817,6 +819,7 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
recordChatUsage(rec, req, resp)
|
||||
rec.Source = usedSrc
|
||||
rec.Model = usedModel
|
||||
g.fireRouted(ctx, "chat", usedSrc, usedModel, -1, false)
|
||||
// Non-streaming: the whole response arrives at once, so TTFB equals
|
||||
// the total latency.
|
||||
rec.FirstByteMs = rec.LatMs
|
||||
@ -824,7 +827,18 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
writeChatCompletion(w, resp, effective)
|
||||
}
|
||||
|
||||
// writeRec records a finished request (audit + aggregates).
|
||||
// writeRec records a finished request (audit + aggregates) and fires the
|
||||
// plugin request_end stage.
|
||||
//
|
||||
// This is the ONE place every request passes through on its way out, which is
|
||||
// what makes it the right hook point: the four entry points (single/stream ×
|
||||
// direct/auto) all funnel here, so a plugin sees each request exactly once with
|
||||
// its final accounting. Firing earlier would miss the streamed ones (their
|
||||
// numbers are only known once the stream finishes), and firing in each entry
|
||||
// point would mean four call sites to keep in sync.
|
||||
//
|
||||
// Hooks run AFTER the record is written: a plugin must not be able to delay or
|
||||
// lose the audit trail, and a plugin that throws is contained by Fire.
|
||||
func (g *Gateway) writeRec(rec *Req) {
|
||||
if rec == nil {
|
||||
return
|
||||
@ -833,6 +847,81 @@ func (g *Gateway) writeRec(rec *Req) {
|
||||
rec.Time = time.Now().UnixMilli()
|
||||
}
|
||||
g.stats.Record(*rec)
|
||||
g.fireEnd(rec)
|
||||
}
|
||||
|
||||
// fireStart dispatches the plugin request_start stage: the request has been
|
||||
// parsed and authorized but no upstream slot has been chosen yet, so `source`
|
||||
// is empty. A plugin that only wants volume/acceptance counts can subscribe
|
||||
// here and stay out of the per-request hot path entirely.
|
||||
func (g *Gateway) fireStart(ctx context.Context, req *chatRequest, kind, model string, msgs, tools int) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
ps.Fire(lua.StageRequestStart, map[string]interface{}{
|
||||
"stage": string(lua.StageRequestStart),
|
||||
"type": kind,
|
||||
"model": model,
|
||||
"key": keyID(reqKey(ctx)),
|
||||
"role": reqRole(ctx),
|
||||
"source": "",
|
||||
"stream": req.Stream,
|
||||
"messages_count": msgs,
|
||||
"tools_count": tools,
|
||||
"ts": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// fireRouted dispatches the plugin routed stage once a (source, model) slot has
|
||||
// been selected. tier is the AUTO tier index, or -1 on the direct path, so a
|
||||
// plugin can tell "this came from tier 1" from "this bypassed the chain".
|
||||
func (g *Gateway) fireRouted(ctx context.Context, kind, source, model string, tier int, stream bool) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
ps.Fire(lua.StageRouted, map[string]interface{}{
|
||||
"stage": string(lua.StageRouted),
|
||||
"type": kind,
|
||||
"source": source,
|
||||
"model": model,
|
||||
"key": keyID(reqKey(ctx)),
|
||||
"tier": tier,
|
||||
"stream": stream,
|
||||
"ts": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// fireEnd dispatches the plugin request_end stage for one finished request.
|
||||
func (g *Gateway) fireEnd(rec *Req) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"stage": string(lua.StageRequestEnd),
|
||||
"type": rec.Type,
|
||||
"model": rec.Model,
|
||||
"source": rec.Source,
|
||||
"key": rec.Key,
|
||||
"ok": rec.OK,
|
||||
"status": rec.Status,
|
||||
"latency_ms": rec.LatMs,
|
||||
"first_byte_ms": rec.FirstByteMs,
|
||||
"prompt_tokens": rec.Prompt,
|
||||
"completion_tokens": rec.Compl,
|
||||
"cache_hit_tokens": rec.CacheHit,
|
||||
"cache_miss_tokens": rec.CacheMiss,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
}
|
||||
// The merged result is intentionally discarded: request_end is the last
|
||||
// stage, so there is nobody downstream to read a plugin's additions. Plugins
|
||||
// that need to publish derived numbers (the billing plugin) do it in their
|
||||
// OWN state and expose them through the /api/plugins/<name>/state endpoint.
|
||||
ps.Fire(lua.StageRequestEnd, payload)
|
||||
}
|
||||
|
||||
// mergeUsage combines token usage across stream chunks additively. Some
|
||||
@ -1039,6 +1128,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
// failover it differs from the first candidate). Direct streams previously
|
||||
// discarded it.
|
||||
rec.Source = usedSrc
|
||||
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -1, true)
|
||||
rec.Prompt = estimatePromptTokens(req)
|
||||
g.pumpStream(w, rec, chunks, effective, t0)
|
||||
}
|
||||
@ -1064,6 +1154,10 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, cha
|
||||
recordChatUsage(rec, req, resp)
|
||||
rec.Source = usedSrc
|
||||
rec.Model = usedModel
|
||||
// AUTO has no single tier to report: the chain may have walked several
|
||||
// before this slot served the request, so -2 means "resolved by the chain"
|
||||
// and a plugin can tell that apart from the direct path's -1.
|
||||
g.fireRouted(ctx, "chat", usedSrc, usedModel, -2, false)
|
||||
rec.FirstByteMs = rec.LatMs
|
||||
g.writeRec(rec)
|
||||
writeChatCompletion(w, resp, usedModel)
|
||||
@ -1091,6 +1185,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
|
||||
rec.Model = usedModel
|
||||
}
|
||||
rec.Source = usedSrc
|
||||
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -2, true)
|
||||
rec.Prompt = estimatePromptTokens(req)
|
||||
g.pumpStream(w, rec, chunks, usedModel, t0)
|
||||
}
|
||||
|
||||
203
internal/gateway/plugins_api.go
Normal file
203
internal/gateway/plugins_api.go
Normal file
@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
187
internal/gateway/plugins_api_test.go
Normal file
187
internal/gateway/plugins_api_test.go
Normal file
@ -0,0 +1,187 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// gatewayWithBilling boots a gateway with the bundled billing plugin loaded, so
|
||||
// the UI-injection endpoint is exercised against a real plugin rather than a
|
||||
// hand-written stub. The other plugin tests in this package assert on the
|
||||
// WebUI source; this one asserts on the HTTP contract the browser consumes.
|
||||
func gatewayWithBilling(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
body := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
// Load the shipped plugin explicitly: seeding only runs for a directory that
|
||||
// does not exist yet, and this test wants a known plugin regardless.
|
||||
src, err := lua.ReadBundledPlugin("billing")
|
||||
if err != nil {
|
||||
t.Fatalf("read bundled billing: %v", err)
|
||||
}
|
||||
if err := c.Plugins().LoadSource("billing", src); err != nil {
|
||||
t.Fatalf("load billing: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestUIInjectServesPluginUI: GET /api/ui-inject is the single call the WebUI
|
||||
// makes at boot, and it must carry BOTH a contributed page and contributed
|
||||
// elements — the browser builds the sidebar from the page and mounts the
|
||||
// elements into existing panes from the same payload, so a partial response
|
||||
// would produce a page with no body or a missing tile.
|
||||
func TestUIInjectServesPluginUI(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var view struct {
|
||||
UI struct {
|
||||
Page *struct {
|
||||
PageID string `json:"page_id"`
|
||||
Title string `json:"title"`
|
||||
Mount string `json:"mount"`
|
||||
} `json:"page"`
|
||||
Elements []struct {
|
||||
Target string `json:"target"`
|
||||
Mount string `json:"mount"`
|
||||
} `json:"elements"`
|
||||
} `json:"ui"`
|
||||
Stages []string `json:"stages"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if view.UI.Page == nil || view.UI.Page.PageID != "billing" {
|
||||
t.Fatalf("no billing page in the inject payload")
|
||||
}
|
||||
if !strings.Contains(view.UI.Page.Mount, "billing-root") {
|
||||
t.Error("the page mount came back empty")
|
||||
}
|
||||
if len(view.UI.Elements) == 0 {
|
||||
t.Error("billing contributes an element to the status page but it is missing")
|
||||
}
|
||||
for _, e := range view.UI.Elements {
|
||||
if e.Target != "status" {
|
||||
t.Errorf("element target = %q, want \"status\"", e.Target)
|
||||
}
|
||||
}
|
||||
if len(view.Stages) != 3 {
|
||||
t.Errorf("stages = %v, want the three pipeline stages", view.Stages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIInjectIsEmptyWithoutPlugins: a gateway with no plugins must still answer
|
||||
// 200 with an empty (not missing, not null) payload. The WebUI calls this
|
||||
// unconditionally at boot, so a 404 or a null `ui` would break every dashboard.
|
||||
func TestUIInjectIsEmptyWithoutPlugins(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"ui"`) {
|
||||
t.Error("no ui key in the response; the WebUI would have nothing to read")
|
||||
}
|
||||
}
|
||||
|
||||
// newRecorderFor pushes a request through the full handler chain.
|
||||
func newRecorderFor(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// TestPluginsListAndStateAPI covers the management surface the plugin docs
|
||||
// promise: listing, and reading a plugin's own published state.
|
||||
func TestPluginsListAndStateAPI(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/plugins = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, want := range []string{"billing", "hook_errors", "plugin_dir", "request_end"} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Errorf("/api/plugins response lacks %q", want)
|
||||
}
|
||||
}
|
||||
// state read: the plugin published its (empty) state, so the key exists.
|
||||
rr = doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"total"`) {
|
||||
t.Errorf("billing state lacks the total bucket: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginStatePUTIsAdminOnly: only the WRITE side is gated. A user key must
|
||||
// be able to READ its own billing widget's data, but must not be able to
|
||||
// rewrite the price table.
|
||||
func TestPluginStatePUTIsAdminOnly(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
|
||||
// Build a user-role key and remember its secret.
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userKey := rec.Key
|
||||
|
||||
// A user key may read the state.
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/plugins/billing/state", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userKey)
|
||||
rr := newRecorderFor(t, g, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("user GET state = %d, want 200 (the billing widget must render for users)", rr.Code)
|
||||
}
|
||||
|
||||
// A user key may NOT write it.
|
||||
put, _ := http.NewRequest(http.MethodPut, "/api/plugins/billing/state",
|
||||
strings.NewReader(`{"prices":{"default":{"prompt":0}}}`))
|
||||
put.Header.Set("Authorization", "Bearer "+userKey)
|
||||
put.Header.Set("Content-Type", "application/json")
|
||||
prr := newRecorderFor(t, g, put)
|
||||
if prr.Code != http.StatusForbidden {
|
||||
t.Errorf("user PUT state = %d, want 403 (a user must not rewrite the price table)", prr.Code)
|
||||
}
|
||||
|
||||
// An admin key may.
|
||||
adm := doReq(t, g, http.MethodPut, "/api/plugins/billing/state",
|
||||
`{"prices":{"default":{"prompt":1e-6}}}`)
|
||||
if adm.Code != http.StatusOK {
|
||||
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
|
||||
}
|
||||
}
|
||||
@ -228,6 +228,10 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleSourcesAPI(w, r)
|
||||
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
||||
g.handleSourceTemplatesAPI(w, r)
|
||||
case r.URL.Path == "/api/ui-inject" || strings.HasPrefix(r.URL.Path, "/api/ui-inject/"):
|
||||
g.handlePluginUI(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":
|
||||
g.handleChat(w, r)
|
||||
case r.URL.Path == "/api/status":
|
||||
|
||||
@ -4815,8 +4815,150 @@
|
||||
if (tab === "keys") return renderKeys();
|
||||
if (tab === "sort") return renderSort();
|
||||
if (tab === "sources") return renderSources();
|
||||
return renderAdapters();
|
||||
if (tab === "adapters") return renderAdapters();
|
||||
// A page contributed by a plugin has no renderer here: its <script>
|
||||
// already ran at injection time and owns its own DOM. We only fire the
|
||||
// "shown" callbacks so it can refresh when the user lands on it.
|
||||
if (PLUGIN_PAGES.has(tab)) return notifyPluginTab(tab);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---- plugin injection -------------------------------------------
|
||||
// Pages and elements contributed by Lua plugins (see docs/plugins.md).
|
||||
//
|
||||
// The server merges every plugin's extension into one payload at
|
||||
// GET /api/ui-inject, because the sidebar needs all of them before it can
|
||||
// be built. Injection happens once at boot, BEFORE the first goTab, so a
|
||||
// plugin page is a real tab rather than a special case in the router.
|
||||
const PLUGIN_PAGES = new Set();
|
||||
const PLUGIN_TAB_CBS = {};
|
||||
const PLUGIN_ELEMENTS = [];
|
||||
|
||||
// pluginAPI is the small surface a plugin's script may rely on. Kept
|
||||
// deliberately tiny: plugins are untrusted, and every convenience here is
|
||||
// one more thing to keep working across kernel changes.
|
||||
window.pluginAPI = {
|
||||
async fetchState(name) {
|
||||
const r = await fetch("/api/plugins/" + encodeURIComponent(name) + "/state", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) throw new Error("state " + r.status);
|
||||
return (await r.json()).state;
|
||||
},
|
||||
async postState(name, obj) {
|
||||
const r = await fetch("/api/plugins/" + encodeURIComponent(name) + "/state", {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(obj),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error?.message || r.status);
|
||||
return true;
|
||||
},
|
||||
onTabShown(fn) {
|
||||
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
|
||||
PLUGIN_TAB_CBS.__last.push(fn);
|
||||
},
|
||||
};
|
||||
|
||||
function notifyPluginTab(tab) {
|
||||
const fns = PLUGIN_TAB_CBS[tab] || PLUGIN_TAB_CBS.__last || [];
|
||||
fns.forEach((f) => {
|
||||
try {
|
||||
f();
|
||||
} catch (e) {
|
||||
console.warn("plugin tab callback failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// injectPluginUI adds the sidebar button + pane for a plugin page and
|
||||
// mounts plugin elements into existing panes.
|
||||
async function injectPluginUI() {
|
||||
let payload;
|
||||
try {
|
||||
const r = await fetch("/api/ui-inject", { credentials: "same-origin" });
|
||||
if (!r.ok) return;
|
||||
payload = await r.json();
|
||||
} catch (e) {
|
||||
return; // plugins are optional; the UI must work without them
|
||||
}
|
||||
const ui = (payload && payload.ui) || {};
|
||||
const main = $("#main");
|
||||
const nav = $("#sb-nav");
|
||||
if (!main || !nav) return;
|
||||
|
||||
// --- page ---
|
||||
if (ui.page && ui.page.page_id && ui.page.mount) {
|
||||
const id = String(ui.page.page_id);
|
||||
if (!document.getElementById("tab-" + id)) {
|
||||
const pane = document.createElement("div");
|
||||
pane.id = "tab-" + id;
|
||||
pane.className = "tab-pane hidden";
|
||||
main.appendChild(pane);
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "sb-i";
|
||||
btn.dataset.tab = id;
|
||||
btn.title = ui.page.title || id;
|
||||
btn.innerHTML =
|
||||
'<span style="font-size:18px;line-height:1">' +
|
||||
esc(ui.page.icon || "•") +
|
||||
"</span>";
|
||||
btn.onclick = () => goTab(id);
|
||||
nav.appendChild(btn);
|
||||
PLUGIN_PAGES.add(id);
|
||||
// The breadcrumb map is local to this file, so extend it here.
|
||||
if (typeof NAV_NAME === "object") NAV_NAME[id] = ui.page.title || id;
|
||||
}
|
||||
const pane = document.getElementById("tab-" + id);
|
||||
if (pane && !pane.dataset.pluginMounted) {
|
||||
pane.dataset.pluginMounted = "1";
|
||||
// Split the mount so <script>/<style> run only AFTER the markup is
|
||||
// in the document. Setting innerHTML with a <script> tag does not
|
||||
// execute it, which is exactly what we want to avoid the opposite
|
||||
// problem: running before its own DOM exists.
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = ui.page.mount;
|
||||
pane.appendChild(tpl.content);
|
||||
// Move each script into a fresh element so it executes.
|
||||
pane.querySelectorAll("script").forEach((old) => {
|
||||
const s = document.createElement("script");
|
||||
Array.from(old.attributes).forEach((a) => s.setAttribute(a.name, a.value));
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- elements into existing pages ---
|
||||
(ui.elements || []).forEach((el, i) => {
|
||||
const target = document.getElementById("tab-" + el.target);
|
||||
if (!target || !el.mount) return;
|
||||
PLUGIN_ELEMENTS.push(el);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "plugin-el";
|
||||
wrap.dataset.target = el.target;
|
||||
wrap.dataset.idx = String(i);
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = el.mount;
|
||||
wrap.appendChild(tpl.content);
|
||||
const anchor = String(el.anchor || "bottom");
|
||||
if (anchor === "top") target.prepend(wrap);
|
||||
else if (anchor.startsWith("before:") || anchor.startsWith("after:")) {
|
||||
const [kind, sel] = anchor.split(/:(.+)/);
|
||||
const ref = target.querySelector(sel);
|
||||
if (ref) ref.parentNode.insertBefore(wrap, kind === "before" ? ref : ref.nextSibling);
|
||||
else target.appendChild(wrap);
|
||||
} else target.appendChild(wrap);
|
||||
wrap.querySelectorAll("script").forEach((old) => {
|
||||
const s = document.createElement("script");
|
||||
Array.from(old.attributes).forEach((a) => s.setAttribute(a.name, a.value));
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api("/api/keys/me");
|
||||
@ -4836,7 +4978,15 @@
|
||||
window.addEventListener("pagehide", () => releaseRecords(false));
|
||||
window.addEventListener("beforeunload", () => releaseRecords(false));
|
||||
|
||||
refresh("status");
|
||||
// Plugin injection runs BEFORE the first render: a plugin page must exist
|
||||
// in #main and the sidebar before goTab runs, otherwise the sidebar shows
|
||||
// no entry and the pane is missing for a moment. Awaited (not fired and
|
||||
// forgotten) so a slow /api/ui-inject cannot race the first paint.
|
||||
injectPluginUI()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
refresh("status");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
158
internal/gateway/ui_plugin_test.go
Normal file
158
internal/gateway/ui_plugin_test.go
Normal file
@ -0,0 +1,158 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The plugin UI injection is JavaScript inside the embedded index.html, and it
|
||||
// is the ONLY thing that turns a plugin's `ui` block into a visible page or
|
||||
// element. These tests pin the wiring on the JS side; the server side (what the
|
||||
// payload contains) is covered by TestUIInjectServesPluginUI and the lua
|
||||
// package's TestBillingPluginDeclaresUI.
|
||||
//
|
||||
// What makes this worth pinning: a missing hook here fails SILENTLY. The page
|
||||
// simply never appears, there is no error anywhere, and it looks like "the
|
||||
// plugin didn't declare a page" rather than "the UI forgot to inject it".
|
||||
|
||||
// uiSource returns the embedded WebUI document.
|
||||
func uiSourceX(t *testing.T) string {
|
||||
t.Helper()
|
||||
return uiSource(t)
|
||||
}
|
||||
|
||||
// TestUIFetchesPluginInjection: the boot sequence must ask the kernel what to
|
||||
// inject. Without this fetch the whole feature is inert.
|
||||
func TestUIFetchesPluginInjection(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
if !strings.Contains(src, "/api/ui-inject") {
|
||||
t.Error("the WebUI never calls /api/ui-inject; plugin pages and elements can never appear")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIInjectsBeforeFirstRender: injection must be awaited before the first
|
||||
// refresh, otherwise the sidebar is built without the plugin entry and the
|
||||
// first paint races the fetch. This is an ordering contract, so it is asserted
|
||||
// on the source order rather than trusted.
|
||||
func TestUIInjectsBeforeFirstRender(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
iInject := strings.Index(src, "injectPluginUI()")
|
||||
iRefresh := strings.LastIndex(src, `refresh("status")`)
|
||||
if iInject < 0 {
|
||||
t.Fatal("injectPluginUI() is never called")
|
||||
}
|
||||
if iRefresh < 0 {
|
||||
t.Fatal("the boot sequence no longer calls refresh(\"status\")")
|
||||
}
|
||||
if iInject > iRefresh {
|
||||
t.Error("injectPluginUI() is called after the first refresh; the sidebar " +
|
||||
"and #main would be built before the plugin page exists")
|
||||
}
|
||||
// And it must be awaited, not fire-and-forget.
|
||||
window := src[iInject:]
|
||||
if !strings.Contains(window[:200], ".finally") && !strings.Contains(window[:200], "await") {
|
||||
t.Error("injectPluginUI() is not awaited before refresh; a slow response " +
|
||||
"would race the first paint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginScriptsRunAfterMarkup is the subtle one. Setting innerHTML with a
|
||||
// <script> tag does NOT execute it; appending via a template neither does. The
|
||||
// mount therefore has to be inserted first and its scripts re-created
|
||||
// afterwards, or a plugin's script runs before its own DOM exists — which is
|
||||
// exactly the "document.getElementById returns null" failure mode.
|
||||
func TestUIPluginScriptsRunAfterMarkup(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
// A <template> is used to parse the mount without executing scripts...
|
||||
if !strings.Contains(src, "createElement(\"template\")") {
|
||||
t.Error("the mount is not parsed via <template>; scripts could execute before their DOM")
|
||||
}
|
||||
// ...and scripts are then re-created as fresh elements so they DO run.
|
||||
if !strings.Contains(src, "document.createElement(\"script\")") {
|
||||
t.Error("plugin <script> blocks are never re-created, so they never execute")
|
||||
}
|
||||
if !strings.Contains(src, "replaceWith(s)") {
|
||||
t.Error("the original inert <script> is not replaced by an executable one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginAPISurface: the documented browser API must exist with the exact
|
||||
// names docs/plugins.md promises, since plugin authors code against it.
|
||||
func TestUIPluginAPISurface(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
for _, member := range []string{"fetchState", "postState", "onTabShown"} {
|
||||
if !strings.Contains(src, member+":") && !strings.Contains(src, member+"(") {
|
||||
t.Errorf("window.pluginAPI.%s is missing; docs/plugins.md documents it", member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginPageBecomesRealTab: a plugin page must get a pane in #main AND a
|
||||
// sidebar button wired to goTab, otherwise the page is unreachable.
|
||||
func TestUIPluginPageBecomesRealTab(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
// pane in #main
|
||||
if !strings.Contains(src, `pane.id = "tab-" + id`) {
|
||||
t.Error("no pane is created for a plugin page")
|
||||
}
|
||||
if !strings.Contains(src, "main.appendChild(pane)") {
|
||||
t.Error("the plugin pane is not appended to #main")
|
||||
}
|
||||
// sidebar button wired to the tab router
|
||||
if !strings.Contains(src, "btn.dataset.tab = id") {
|
||||
t.Error("the sidebar button is not given a data-tab, so goTab() will not route to it")
|
||||
}
|
||||
if !strings.Contains(src, "btn.onclick = () => goTab(id)") {
|
||||
t.Error("the sidebar button is not wired to goTab()")
|
||||
}
|
||||
// and the router must know about it
|
||||
if !strings.Contains(src, "PLUGIN_PAGES.has(tab)") {
|
||||
t.Error("refresh() does not route plugin pages, so opening one renders nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginElementsHonorAnchor: elements declare top / bottom / before:sel /
|
||||
// after:sel. Silently ignoring the anchor would put a "top" tile at the bottom
|
||||
// of the status page, which looks like a layout bug rather than a plugin bug.
|
||||
func TestUIPluginElementsHonorAnchor(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
for _, anchor := range []string{`anchor === "top"`, `anchor.startsWith("before:")`, `"after:"`} {
|
||||
if !strings.Contains(src, anchor) {
|
||||
t.Errorf("the anchor form %s is not handled; elements would all land at the bottom", anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginInjectionFailureIsNonFatal: plugins are optional, so a failed
|
||||
// /api/ui-inject must still leave a working UI (the dashboard has to render).
|
||||
// Two places have to cooperate: the function swallows the fetch error, and the
|
||||
// caller catches anything that still escapes so refresh() always runs.
|
||||
func TestUIPluginInjectionFailureIsNonFatal(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
// inside the function: the fetch is wrapped in try/catch
|
||||
fnStart := strings.Index(src, "async function injectPluginUI()")
|
||||
if fnStart < 0 {
|
||||
t.Fatal("injectPluginUI() is not defined")
|
||||
}
|
||||
fn := src[fnStart:]
|
||||
if !strings.Contains(fn, "plugins are optional; the UI must work without them") {
|
||||
t.Error("injectPluginUI does not guard its own fetch failure")
|
||||
}
|
||||
// at the call site: the rejection cannot escape before the first render
|
||||
// LastIndex, not Index: the DEFINITION of injectPluginUI also matches, and
|
||||
// the definition has no .catch on it.
|
||||
iCall := strings.LastIndex(src, "injectPluginUI()")
|
||||
if iCall < 0 {
|
||||
t.Fatal("injectPluginUI() is never called")
|
||||
}
|
||||
// Bound the window at len(src): the call site sits near EOF and a fixed
|
||||
// slice overruns it (a panic in a test is worse than a skipped assertion).
|
||||
end := iCall + 220
|
||||
if end > len(src) {
|
||||
end = len(src)
|
||||
}
|
||||
if !strings.Contains(src[iCall:end], ".catch") {
|
||||
t.Error("a failed /api/ui-inject would reject before refresh(\"status\"), " +
|
||||
"leaving the dashboard blank")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user