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
314 lines
8.7 KiB
Go
314 lines
8.7 KiB
Go
package lua
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newPluginVM builds a VM plus a plugin registry rooted at dir.
|
|
func newPluginVM(t *testing.T) (*VM, *Plugins, string) {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "adapters")
|
|
vm := NewVM(dir)
|
|
if err := vm.Start(); err != nil {
|
|
t.Fatalf("vm start: %v", err)
|
|
}
|
|
t.Cleanup(vm.Stop)
|
|
pdir := filepath.Join(t.TempDir(), "plugins")
|
|
return vm, NewPlugins(vm, pdir), pdir
|
|
}
|
|
|
|
// loadPlugin writes one plugin to disk and loads it.
|
|
func loadPlugin(t *testing.T, ps *Plugins, name, code string) error {
|
|
t.Helper()
|
|
if err := os.MkdirAll(ps.dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ps.dir, name+".lua"), []byte(code), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ps.LoadSource(name, code)
|
|
}
|
|
|
|
// TestPluginManifestAndHooks: the two hook registration forms both work and the
|
|
// manifest is read.
|
|
func TestPluginManifestAndHooks(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = {}
|
|
p.name = "demo"
|
|
p.version = "1.2.3"
|
|
p.description = "a demo plugin"
|
|
p.author = "tester"
|
|
p.hooks = { request_end = "on_end" }
|
|
function p.on_end(payload)
|
|
payload.seen = true
|
|
payload.name_seen = "demo"
|
|
return payload
|
|
end
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "demo", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
list := ps.List()
|
|
if len(list) != 1 {
|
|
t.Fatalf("List() = %d plugins, want 1", len(list))
|
|
}
|
|
if list[0]["name"] != "demo" || list[0]["version"] != "1.2.3" {
|
|
t.Errorf("manifest not read: %+v", list[0])
|
|
}
|
|
hooks := list[0]["hooks"].([]string)
|
|
if len(hooks) != 1 || hooks[0] != string(StageRequestEnd) {
|
|
t.Errorf("hooks = %v, want [request_end]", hooks)
|
|
}
|
|
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
|
if out["seen"] != true || out["name_seen"] != "demo" {
|
|
t.Errorf("hook did not mutate payload: %+v", out)
|
|
}
|
|
}
|
|
|
|
// TestPluginAnonymousHookForm: `request_end = function() end` directly on the
|
|
// table must register too, since a single-hook plugin should not need a name.
|
|
func TestPluginAnonymousHookForm(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "anon" }
|
|
p.request_end = function(payload)
|
|
payload.hit = 1
|
|
return payload
|
|
end
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "anon", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
|
|
if out["hit"] != float64(1) {
|
|
t.Errorf("anonymous hook did not fire: %+v", out)
|
|
}
|
|
}
|
|
|
|
// TestPluginHookStagesFireInOrder: each stage reaches only its own hooks.
|
|
func TestPluginHookStagesFireInOrder(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "stages" }
|
|
p.hooks = {
|
|
request_start = "s1",
|
|
routed = "s2",
|
|
request_end = "s3",
|
|
}
|
|
function p.s1(x) x.order = (x.order or "") .. "1" return x end
|
|
function p.s2(x) x.order = (x.order or "") .. "2" return x end
|
|
function p.s3(x) x.order = (x.order or "") .. "3" return x end
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "stages", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
payload := map[string]interface{}{}
|
|
ps.Fire(StageRequestStart, payload)
|
|
ps.Fire(StageRouted, payload)
|
|
ps.Fire(StageRequestEnd, payload)
|
|
if payload["order"] != "123" {
|
|
t.Errorf("stage order = %v, want \"123\"", payload["order"])
|
|
}
|
|
}
|
|
|
|
// TestPluginErrorIsContained is the critical safety property: a throwing hook
|
|
// must not propagate. Forwarding depends on it.
|
|
func TestPluginErrorIsContained(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "boom" }
|
|
p.hooks = { request_end = "kaboom" }
|
|
function p.kaboom(payload)
|
|
error("intentional plugin failure")
|
|
end
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "boom", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
// Must not panic and must return the payload unchanged.
|
|
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
|
if out["model"] != "m" {
|
|
t.Errorf("payload was altered by a failing plugin: %+v", out)
|
|
}
|
|
// And the failure must be visible, not silent.
|
|
errs := ps.HookErrors()
|
|
if errs["request_end"] == nil {
|
|
t.Error("a failing plugin left no error record; it would be silently missing")
|
|
}
|
|
}
|
|
|
|
// TestPluginFailingHookDoesNotBlockLaterPlugins: one bad plugin must not stop
|
|
// the next one from running.
|
|
func TestPluginFailingHookDoesNotBlockLaterPlugins(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
bad := `
|
|
local p = { name = "bad" }
|
|
p.hooks = { request_end = "f" }
|
|
function p.f(x) error("boom") end
|
|
return p
|
|
`
|
|
good := `
|
|
local p = { name = "good" }
|
|
p.hooks = { request_end = "f" }
|
|
function p.f(x) x.good = true return x end
|
|
return p
|
|
`
|
|
_ = loadPlugin(t, ps, "bad", bad)
|
|
if err := loadPlugin(t, ps, "good", good); err != nil {
|
|
t.Fatalf("load good: %v", err)
|
|
}
|
|
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
|
|
if out["good"] != true {
|
|
t.Errorf("a good plugin was blocked by a failing one: %+v", out)
|
|
}
|
|
}
|
|
|
|
// TestPluginSyntaxErrorIsIsolated: a plugin that will not compile is listed
|
|
// with its error and is never called — it must not prevent LoadDir from loading
|
|
// the rest.
|
|
func TestPluginSyntaxErrorIsIsolated(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
broken := "this is not lua((("
|
|
good := `
|
|
local p = { name = "ok" }
|
|
p.hooks = { request_end = "f" }
|
|
function p.f(x) x.ok = true return x end
|
|
return p
|
|
`
|
|
_ = loadPlugin(t, ps, "broken", broken)
|
|
if err := loadPlugin(t, ps, "ok", good); err != nil {
|
|
t.Fatalf("load ok: %v", err)
|
|
}
|
|
if err := ps.LoadDir(); err != nil {
|
|
t.Fatalf("LoadDir: %v", err)
|
|
}
|
|
// The broken plugin must not be callable and must carry an error.
|
|
for _, row := range ps.List() {
|
|
if row["name"] == "broken" {
|
|
if row["loaded"] == true {
|
|
t.Error("a plugin with a syntax error reported itself as loaded")
|
|
}
|
|
if row["error"] == nil || row["error"] == "" {
|
|
t.Error("a broken plugin carries no error message")
|
|
}
|
|
}
|
|
}
|
|
// The good plugin still works.
|
|
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
|
|
if out["ok"] != true {
|
|
t.Errorf("good plugin stopped working: %+v", out)
|
|
}
|
|
}
|
|
|
|
// TestPluginUIExtension: a plugin can contribute a page and elements.
|
|
func TestPluginUIExtension(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "ui" }
|
|
p.hooks = { request_end = "f" }
|
|
function p.f(x) return x end
|
|
p.ui = {
|
|
page = {
|
|
page_id = "billing",
|
|
title = "Billing",
|
|
icon = "💰",
|
|
order = 50,
|
|
mount = "<div id=billing>hi</div><script>console.log('m')</script>",
|
|
},
|
|
elements = {
|
|
{ target = "status", anchor = "top", mount = "<div>cost</div>" },
|
|
},
|
|
}
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "ui", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
ui := ps.UI()
|
|
if ui.Page == nil {
|
|
t.Fatal("no page contributed")
|
|
}
|
|
if ui.Page.PageID != "billing" || ui.Page.Title != "Billing" {
|
|
t.Errorf("page = %+v", ui.Page)
|
|
}
|
|
if !strings.Contains(ui.Page.Mount, "console.log") {
|
|
t.Error("mount lost its script content")
|
|
}
|
|
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
|
|
t.Errorf("elements = %+v", ui.Elements)
|
|
}
|
|
}
|
|
|
|
// TestPluginHookReturnsNilIsNoOpinion: a hook returning nothing must leave the
|
|
// payload untouched (plugins should not be forced to echo it back).
|
|
func TestPluginHookReturnsNilIsNoOpinion(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "silent" }
|
|
p.hooks = { request_end = "f" }
|
|
function p.f(payload)
|
|
-- records nothing, returns nothing
|
|
return nil
|
|
end
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "silent", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "ok": true})
|
|
if out["model"] != "m" || out["ok"] != true {
|
|
t.Errorf("a no-op hook disturbed the payload: %+v", out)
|
|
}
|
|
}
|
|
|
|
// TestPluginUIJSONShape is a wire-format guard: the kernel sends this to the
|
|
// browser, so the shape is a contract with the WebUI.
|
|
func TestPluginUIJSONShape(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "shape" }
|
|
p.hooks = { request_end = "f" }
|
|
function p.f(x) return x end
|
|
p.ui = { elements = { { target = "keys", mount = "<b>k</b>" } } }
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "shape", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
b, err := json.Marshal(ps.UI())
|
|
if err != nil {
|
|
t.Fatalf("marshal UI: %v", err)
|
|
}
|
|
var view struct {
|
|
Elements []struct {
|
|
Target string `json:"target"`
|
|
Mount string `json:"mount"`
|
|
} `json:"elements"`
|
|
}
|
|
if err := json.Unmarshal(b, &view); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(view.Elements) != 1 || view.Elements[0].Target != "keys" {
|
|
t.Errorf("UI JSON shape = %+v", view.Elements)
|
|
}
|
|
}
|
|
|
|
// TestPluginFireWithNoPluginsIsNoop: an empty registry must not allocate or fail.
|
|
func TestPluginFireWithNoPluginsIsNoop(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
in := map[string]interface{}{"a": 1}
|
|
out := ps.Fire(StageRequestEnd, in)
|
|
if out["a"] != 1 || ps.Count() != 0 {
|
|
t.Errorf("empty registry misbehaved: %+v", out)
|
|
}
|
|
}
|