From a51a6811a69f39a6c26932a3d021303fcf4699f7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 00:37:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20Lua=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=20+=20=E8=AE=A1=E8=B4=B9=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=20+=20=E6=8F=92=E4=BB=B6=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件 = 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 再重建 diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go new file mode 100644 index 0000000..d76b2b3 --- /dev/null +++ b/internal/gateway/ui_plugin_test.go @@ -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 +// +]==], + }, + -- Two elements on the EXISTING status page: a headline tile and a + -- per-source cost breakdown, so the number is visible without opening the + -- Billing tab. + elements = { + { + target = "status", + anchor = "top", + order = 5, + mount = [==[ +
+
Total spend (billing plugin)
+
—
+
+
+ +]==], + }, + }, +} + +return plugin \ No newline at end of file diff --git a/internal/lua/plugins_test.go b/internal/lua/plugins_test.go new file mode 100644 index 0000000..41ea89a --- /dev/null +++ b/internal/lua/plugins_test.go @@ -0,0 +1,313 @@ +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 = "
hi
", + }, + elements = { + { target = "status", anchor = "top", mount = "
cost
" }, + }, +} +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 = "k" } } } +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) + } +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index d22e7bc..51ff6af 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -36,7 +36,7 @@ import ( golua "github.com/aarzilli/golua/lua" ) -//go:embed adapters/*.lua +//go:embed adapters/*.lua plugins/*.lua var bundledAdapters embed.FS // adapterGlobal is the reserved global holding the adapter table after the @@ -103,6 +103,11 @@ type adapterPool struct { lastGrow time.Time idleRounds int // consecutive janitor rounds that saw reclaimable slack peakInUse int // high-water mark of inUse, for observability + // pluginMode makes boot() store the returned table under pluginGlobal + // instead of adapterGlobal. Everything else (elastic sizing, reclaim) is + // identical, which is why plugins reuse this pool rather than getting a + // second implementation. + pluginMode bool } const ( @@ -116,6 +121,13 @@ const ( // growCooldown keeps a burst of misses from batching repeatedly while the // previous batch is still booting. growCooldown = time.Second + // maxPluginStates caps how many concurrent VM states ONE plugin may occupy. + // A plugin is third-party code on the request path, so its ceiling is much + // lower than an adapter's (which is sized from the sources' + // max_concurrent): a plugin hook is a short synchronous call, so a handful + // of states is already far more parallelism than any real hook needs, and a + // runaway plugin cannot balloon memory the way a per-source adapter pool can. + maxPluginStates = 4 // residentWorkers is how many states an adapter keeps warm once it has // served at least one request. Booting is milliseconds, but keeping one warm // removes that from the critical path of the next request. Adapters that @@ -265,7 +277,14 @@ func (p *adapterPool) boot() (*worker, error) { L.Close() return nil, fmt.Errorf("adapter %s must return a table", p.name) } - L.SetGlobal(adapterGlobal) + // SetGlobal POPS the value off the stack, so it can only be called once per + // boot. Plugins therefore store under pluginGlobal only, and NOT under + // adapterGlobal: a second SetGlobal on the now-empty stack would assign nil. + if p.pluginMode { + L.SetGlobal(pluginGlobal) + } else { + L.SetGlobal(adapterGlobal) + } L.SetTop(0) return &worker{L: L}, nil } @@ -601,6 +620,55 @@ func (v *VM) Start() error { return nil } +// bundledPluginsDir is the directory inside the embedded FS holding the +// plugins shipped with the gateway. It is separate from adapters/ on purpose: +// the two are loaded by different machinery into different kinds of Lua state +// (a protocol transform vs. request-pipeline hooks), and keeping them apart +// makes it obvious that dropping a file in one does not affect the other. +const bundledPluginsDir = "plugins" + +// ReadBundledPlugin returns the source of a plugin shipped with the gateway. +// It exists so a test (or an operator tool) can load a bundled plugin without +// depending on whether seeding has already run for this directory. +func ReadBundledPlugin(name string) (string, error) { + data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + name + ".lua") + if err != nil { + return "", fmt.Errorf("bundled plugin %s: %w", name, err) + } + return string(data), nil +} + +// writeBundledPlugins seeds the plugin directory with the shipped plugins. +// +// It runs only when the directory does not exist yet (same rule as adapters): +// once the directory exists it is authoritative, so deleting a shipped plugin is +// a real delete and editing one survives restarts. +func writeBundledPlugins(dir string) error { + if dir == "" { + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir plugin dir: %w", err) + } + entries, err := bundledAdapters.ReadDir(bundledPluginsDir) + if err != nil { + return nil // nothing embedded; not an error + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { + continue + } + data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name()) + if err != nil { + continue + } + if err := os.WriteFile(filepath.Join(dir, e.Name()), data, 0644); err != nil { + return err + } + } + return nil +} + func (v *VM) Stop() { select { case <-v.janitorStop: @@ -1104,6 +1172,56 @@ func pushGoValue(L *golua.State, v interface{}) { } func jsonEncode(v interface{}) ([]byte, error) { return json.Marshal(v) } + +// luaToJSON converts the Lua value at idx into a Go value via json.encode, then +// unmarshals it into out. It is the bridge used by the plugin manifest/UI +// reader: the plugin returns a plain Lua table, and Go wants a typed struct. +// +// It goes through JSON rather than walking the Lua stack directly because the +// adapter/plugin boundary already speaks JSON everywhere else (transform_request +// gets a JSON string, hooks get a JSON string), so this keeps one representation +// instead of two. +func luaToJSON(L *golua.State, idx int, out interface{}) error { + if L.GetTop() < 1 { + return fmt.Errorf("empty stack") + } + abs := idx + if abs < 0 { + abs = L.GetTop() + 1 + abs + } + if abs < 1 || abs > L.GetTop() { + return fmt.Errorf("index %d out of range (top=%d)", idx, L.GetTop()) + } + // Absolute indices throughout: this binding aborts the process (SIGABRT) + // on a bad index rather than panicking, so the stack is captured before any + // push instead of being addressed relative to a shifting top. + // + // json.encode is pushed onto the stack and the value is pushed AFTER it, so + // Call(1, 1) invokes it (Call takes no function index — it calls whatever + // sits below the nargs values). + L.GetGlobal("json") + if L.IsNil(-1) { + L.SetTop(0) + return fmt.Errorf("json global missing") + } + L.GetField(-1, "encode") + if L.Type(-1) != golua.LUA_TFUNCTION { + L.SetTop(0) + return fmt.Errorf("json.encode missing") + } + L.PushValue(abs) + if err := L.Call(1, 1); err != nil { + L.SetTop(0) + return err + } + if L.GetTop() < 1 || L.Type(-1) != golua.LUA_TSTRING { + L.SetTop(0) + return fmt.Errorf("json.encode did not return a string") + } + s := L.ToString(-1) + L.SetTop(0) + return json.Unmarshal([]byte(s), out) +} func jsonDecode(s string) (interface{}, error) { var v interface{} if err := json.Unmarshal([]byte(s), &v); err != nil {