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
809 lines
24 KiB
Go
809 lines
24 KiB
Go
package lua
|
|
|
|
// Plugin runtime.
|
|
//
|
|
// A plugin is a single .lua file, loaded from its own directory, that extends
|
|
// the gateway in two ways:
|
|
//
|
|
// 1. Hooks: it registers callbacks on the request pipeline's stages
|
|
// (request_start, response_end, …). A hook receives a JSON table and
|
|
// returns either nil (no opinion) or a JSON object.
|
|
// 2. UI: at boot it returns HTML/CSS/JS fragments that the kernel injects
|
|
// into the WebUI — either as a whole new page, or as an extra element on
|
|
// an existing page.
|
|
//
|
|
// Design notes that are load-bearing (each one cost something to learn):
|
|
//
|
|
// - Plugins run in a SEPARATE VM from adapters, and each plugin gets its own
|
|
// elastic pool, exactly like an adapter. Sharing one state would let a
|
|
// plugin's globals corrupt an adapter's protocol translation (or vice
|
|
// versa), and a plugin is third-party code while an adapter is core.
|
|
//
|
|
// - A plugin that errors must NEVER break request forwarding. Hook calls are
|
|
// wrapped so a plugin error is logged and the original value is returned
|
|
// unchanged. A broken plugin is a missing feature, not an outage — the same
|
|
// reason transform_stream_chunk swallows errors today.
|
|
//
|
|
// - The billing plugin therefore cannot be trusted to be the source of truth
|
|
// for anything the gateway must enforce. It reads usage off the hook
|
|
// payload and accumulates in its own state; the gateway's own quota
|
|
// accounting (internal/gateway/stats.go) stays authoritative for limits.
|
|
// Two accounting paths that disagree is worse than one that is slightly
|
|
// less featureful, so the split is explicit and documented.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
golua "github.com/aarzilli/golua/lua"
|
|
)
|
|
|
|
// Stage identifies a point in the request pipeline. Plugins may register a
|
|
// function for any stage; unknown stages are ignored at call time so an old
|
|
// plugin survives a gateway that grew new stages.
|
|
type Stage string
|
|
|
|
// The pipeline stages. The order here is the order they fire in; it is the
|
|
// contract plugins are written against.
|
|
const (
|
|
// StageRequestStart fires after the gateway has parsed and authorized a
|
|
// request but BEFORE any upstream slot is chosen. payload:
|
|
// stage, type ("chat"|"stream"|"image"), model (as requested),
|
|
// key (masked gateway key id), role, source (empty), stream (bool),
|
|
// messages_count, tools_count, ts (unix seconds).
|
|
StageRequestStart Stage = "request_start"
|
|
|
|
// StageRouted fires once a (source, model) slot has been chosen and before
|
|
// the upstream call. payload adds: source, model (the resolved one),
|
|
// tier (AUTO tier, -1 for the direct path), stream.
|
|
StageRouted Stage = "routed"
|
|
|
|
// StageRequestEnd fires exactly once per request, after the client response
|
|
// has been produced (or after a failure was recorded). payload adds:
|
|
// source, model, ok, status, latency_ms, first_byte_ms, prompt_tokens,
|
|
// completion_tokens, cache_hit_tokens, cache_miss_tokens, image_count,
|
|
// error ("" when ok).
|
|
//
|
|
// This is the stage a billing plugin should read: it carries the final
|
|
// accounting for the request, including the upstream's own usage numbers.
|
|
StageRequestEnd Stage = "request_end"
|
|
)
|
|
|
|
// AllStages is the firing order, used by the docs and by the hook listing.
|
|
var AllStages = []Stage{
|
|
StageRequestStart,
|
|
StageRouted,
|
|
StageRequestEnd,
|
|
}
|
|
|
|
// UIExtension is what a plugin contributes to the WebUI at boot.
|
|
type UIExtension struct {
|
|
// Page is a whole new sidebar entry + pane. Requires PageID and Title.
|
|
// The kernel renders Page's HTML into a pane whose id is "tab-"+PageID and
|
|
// adds a sidebar button with data-tab="<PageID>".
|
|
Page *UIPage `json:"page,omitempty"`
|
|
// Elements are snippets injected into EXISTING pages, keyed by target page
|
|
// id (e.g. "status", "keys"). Order within a target is plugin load order.
|
|
Elements []UIElement `json:"elements,omitempty"`
|
|
}
|
|
|
|
// UIPage is a plugin-provided page.
|
|
type UIPage struct {
|
|
PageID string `json:"page_id"` // kebab-case; becomes data-tab and #tab-<id>
|
|
Title string `json:"title"` // sidebar label
|
|
Icon string `json:"icon"` // optional inline SVG or short glyph
|
|
Order int `json:"order"` // sidebar sort key (default 100)
|
|
// Mount is the page body. It may contain <script> and <style>; the kernel
|
|
// executes scripts AFTER injecting the HTML so the DOM exists, and exposes
|
|
// `pluginAPI` to them (see docs/plugins.md).
|
|
Mount string `json:"mount"`
|
|
}
|
|
|
|
// UIElement is a snippet injected into an existing page.
|
|
type UIElement struct {
|
|
// Target is the id of the pane to inject into: "status", "chat", "keys",
|
|
// "sort", "sources" or "adapters".
|
|
Target string `json:"target"`
|
|
// Anchor is where in the target pane: "top", "bottom" or "before:<sel>" /
|
|
// "after:<sel>" for a CSS selector. Empty = "bottom".
|
|
Anchor string `json:"anchor,omitempty"`
|
|
Order int `json:"order,omitempty"` // sort key within the target
|
|
Mount string `json:"mount"`
|
|
}
|
|
|
|
// PluginInfo is the manifest a plugin declares.
|
|
type PluginInfo struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Description string `json:"description,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
}
|
|
|
|
// Plugin is one loaded plugin.
|
|
type Plugin struct {
|
|
Info PluginInfo
|
|
Hooks map[Stage]string // stage -> exported function name
|
|
UI *UIExtension
|
|
// LoadError is non-empty when the plugin failed to compile or register. Such
|
|
// a plugin is listed in the UI with its error but is never called.
|
|
LoadError string
|
|
dir string
|
|
// script is the plugin's source, kept so a reload can rebuild its state.
|
|
script string
|
|
|
|
// state is the plugin's SINGLE authoritative Lua state, guarded by mu.
|
|
//
|
|
// A plugin deliberately does NOT use the adapter's elastic pool. Adapters
|
|
// are per-worker stateless transforms, so N independent states are correct
|
|
// (and necessary) for them. A plugin's hook, however, typically ACCUMULATES
|
|
// into `plugin.state` — the billing plugin's totals live there — so two
|
|
// states would mean two divergent sets of totals, and whichever worker a
|
|
// hook happened to get would see a different number. That was a real bug
|
|
// found by a test: SetState wrote prices to one worker, the hook then ran on
|
|
// another and priced everything at zero.
|
|
//
|
|
// A single state is sufficient because every entry point (Fire, State,
|
|
// SetState) serializes on mu, and a plugin hook is a short synchronous call.
|
|
// A hook that blocks for seconds would stall every other plugin's hook,
|
|
// which is the real cost — documented as a rule in docs/plugins.md.
|
|
mu sync.Mutex
|
|
state *worker
|
|
}
|
|
|
|
// Plugins is the loaded plugin set, owned by the VM.
|
|
type Plugins struct {
|
|
mu sync.RWMutex
|
|
vm *VM
|
|
dir string // plugin directory; "" disables plugin loading entirely
|
|
plugins []*Plugin // load order; the index is the stable plugin id
|
|
// ui caches the merged UI extensions so the boot payload is computed once.
|
|
ui atomic.Pointer[UIExtension]
|
|
// stageFuncs is the precomputed stage -> []hookCall, in plugin load order.
|
|
stageFuncs map[Stage][]hookCall
|
|
// hookErr records per-stage plugin failures so a silently broken plugin is
|
|
// visible in /api/status rather than merely missing.
|
|
hookErr *hookErrors
|
|
}
|
|
|
|
type hookCall struct {
|
|
pluginIdx int
|
|
plugin string
|
|
fn string
|
|
}
|
|
|
|
// hookErrors counts hook failures per stage, surfaced in /api/status so a
|
|
// silently broken plugin is visible instead of just missing.
|
|
type hookErrors struct {
|
|
mu sync.Mutex
|
|
counts map[Stage]int
|
|
last map[Stage]string
|
|
}
|
|
|
|
func newHookErrors() *hookErrors {
|
|
return &hookErrors{counts: map[Stage]int{}, last: map[Stage]string{}}
|
|
}
|
|
|
|
func (h *hookErrors) note(s Stage, msg string) {
|
|
h.mu.Lock()
|
|
h.counts[s]++
|
|
if len(msg) > 200 {
|
|
msg = msg[:200]
|
|
}
|
|
h.last[s] = msg
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
func (h *hookErrors) snapshot() map[string]map[string]interface{} {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
out := map[string]map[string]interface{}{}
|
|
for s, n := range h.counts {
|
|
if n == 0 {
|
|
continue
|
|
}
|
|
out[string(s)] = map[string]interface{}{"count": n, "last_error": h.last[s]}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pluginGlobal is the Lua global the plugin's returned table is stored under,
|
|
// mirroring adapterGlobal for adapters.
|
|
const pluginGlobal = "__llmsproxy_plugin"
|
|
|
|
// NewPlugins creates the plugin registry for a VM. dir is the plugin directory;
|
|
// a missing directory is not an error (plugins are optional).
|
|
func NewPlugins(vm *VM, dir string) *Plugins {
|
|
return &Plugins{
|
|
vm: vm,
|
|
stageFuncs: map[Stage][]hookCall{},
|
|
hookErr: newHookErrors(),
|
|
dir: dir,
|
|
}
|
|
}
|
|
|
|
// LoadDir loads every .lua file in dir as a plugin. Files are loaded in
|
|
// lexical order so a plugin's UI order is stable across restarts.
|
|
//
|
|
// A plugin that fails to compile or register is NOT fatal: it is kept with its
|
|
// LoadError so the UI can show it, and it is never called. This is the same
|
|
// posture as adapters, except adapters are core while plugins are not.
|
|
func (ps *Plugins) LoadDir() error {
|
|
if ps.dir == "" {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
entries, err := os.ReadDir(ps.dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
|
continue
|
|
}
|
|
names = append(names, e.Name())
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
path := filepath.Join(ps.dir, n)
|
|
code, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := ps.LoadSource(strings.TrimSuffix(n, ".lua"), string(code)); err != nil {
|
|
// LoadSource records the error on the plugin itself; keep going so
|
|
// one bad plugin does not stop the others from loading.
|
|
continue
|
|
}
|
|
}
|
|
ps.rebuild()
|
|
return nil
|
|
}
|
|
|
|
// SeedBundled writes the plugins shipped with the gateway into dir when the
|
|
// directory does not exist yet, mirroring the adapter seeding rule: once the
|
|
// directory exists it is authoritative, so deleting or editing a shipped plugin
|
|
// is a real action that survives restarts.
|
|
func (ps *Plugins) SeedBundled() error {
|
|
if ps.dir == "" {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(ps.dir); err == nil {
|
|
return nil
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return writeBundledPlugins(ps.dir)
|
|
}
|
|
|
|
// bootPluginState compiles one plugin into a fresh Lua state and stores its
|
|
// returned table under pluginGlobal. It is the plugin counterpart of
|
|
// adapterPool.boot, minus the pooling: a plugin keeps exactly one state.
|
|
func bootPluginState(code, name string) (*worker, error) {
|
|
L := golua.NewState()
|
|
L.OpenLibs()
|
|
setupGlobals(L)
|
|
if err := L.DoString(code); err != nil {
|
|
L.Close()
|
|
return nil, fmt.Errorf("compile plugin %s: %w", name, err)
|
|
}
|
|
if L.Type(-1) != golua.LUA_TTABLE {
|
|
L.Close()
|
|
return nil, fmt.Errorf("plugin %s must return a table", name)
|
|
}
|
|
// SetGlobal POPS, so this is the only global it can set (see vm.go boot()).
|
|
L.SetGlobal(pluginGlobal)
|
|
L.SetTop(0)
|
|
return &worker{L: L}, nil
|
|
}
|
|
|
|
// LoadSource loads one plugin from source text. name is the plugin id (the
|
|
// file's base name). It returns an error only for conditions the caller should
|
|
// see; a plugin that merely registers nothing is not an error.
|
|
func (ps *Plugins) LoadSource(name, code string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("plugin name required")
|
|
}
|
|
if err := os.MkdirAll(ps.dir, 0755); err != nil && ps.dir != "" {
|
|
return err
|
|
}
|
|
p := &Plugin{
|
|
Info: PluginInfo{Name: name},
|
|
Hooks: map[Stage]string{},
|
|
dir: ps.dir,
|
|
}
|
|
|
|
// A plugin runs in its OWN Lua state, separate from every adapter and every
|
|
// other plugin, so an error in one cannot corrupt another. The returned
|
|
// table is stored under pluginGlobal, exactly like adapterGlobal.
|
|
//
|
|
// One state, held for the plugin's lifetime (see Plugin.state): a hook that
|
|
// accumulates into plugin.state would otherwise split its totals across
|
|
// whichever worker it happened to run on.
|
|
w, err := bootPluginState(code, name)
|
|
if err != nil {
|
|
p.LoadError = err.Error()
|
|
ps.append(p)
|
|
ps.rebuild()
|
|
return err
|
|
}
|
|
p.script = code
|
|
p.state = w
|
|
|
|
// ---- manifest ----
|
|
w.L.GetGlobal(pluginGlobal)
|
|
if !w.L.IsNil(-1) {
|
|
w.L.GetField(-1, "name")
|
|
if s := w.L.ToString(-1); s != "" {
|
|
p.Info.Name = s
|
|
}
|
|
w.L.SetTop(-2)
|
|
w.L.GetField(-1, "version")
|
|
if s := w.L.ToString(-1); s != "" {
|
|
p.Info.Version = s
|
|
}
|
|
w.L.SetTop(-2)
|
|
w.L.GetField(-1, "description")
|
|
if s := w.L.ToString(-1); s != "" {
|
|
p.Info.Description = s
|
|
}
|
|
w.L.SetTop(-2)
|
|
w.L.GetField(-1, "author")
|
|
if s := w.L.ToString(-1); s != "" {
|
|
p.Info.Author = s
|
|
}
|
|
w.L.SetTop(-2)
|
|
}
|
|
w.L.SetTop(0)
|
|
|
|
// ---- hooks ----
|
|
for _, st := range AllStages {
|
|
if fn, ok := pluginHookName(w.L, string(st)); ok {
|
|
p.Hooks[st] = fn
|
|
}
|
|
}
|
|
|
|
// ---- UI ----
|
|
if ui, ok := readPluginUI(w.L); ok {
|
|
p.UI = ui
|
|
}
|
|
|
|
ps.append(p)
|
|
// Rebuild here rather than only in LoadDir: LoadSource is also the single-
|
|
// plugin entry point (the WebUI upload path), and a caller that loads one
|
|
// plugin and immediately fires a stage must not silently get nothing.
|
|
ps.rebuild()
|
|
return nil
|
|
}
|
|
|
|
// pluginHookName returns the exported function name a plugin registered for a
|
|
// stage. A plugin registers either `hooks = {request_end = "on_end"}` or a
|
|
// direct `request_end = function(...) end` on the returned table; both forms are
|
|
// accepted because the table form keeps the manifest tidy while the direct form
|
|
// is shorter for a single-hook plugin.
|
|
//
|
|
// For the anonymous-function forms the value is re-keyed onto the plugin table
|
|
// under a synthetic name so the hot path can fetch every hook by name.
|
|
//
|
|
// STACK DISCIPLINE (this binding aborts the PROCESS on a bad index — SIGABRT,
|
|
// not a Go panic, so nothing can recover it):
|
|
//
|
|
// - GetField/SetField take the table by ABSOLUTE index, so the plugin table's
|
|
// index must be re-read after every SetTop, since SetTop(0) invalidates it.
|
|
// - Therefore each form re-pushes the plugin table and re-reads its index,
|
|
// instead of caching one index across a reset. Getting this wrong was a
|
|
// real crash found by running the test, not by reading the code.
|
|
func pluginHookName(L *golua.State, stage string) (string, bool) {
|
|
L.SetTop(0)
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
L.SetTop(0)
|
|
return "", false
|
|
}
|
|
plug := L.GetTop()
|
|
|
|
// form 1: hooks = { request_end = "fn" } (or = function)
|
|
L.GetField(plug, "hooks")
|
|
if L.Type(-1) != golua.LUA_TNIL {
|
|
hooksIdx := L.GetTop()
|
|
L.GetField(hooksIdx, stage)
|
|
switch L.Type(-1) {
|
|
case golua.LUA_TSTRING:
|
|
name := L.ToString(-1)
|
|
L.SetTop(0)
|
|
if name != "" {
|
|
return name, true
|
|
}
|
|
return "", false
|
|
case golua.LUA_TFUNCTION:
|
|
// An anonymous function: key it onto the plugin table under a stable
|
|
// per-stage name so invoke() can fetch it like any other hook.
|
|
name := "__hook_" + stage
|
|
L.SetField(plug, name)
|
|
L.SetTop(0)
|
|
return name, true
|
|
}
|
|
L.SetTop(0)
|
|
}
|
|
// form 2: request_end = function(...) end directly on the table.
|
|
// Re-push and re-read the index: SetTop(0) above invalidated `plug`.
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
L.SetTop(0)
|
|
return "", false
|
|
}
|
|
plug = L.GetTop()
|
|
L.GetField(plug, stage)
|
|
if L.Type(-1) == golua.LUA_TFUNCTION {
|
|
name := "__hook_" + stage
|
|
L.SetField(plug, name)
|
|
L.SetTop(0)
|
|
return name, true
|
|
}
|
|
L.SetTop(0)
|
|
return "", false
|
|
}
|
|
|
|
// readPluginUI reads the optional ui extension block.
|
|
func readPluginUI(L *golua.State) (*UIExtension, bool) {
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
return nil, false
|
|
}
|
|
L.GetField(-1, "ui")
|
|
if L.Type(-1) == golua.LUA_TNIL {
|
|
L.SetTop(0)
|
|
return nil, false
|
|
}
|
|
var ui UIExtension
|
|
if err := luaToJSON(L, -1, &ui); err != nil {
|
|
L.SetTop(0)
|
|
return nil, false
|
|
}
|
|
L.SetTop(0)
|
|
if ui.Page == nil && len(ui.Elements) == 0 {
|
|
return nil, false
|
|
}
|
|
return &ui, true
|
|
}
|
|
|
|
func (ps *Plugins) append(p *Plugin) {
|
|
ps.mu.Lock()
|
|
ps.plugins = append(ps.plugins, p)
|
|
ps.mu.Unlock()
|
|
}
|
|
|
|
// rebuild recomputes the stage dispatch table and the merged UI payload. It is
|
|
// called after any load so the hot path (Fire) is a slice walk with no map
|
|
// lookups or locking beyond one RLock.
|
|
func (ps *Plugins) rebuild() {
|
|
ps.mu.Lock()
|
|
defer ps.mu.Unlock()
|
|
stageFuncs := map[Stage][]hookCall{}
|
|
for i, p := range ps.plugins {
|
|
if p.LoadError != "" {
|
|
continue
|
|
}
|
|
for _, st := range AllStages {
|
|
if fn, ok := p.Hooks[st]; ok {
|
|
stageFuncs[st] = append(stageFuncs[st], hookCall{pluginIdx: i, plugin: p.Info.Name, fn: fn})
|
|
}
|
|
}
|
|
}
|
|
ps.stageFuncs = stageFuncs
|
|
|
|
merged := &UIExtension{}
|
|
for _, p := range ps.plugins {
|
|
if p.LoadError != "" || p.UI == nil {
|
|
continue
|
|
}
|
|
if p.UI.Page != nil {
|
|
merged.Page = p.UI.Page
|
|
}
|
|
merged.Elements = append(merged.Elements, p.UI.Elements...)
|
|
}
|
|
ps.ui.Store(merged)
|
|
}
|
|
|
|
// Count returns how many plugins loaded (including ones with LoadError).
|
|
func (ps *Plugins) Count() int {
|
|
ps.mu.RLock()
|
|
defer ps.mu.RUnlock()
|
|
return len(ps.plugins)
|
|
}
|
|
|
|
// List returns a JSON-friendly view of every loaded plugin, for the status API.
|
|
func (ps *Plugins) List() []map[string]interface{} {
|
|
ps.mu.RLock()
|
|
defer ps.mu.RUnlock()
|
|
out := make([]map[string]interface{}, 0, len(ps.plugins))
|
|
for _, p := range ps.plugins {
|
|
stages := make([]string, 0, len(p.Hooks))
|
|
for _, st := range AllStages {
|
|
if _, ok := p.Hooks[st]; ok {
|
|
stages = append(stages, string(st))
|
|
}
|
|
}
|
|
row := map[string]interface{}{
|
|
"name": p.Info.Name,
|
|
"version": p.Info.Version,
|
|
"description": p.Info.Description,
|
|
"author": p.Info.Author,
|
|
"hooks": stages,
|
|
"loaded": p.LoadError == "",
|
|
}
|
|
if p.LoadError != "" {
|
|
row["error"] = p.LoadError
|
|
}
|
|
if p.UI != nil {
|
|
ui := map[string]interface{}{}
|
|
if p.UI.Page != nil {
|
|
ui["page"] = p.UI.Page.PageID
|
|
}
|
|
if len(p.UI.Elements) > 0 {
|
|
ui["elements"] = len(p.UI.Elements)
|
|
}
|
|
row["ui"] = ui
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HookErrors returns per-stage hook failure counts (empty when all is well).
|
|
func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
|
|
return ps.hookErr.snapshot()
|
|
}
|
|
|
|
// UI returns the merged UI extensions to inject into the WebUI.
|
|
func (ps *Plugins) UI() *UIExtension {
|
|
if u := ps.ui.Load(); u != nil {
|
|
return u
|
|
}
|
|
return &UIExtension{}
|
|
}
|
|
|
|
// State returns a plugin's own published state. A plugin publishes it by
|
|
// setting `plugin.state = {...}` inside its hook; that is the only way a hook's
|
|
// numbers reach the WebUI, because request_end is the LAST pipeline stage and
|
|
// has no downstream consumer to hand a return value to.
|
|
//
|
|
// Returns nil when the plugin does not exist or has not published anything.
|
|
func (ps *Plugins) State(name string) interface{} {
|
|
ps.mu.RLock()
|
|
p := ps.find(name)
|
|
ps.mu.RUnlock()
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
p.mu.Lock()
|
|
w := p.state
|
|
if w == nil {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
defer p.mu.Unlock()
|
|
L := w.L
|
|
L.SetTop(0)
|
|
defer L.SetTop(0)
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
return nil
|
|
}
|
|
plug := L.GetTop()
|
|
L.GetField(plug, "state")
|
|
if L.Type(-1) == golua.LUA_TNIL {
|
|
return nil
|
|
}
|
|
var out interface{}
|
|
if err := luaToJSON(L, -1, &out); err != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SetState replaces a plugin's published state (admin API). It is how a
|
|
// configuration change (a new price for a model) reaches the plugin without
|
|
// reloading it.
|
|
//
|
|
// CONTRACT: a `prices` key in the payload is applied to the plugin's SEPARATE
|
|
// `prices` field and STRIPPED from `state`. That split is deliberate: prices are
|
|
// configuration while state is accumulated history, and a single replaceable
|
|
// field would make a price update wipe the totals (or make the totals carry a
|
|
// stale price table). A plugin that keeps its config elsewhere can ignore the
|
|
// convention and read the whole payload from `state` instead.
|
|
func (ps *Plugins) SetState(name string, state interface{}) error {
|
|
ps.mu.RLock()
|
|
p := ps.find(name)
|
|
ps.mu.RUnlock()
|
|
if p == nil {
|
|
return fmt.Errorf("plugin %s not loaded", name)
|
|
}
|
|
p.mu.Lock()
|
|
w := p.state
|
|
if w == nil {
|
|
p.mu.Unlock()
|
|
return fmt.Errorf("plugin %s has no state", name)
|
|
}
|
|
defer p.mu.Unlock()
|
|
L := w.L
|
|
L.SetTop(0)
|
|
defer L.SetTop(0)
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
return fmt.Errorf("plugin %s has no table", name)
|
|
}
|
|
plug := L.GetTop()
|
|
|
|
// Pull `prices` out of the payload before storing the rest as state.
|
|
body := state
|
|
if m, ok := state.(map[string]interface{}); ok {
|
|
if prices, has := m["prices"]; has {
|
|
pushGoValue(L, prices)
|
|
L.SetField(plug, "prices")
|
|
rest := make(map[string]interface{}, len(m))
|
|
for k, v := range m {
|
|
if k != "prices" {
|
|
rest[k] = v
|
|
}
|
|
}
|
|
if len(rest) == 0 {
|
|
// A prices-ONLY payload is a configuration change, not a state
|
|
// reset. Leaving `state` untouched is what makes repricing safe:
|
|
// replacing it with an empty table would silently erase every
|
|
// accumulated total, so the next request would start from zero
|
|
// and the dashboard would show a sudden drop in spend.
|
|
L.SetTop(0)
|
|
return nil
|
|
}
|
|
body = rest
|
|
}
|
|
}
|
|
|
|
pushGoValue(L, body)
|
|
L.SetField(plug, "state")
|
|
L.SetTop(0)
|
|
return nil
|
|
}
|
|
|
|
// Unload removes a plugin from the running set. Its states are closed so the
|
|
// memory goes back; a subsequent LoadSource with the same name works again.
|
|
func (ps *Plugins) Unload(name string) error {
|
|
ps.mu.Lock()
|
|
idx := -1
|
|
for i, p := range ps.plugins {
|
|
if p.Info.Name == name || strings.HasSuffix(filepath.Base(p.dir), name+".lua") {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
ps.mu.Unlock()
|
|
return fmt.Errorf("plugin %s not loaded", name)
|
|
}
|
|
p := ps.plugins[idx]
|
|
ps.plugins = append(ps.plugins[:idx], ps.plugins[idx+1:]...)
|
|
ps.mu.Unlock()
|
|
p.mu.Lock()
|
|
if p.state != nil && p.state.L != nil {
|
|
p.state.L.Close()
|
|
p.state = nil
|
|
}
|
|
p.mu.Unlock()
|
|
ps.rebuild()
|
|
return nil
|
|
}
|
|
|
|
// find returns a loaded plugin by declared name. Caller holds ps.mu.
|
|
func (ps *Plugins) find(name string) *Plugin {
|
|
for _, p := range ps.plugins {
|
|
if p.Info.Name == name {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Fire calls every plugin registered for a stage, in plugin load order.
|
|
//
|
|
// A plugin may MUTATE the payload by returning a JSON object: any keys it
|
|
// returns are merged into the payload for the next hook and returned to the
|
|
// caller. Returning nil or an empty table means "no opinion". This lets a
|
|
// plugin add fields (the billing plugin adds `cost_usd`) without the gateway
|
|
// having to know about them.
|
|
//
|
|
// Errors are contained: a plugin that throws is logged against the stage and
|
|
// skipped. Forwarding never depends on plugin health.
|
|
func (ps *Plugins) Fire(stage Stage, payload map[string]interface{}) map[string]interface{} {
|
|
ps.mu.RLock()
|
|
calls := ps.stageFuncs[stage]
|
|
ps.mu.RUnlock()
|
|
if len(calls) == 0 {
|
|
return payload
|
|
}
|
|
for _, hc := range calls {
|
|
ps.mu.RLock()
|
|
p := ps.plugins[hc.pluginIdx]
|
|
ps.mu.RUnlock()
|
|
if p == nil || p.LoadError != "" {
|
|
continue
|
|
}
|
|
out, err := ps.invoke(p, hc.fn, payload)
|
|
if err != nil {
|
|
ps.hookErr.note(stage, p.Info.Name+": "+err.Error())
|
|
continue
|
|
}
|
|
if len(out) > 0 {
|
|
for k, v := range out {
|
|
payload[k] = v
|
|
}
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// invoke runs one plugin hook on that plugin's own state, under its pool's
|
|
// concurrency cap. The plugin's returned table is re-fetched each call because
|
|
// the pool is elastic: a plugin may have several states, and the hook function
|
|
// lives in each.
|
|
func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{}) (map[string]interface{}, error) {
|
|
p.mu.Lock()
|
|
w := p.state
|
|
if w == nil {
|
|
p.mu.Unlock()
|
|
return nil, fmt.Errorf("no state for plugin %s", p.Info.Name)
|
|
}
|
|
defer p.mu.Unlock()
|
|
L := w.L
|
|
L.SetTop(0)
|
|
defer L.SetTop(0)
|
|
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
return nil, fmt.Errorf("plugin table missing")
|
|
}
|
|
plug := L.GetTop() // absolute, so nothing below shifts
|
|
L.GetField(plug, fn)
|
|
if !L.IsFunction(-1) {
|
|
L.SetTop(0)
|
|
return nil, fmt.Errorf("hook %s missing", fn)
|
|
}
|
|
// The hook is called with a DECODED table, not the raw JSON string: the
|
|
// adapter protocol passes JSON text to its transforms (they decode it
|
|
// themselves), but a plugin hook receives a table so it can read
|
|
// payload.model directly. Passing the string made every hook fail with
|
|
// "attempt to index local 'payload' (a string value)".
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var decoded interface{}
|
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
pushGoValue(L, decoded)
|
|
// Call takes NO function index: it invokes whatever sits directly below the
|
|
// nargs values it just pushed. Passing an index here is a compile-time no-op
|
|
// in this binding and the call lands on the argument instead
|
|
// ("attempt to call a table value").
|
|
if err := L.Call(1, 1); err != nil {
|
|
return nil, err
|
|
}
|
|
if L.GetTop() < 1 || L.IsNil(-1) {
|
|
return nil, nil
|
|
}
|
|
var out map[string]interface{}
|
|
if err := luaToJSON(L, -1, &out); err != nil {
|
|
return nil, nil // not a table: treat as "no opinion"
|
|
}
|
|
return out, nil
|
|
}
|