fix(plugins): 插件 Lua 报错不再拖垮网关(生产事故修复)

## 事故

13:37 部署后线上 6 次 SIGSEGV 崩溃循环,8081 完全不可用,用户报大量
connect error。崩溃点固定在 internal/lua/plugins.go:invoke → L.Call →
golua StackTrace 里的 lua_getinfo。

## 根因(不是并发/GC/锁)

golua 的 callEx 在**任何** pcall 失败后无条件执行 L.StackTrace(),而
StackTrace 调 lua_getinfo,这个 LuaJIT 构建在栈够深时(带 AUTO 链轨迹的
request_end payload 正好够深)直接段错误。这是 C 层信号,Go 无法 recover,
所以一个插件的脚本错误就能带走整个进程和所有在途请求。

触发错误来自我上一轮加的 billing 日级维度:

    add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), ...)

三个 bucket( 只对应两个 ),最外层 bucket() 只收到一个参数,k=nil,于是
billing.lua:141 `tbl[k] = b` 抛 "table index is nil",**每个请求都抛**。

同时还有第二个 bug:中间层用了 bucket()(返回 emptyBucket,含 cost/requests
字段)当作嵌套容器,结构也是错的。改为 dayMap() 返回纯表。

## 修法

1. billing.lua:修正括号,多层容器改用 dayMap()。
2. **pcall 守卫**(真正的架构修复):在 setupGlobals 里注册
   __llmsproxy_call_hook,钩子改为经它调用。

       function __llmsproxy_call_hook(fn, payload)
         local ok, res = pcall(fn, payload)
         if not ok then return nil, tostring(res) end
         return res, nil
       end

   Lua 侧 pcall 在 golua 看到非零 pcall 状态之前就拦下错误,C 栈回溯路径
   永远进不去。错误变成普通返回值 (nil, msg),Go 侧记进 hook_errors 并跳过
   ——"插件出错不影响请求转发"这条承诺对脚本错误也终于成立,而不只是对 Go panic。

## 这同时修掉了那个查了很久的间歇崩溃

同一个机制解释了此前 8/20 复现、却查不出根因的 SIGSEGV(怀疑过 janitor 竞态、
GC、LuaJIT 全局状态、VM 释放时序,全部排除)。实测对比:

  TestBillingPrecedence   修复前 8/20 崩溃 → 修复后 0/20
  并发建 16 个 VM 的探针   修复前 3/3  崩溃 → 修复后 0/6
  全量 ./...              连跑 5 次全绿

那些崩溃本来就是一个 Lua 钩子错误在栈深时炸掉 StackTrace,时机随机所以看着
像并发问题。

## 判据

TestHookThatRaisesDoesNotCrashTheProcess:装一个每请求必崩的插件,连打 50 次,
断言进程存活 + 错误被记录 + 同状态里健康的 billing 插件照常工作。
3 个变异(守卫不 pcall / 守卫名写错 / 守卫未注册)全部被捕获,其中第一个直接
让 SIGSEGV 重现,说明守卫就是唯一防线。

## 线上验证

往生产插件目录放一个每请求必然报错的插件,连打 30 个真实流式请求:

  30× HTTP 200,SIGSEGV 0 次
  hook_errors 记录 count=44 且指名 zbroken-test(可观测)
  billing 照常累计(2999 请求 / $0.5668)

测试插件已移除。

回滚点:/usr/local/bin/llmsproxy.bak-real-<TS>、billing.lua.bak-real-<TS>。
This commit is contained in:
JianFeeeee
2026-10-02 14:07:44 +08:00
parent fe0764e375
commit d9652f479a
8 changed files with 937 additions and 38 deletions

View File

@ -67,26 +67,30 @@ func TestUIInjectServesPluginUI(t *testing.T) {
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"`
// Decoded into the real types so the test cannot drift from the wire
// contract. An inline copy missed the `pages` field when the payload
// shape changed and failed to compile, which is at least loud — but the
// same copy also went on asserting the OLD single-page shape for a
// release, silently.
UI lua.UIExtension `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")
// The payload carries every page in one list; ui.page is no longer a
// separate slot (it was, and a second plugin contributing a page overwrote
// whatever was there).
var billing *lua.UIPage
for i, pg := range view.UI.Pages {
if pg != nil && pg.PageID == "billing" {
billing = view.UI.Pages[i]
}
}
if !strings.Contains(view.UI.Page.Mount, "billing-root") {
if billing == nil {
t.Fatalf("no billing page in the inject payload; pages=%d", len(view.UI.Pages))
}
if !strings.Contains(billing.Mount, "billing-root") {
t.Error("the page mount came back empty")
}
if len(view.UI.Elements) == 0 {

View File

@ -5362,9 +5362,21 @@
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);
// --- pages ---
//
// A plugin may contribute ONE page (`ui.page`) or SEVERAL
// (`ui.pages[]`). Both are handled by the same code: a plugin whose
// price rules produce the numbers on its billing page needs a second
// screen to edit them, and cramming both into one pane behind
// in-page tabs would hide a whole capability behind a toggle. The
// single-page shape stays supported because it is what docs/plugins.md
// documents and what every existing plugin uses.
const pluginPages = []
.concat(ui.page ? [ui.page] : [])
.concat(Array.isArray(ui.pages) ? ui.pages : [])
.filter((p) => p && p.page_id && p.mount);
pluginPages.forEach((pg) => {
const id = String(pg.page_id);
if (!document.getElementById("tab-" + id)) {
const pane = document.createElement("div");
pane.id = "tab-" + id;
@ -5381,7 +5393,7 @@
const btn = document.createElement("button");
btn.className = "sb-i";
btn.dataset.tab = id;
btn.title = ui.page.title || id;
btn.title = pg.title || id;
// A plugin icon may be plain text (an emoji, a glyph) or an inline
// SVG snippet. Native tabs use inline SVG styled with
// `stroke: currentColor`, so an emoji next to them renders at the
@ -5391,12 +5403,12 @@
// The SVG form is allowed through RAW, which is only safe because
// it is strictly filtered: see pluginIconHTML. Escaping it (as this
// did) would print the markup as text instead.
btn.innerHTML = pluginIconHTML(ui.page.icon);
btn.innerHTML = pluginIconHTML(pg.icon);
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;
if (typeof NAV_NAME === "object") NAV_NAME[id] = pg.title || id;
}
const pane = document.getElementById("tab-" + id);
if (pane && !pane.dataset.pluginMounted) {
@ -5406,7 +5418,7 @@
// 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;
tpl.innerHTML = pg.mount;
pane.appendChild(tpl.content);
// Move each script into a fresh element so it executes.
pane.querySelectorAll("script").forEach((old) => {
@ -5416,7 +5428,7 @@
old.replaceWith(s);
});
}
}
});
// --- elements into existing pages ---
//

View File

@ -275,6 +275,22 @@ func TestBillingPluginDeclaresUI(t *testing.T) {
if page, _ := ui["page"].(string); page != "billing" {
t.Errorf("ui.page = %v, want \"billing\"", ui["page"])
}
// The management listing must name EVERY page, not just the first.
// Without `pages`, a plugin contributing the totals page and the rule
// editor is shown as contributing one, and the operator has no way to
// tell from the plugin list that a second screen exists.
// List() builds this map in Go, so the value is []string here; only
// after the HTTP round-trip would it be []interface{}. Asserting the
// wrong one is a silently empty set, which is what made the first
// version of this check fail for the wrong reason.
pages, _ := ui["pages"].([]string)
found := map[string]bool{}
for _, id := range pages {
found[id] = true
}
if !found["billing-rules"] {
t.Errorf("ui.pages = %v, want it to include billing-rules", found)
}
if n, _ := ui["elements"].(int); n < 1 {
t.Error("billing contributes no element to an existing page")
}
@ -283,6 +299,137 @@ func TestBillingPluginDeclaresUI(t *testing.T) {
t.Fatal("billing plugin is not loaded")
}
// TestBillingDeclaresRulesEditorPage: the rule editor is a SECOND screen, not a
// tab inside the totals page. Two reasons it has to be its own page: mixing
// editable configuration with read-only results blurs the line between "looking
// at numbers" and "changing prices", and the plugin UI contract only ever had
// room for one page — a second plugin contributing a page silently overwrote
// the first, so multi-page had to become a first-class shape before this could
// exist.
func TestBillingDeclaresRulesEditorPage(t *testing.T) {
ps, _ := billingVM(t)
var ui *UIExtension = nil
for _, p := range ps.plugins {
if p.Info.Name == "billing" {
ui = p.UI
}
}
if ui == nil {
t.Fatal("billing plugin loaded with no UI extension")
}
// Read the plugin's OWN extension, which is where the single `page` field
// still lives — the fold into one list happens in the merged view, not here.
ids := map[string]bool{}
if ui.Page != nil {
ids[ui.Page.PageID] = true
}
for _, pg := range ui.Pages {
if pg != nil {
ids[pg.PageID] = true
}
}
if !ids["billing"] {
t.Error("the totals page is missing")
}
if !ids["billing-rules"] {
t.Errorf("the rule editor page is missing; pages = %v", ids)
}
// The editor must actually contain its controls, not just a pane: a page
// that mounts an empty div looks fine in the sidebar and does nothing.
var mount string
for _, pg := range ui.Pages {
if pg != nil && pg.PageID == "billing-rules" {
mount = pg.Mount
}
}
for _, needle := range []string{`id="br-body"`, "data-act='save'", "data-act='export'", "data-act='newprofile'", ".r-url", ".r-mode"} {
if !strings.Contains(mount, needle) {
t.Errorf("the rule editor page is missing %s", needle)
}
}
}
// TestUIExtensionMergesEveryPluginPage: two plugins contributing pages must
// BOTH appear. The old merge assigned a single field, so the second plugin
// erased the first one's page from the sidebar with no error anywhere.
func TestUIExtensionMergesEveryPluginPage(t *testing.T) {
ps, pdir := billingVM(t)
mk := func(name, code string) {
if err := os.WriteFile(filepath.Join(pdir, name+".lua"), []byte(code), 0644); err != nil {
t.Fatal(err)
}
if err := ps.LoadSource(name, code); err != nil {
t.Fatalf("load %s: %v", name, err)
}
}
mk("other", `
local plugin = {}
plugin.name = "other"
plugin.version = "0.1"
plugin.ui = { page = { page_id = "other-page", title = "Other", order = 90,
mount = "<div id='other-root'></div>" } }
return plugin`)
mk("third", `
local plugin = {}
plugin.name = "third"
plugin.version = "0.1"
plugin.ui = { pages = {
{ page_id = "third-a", title = "Third A", order = 80, mount = "<div id='ta'></div>" },
{ page_id = "third-b", title = "Third B", order = 81, mount = "<div id='tb'></div>" },
} }
return plugin`)
ui := ps.UI()
if ui == nil {
t.Fatal("no merged UI")
}
got := map[string]bool{}
for _, pg := range ui.Pages {
got[pg.PageID] = true
}
for _, want := range []string{"billing", "billing-rules", "other-page", "third-a", "third-b"} {
if !got[want] {
t.Errorf("merged UI lost page %q; has %v", want, got)
}
}
// A duplicate page_id must not appear twice. Two plugins claiming the same
// id collide in the DOM (getElementById returns the first, the second pane
// is silently unreachable), so the merge keeps the first and drops the
// later one.
mk("collide", `
local plugin = {}
plugin.name = "collide"
plugin.version = "0.1"
plugin.ui = { pages = {
{ page_id = "third-a", title = "Impostor", order = 79, mount = "<div id='impostor'></div>" },
} }
return plugin`)
ui = ps.UI()
counts := map[string]int{}
for _, pg := range ui.Pages {
counts[pg.PageID]++
}
if counts["third-a"] != 1 {
t.Errorf("page id third-a appears %d times; a duplicate id collides in the DOM", counts["third-a"])
}
for _, pg := range ui.Pages {
if pg.PageID == "third-a" && pg.Title == "Impostor" {
t.Error("the LATER plugin won the id; first writer should keep it")
}
}
// Order must be honoured so the sidebar is predictable.
if len(ui.Pages) != 5 {
t.Fatalf("expected all 5 pages to merge, got %d: %v", len(ui.Pages), got)
}
for i := 1; i < len(ui.Pages); i++ {
if ui.Pages[i].Order < ui.Pages[i-1].Order {
t.Errorf("pages out of order at %d: %d before %d",
i, ui.Pages[i-1].Order, ui.Pages[i].Order)
}
}
}
// TestBillingPluginLoadedByDefault: the shipped plugin must load with no
// configuration, since seeding only happens on a fresh plugin dir.
func TestBillingPluginLoadedByDefault(t *testing.T) {

View File

@ -0,0 +1,76 @@
package lua
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A plugin hook that RAISES must not take the process down. This is the
// production outage: a Lua error anywhere in a hook made golua's callEx call
// L.StackTrace(), which calls lua_getinfo and SIGSEGVs on a deep enough stack —
// a C-level signal Go cannot recover from, so one buggy plugin killed the whole
// gateway and took every in-flight request with it.
//
// The fix routes hook calls through a Lua-side pcall guard, so the error comes
// back as an ordinary return value. This test fires hooks that raise on purpose
// and asserts three things: the process survives, the failure is RECORDED, and
// a well-behaved plugin on the same state keeps working afterwards (the error
// must not poison the Lua state).
func TestHookThatRaisesDoesNotCrashTheProcess(t *testing.T) {
ps, pdir := billingVM(t)
boom := `
local plugin = {}
plugin.name = "boom"
plugin.version = "0.1"
function plugin.request_end(payload)
-- Raise on a table index, the exact shape of the billing bug that caused the
-- outage. Deliberately NOT a syntax error: this must load fine and fail only
-- when invoked.
local x = nil
return x.field
end
return plugin`
if err := os.WriteFile(filepath.Join(pdir, "boom.lua"), []byte(boom), 0644); err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("boom", boom); err != nil {
t.Fatalf("load boom: %v", err)
}
payload := map[string]interface{}{
"model": "m", "source": "s", "ok": true,
"prompt_tokens": 100, "completion_tokens": 10, "time": 1750000000000,
}
// Fire many times: a single call could pass by luck, but if the error ever
// escapes into golua's C path the process dies and this test never returns.
for i := 0; i < 50; i++ {
ps.Fire(StageRequestEnd, payload)
}
// Reaching this line at all is the primary assertion.
errs := ps.HookErrors()
end, ok := errs[string(StageRequestEnd)]
if !ok {
t.Fatal("a raising hook left no record — failures must be observable, not swallowed")
}
if end["count"] == nil || end["count"].(int) == 0 {
t.Error("hook error count is zero despite 50 raising calls")
}
msg, _ := end["last_error"].(string)
if !strings.Contains(msg, "boom") {
t.Errorf("last_error does not name the offending plugin: %q", msg)
}
// The billing plugin shares the same Plugins registry and must still work:
// one broken plugin may not disable the others.
st, _ := ps.State("billing").(map[string]interface{})
if st == nil || st["total"] == nil {
t.Fatalf("the healthy plugin stopped working after another plugin raised")
}
tot, _ := st["total"].(map[string]interface{})
if tot == nil || tot["requests"] == nil || tot["requests"].(float64) == 0 {
t.Errorf("billing recorded no requests after the raising plugin ran: %v", st["total"])
}
}

View File

@ -115,6 +115,14 @@ type UIExtension struct {
// 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"`
// Pages is the multi-page form of the same thing. A plugin with several
// distinct screens (billing totals vs. the price rules that produced
// them) would otherwise have to cram both into one pane behind tabs, or
// smuggle the second one in as a hidden element. Both make the sidebar
// lie about what the plugin contributes.
//
// Page and Pages merge: a plugin may use either or both.
Pages []*UIPage `json:"pages,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"`
@ -683,7 +691,7 @@ func readPluginUI(L *golua.State) (*UIExtension, bool) {
return nil, false
}
L.SetTop(0)
if ui.Page == nil && len(ui.Elements) == 0 {
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
return &ui, true
@ -718,15 +726,40 @@ func (ps *Plugins) rebuild() {
ps.stageFuncs = stageFuncs
merged := &UIExtension{}
seenPage := map[string]bool{}
for _, p := range ps.plugins {
if p.LoadError != "" || p.Disabled || p.UI == nil {
continue
}
// The single `page` field is folded into the same list as `pages`.
// Assigning it to its own slot meant the LAST plugin to declare a page
// silently replaced every earlier one — a second plugin contributing a
// page erased the first from the sidebar with no error. One list, one
// rule: first writer wins per page_id (a duplicate id would collide in
// the DOM, and the plugin that loaded first is the better answer than
// whichever happened to load last).
var declared []*UIPage
if p.UI.Page != nil {
merged.Page = p.UI.Page
declared = append(declared, p.UI.Page)
}
declared = append(declared, p.UI.Pages...)
for _, pg := range declared {
if pg == nil || seenPage[pg.PageID] {
continue
}
seenPage[pg.PageID] = true
merged.Pages = append(merged.Pages, pg)
}
merged.Elements = append(merged.Elements, p.UI.Elements...)
}
// Stable order by declared Order then page id, so the sidebar does not
// reshuffle when a plugin is reloaded.
sort.SliceStable(merged.Pages, func(i, j int) bool {
if merged.Pages[i].Order != merged.Pages[j].Order {
return merged.Pages[i].Order < merged.Pages[j].Order
}
return merged.Pages[i].PageID < merged.Pages[j].PageID
})
ps.ui.Store(merged)
}
@ -839,6 +872,24 @@ func (ps *Plugins) List() []map[string]interface{} {
if p.UI.Page != nil {
ui["page"] = p.UI.Page.PageID
}
if len(p.UI.Pages) > 0 {
// Every contributed page, not just the first. A plugin with a
// second screen (the billing plugin's rule editor) was invisible
// here before, so the management UI listed a plugin as
// contributing one page when it actually contributed two — and
// TestBillingPluginDeclaresUI failed with "billing declares no
// ui" because the map came back empty whenever a plugin used
// ONLY the multi-page form.
ids := make([]string, 0, len(p.UI.Pages))
for _, pg := range p.UI.Pages {
if pg != nil && pg.PageID != "" {
ids = append(ids, pg.PageID)
}
}
if len(ids) > 0 {
ui["pages"] = ids
}
}
if len(p.UI.Elements) > 0 {
ui["elements"] = len(p.UI.Elements)
}
@ -1451,6 +1502,21 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{})
return nil, fmt.Errorf("plugin table missing")
}
plug := L.GetTop() // absolute, so nothing below shifts
// The hook is called THROUGH a pcall guard (see hookGuardSrc) so a script
// error returns as values instead of raising into golua, whose error path
// SIGSEGVs the process. Desired stack before the payload push:
// [pluginGlobal, guard, hookfn]. Build it in that order — GetGlobal(guard)
// then GetField(hookfn) lands the function exactly above the guard, with no
// Remove/Insert juggling. (The first version reordered with Remove/Insert
// and ended up calling pluginGlobal as if it were the hook, producing
// "attempt to call a table value" and silently zeroed every total.)
L.GetGlobal(hookGuardName)
if L.IsNil(-1) {
// Guard absent (a state built before this existed): drop the nil so the
// stack is [pluginGlobal, hookfn] and the hook is called directly.
// Correct plugins still work; only their errors stop being survivable.
L.Pop(1)
}
L.GetField(plug, fn)
if !L.IsFunction(-1) {
L.SetTop(0)
@ -1468,13 +1534,31 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{})
// by type-switching, and falls back to JSON only for a type it does not
// model, so an exotic payload still arrives instead of vanishing.
pushGoValue(L, plainForLua(payload))
// 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 {
if err := L.Call(2, 2); err != nil {
return nil, err
}
// The guard returns TWO values: the hook's value, then an error string
// (nil on success). Drop the error slot unconditionally so the value is
// left on top. Peeking instead of popping was the first bug here: on a
// SUCCESSFUL call the top is nil, so "the error is non-nil" is false, the
// pop was skipped, and the code then read the (always-present) nil error as
// if it were the hook's answer. Every plugin's returned table silently
// became "no opinion" while the tests that only checked totals kept
// passing.
errMsg := ""
if L.GetTop() >= 2 {
if !L.IsNil(-1) {
errMsg = L.ToString(-1)
}
L.Pop(1) // the error slot, nil or not
}
if errMsg != "" {
// A hook that raised partway may already have mutated plugin.state, so
// mark it dirty before bailing: the accounting it managed to do is
// still real and should be persisted.
ps.markDirtyLocked(p)
return nil, fmt.Errorf("plugin %s: %s", p.Info.Name, errMsg)
}
// Read the return value FIRST, then snapshot state for persistence.
//
// The order is load-bearing. markDirtyLocked walks the Lua tables and resets

View File

@ -143,6 +143,23 @@ local function bucket(tbl, k)
return b
end
-- dayMap returns the plain per-day SUB-TABLE of a nested dimension map, e.g.
-- by_day_src[day] -> { source -> bucket }.
--
-- bucket() cannot be reused for this level. It returns a BUCKET, so the
-- per-day container would come back carrying cost/requests/prompt_tokens keys
-- of its own, with the real source entries mixed in beside them. The period
-- view reads by_day_src[day] as "name -> bucket" and would then fold the
-- container's own fields as if they were sources.
local function dayMap(tbl, dk)
local m = tbl[dk]
if m == nil then
m = {}
tbl[dk] = m
end
return m
end
local function add(b, cost, prompt, completion, ok, cacheHit, cacheReported)
b.cost = b.cost + cost
b.requests = b.requests + 1
@ -469,13 +486,13 @@ function plugin.on_request_end(payload)
local dk = dayKey(ts)
add(bucket(s.by_day, dk), cost, prompt, completion, ok, C, R)
if payload.source ~= nil and payload.source ~= "" then
add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), cost, prompt, completion, ok, C, R)
add(bucket(dayMap(s.by_day_src, dk), payload.source), cost, prompt, completion, ok, C, R)
end
if payload.model ~= nil and payload.model ~= "" then
add(bucket(bucket(bucket(s.by_day_model, dk), payload.model)), cost, prompt, completion, ok, C, R)
add(bucket(dayMap(s.by_day_model, dk), payload.model), cost, prompt, completion, ok, C, R)
end
if payload.key ~= nil and payload.key ~= "" then
add(bucket(bucket(bucket(s.by_day_key, dk), payload.key)), cost, prompt, completion, ok, C, R)
add(bucket(dayMap(s.by_day_key, dk), payload.key), cost, prompt, completion, ok, C, R)
end
end
return nil -- last stage: nobody downstream would read a return value
@ -899,4 +916,521 @@ plugin.ui = {
},
}
-- 第二页:计费规则编辑。
--
-- 与统计页分开,而不是塞进同一页的标签里:规则是可编辑的配置,统计是只读的
-- 结果,混在一个页面里会让"改数字"和"看数字"的边界变模糊。
-- pages is built by APPENDING. Writing plugin.ui.pages[2] instead makes the
-- table sparse (index 2 with no 1, 2), and Lua's tojson/JSON conversion then
-- emits an OBJECT {"2": {...}} instead of an array — which the Go side decodes
-- to nothing at all. The plugin then loaded with UI == nil and no error
-- anywhere: the totals page, the status tile and this editor all silently
-- vanished. First-wins on page_id is enforced Go-side, so appending is safe.
plugin.ui.pages = plugin.ui.pages or {}
table.insert(plugin.ui.pages, {
page_id = "billing-rules",
title = "Billing rules",
icon = [==[<svg viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 12h4M12 12h8M4 17h8M16 17h4"/><path d="M14 5l2 2-2 2M10 10l-2 2 2 2M12 15l2 2-2 2"/></svg>]==],
order = 41,
mount = [==[
<div id="br-root" style="padding:16px;min-width:0;max-width:100%">
<div id="br-body"><div class="muted">…</div></div>
</div>
<script>
(function () {
var API = "/api/plugins/billing/rules";
var STR = {
en: {
title: "Billing rules", profile: "Profile", newProfile: "New profile",
addRule: "Add rule", delRule: "Delete", exportYaml: "Export YAML",
url: "Source URL", mode: "Mode", prompt: "Prompt $/M",
completion: "Completion $/M", currency: "Currency",
monthly: "Monthly fee", peakMult: "Peak multiplier",
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
models: "Per-model prices", addModel: "+ model",
mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
noProfile: "No profile yet — create one to start pricing.",
noRules: "No rules in this profile",
warnNoSource: "matches no configured source",
active: "ACTIVE", setActive: "Set active",
saving: "saving…", saved: "Saved", rejected: "Rejected", save: "Save",
newProfileId: "New profile id",
needUrl: "a rule needs a URL", needPrices: "every model needs both prices",
badPeakHours: "peak hours look like 1-4,6-10 (UTC, 0-23)",
badPeakDays: "peak days are UTC weekday numbers 0-6",
peakNeedsMult: "peak hours need a multiplier",
adminOnly: "Billing rules are admin-only.",
activePrices: "Prices now in effect",
},
zh: {
title: "计费规则", profile: "方案", newProfile: "新建方案",
addRule: "新增规则", delRule: "删除", exportYaml: "导出 YAML",
url: "源 URL", mode: "计费模式", prompt: "输入 $/M",
completion: "输出 $/M", currency: "币种",
monthly: "月费", peakMult: "峰段倍数",
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
models: "按模型价格", addModel: "+ 模型",
mFree: "free 免费", mToken: "token 按量",
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
noProfile: "还没有方案 —— 新建一个开始定价。",
noRules: "该方案暂无规则",
warnNoSource: "匹配不到已配置的源",
active: "生效中", setActive: "设为生效",
saving: "保存中…", saved: "已保存", rejected: "被拒绝", save: "保存",
newProfileId: "新方案 id",
needUrl: "规则必须有 URL",
needPrices: "每个模型都要填输入和输出价格",
badPeakHours: "峰段小时应形如 1-4,6-10(UTC,0-23)",
badPeakDays: "峰段星期应为 UTC 星期编号 0-6",
peakNeedsMult: "填了峰段小时就要同时填倍数",
adminOnly: "计费规则仅限管理员。",
activePrices: "当前生效的价格",
},
};
function L() {
var lang = (window.pluginAPI && pluginAPI.lang) === "en" ? "en" : "zh";
return STR[lang] || STR.zh;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
function call(method, body) {
return fetch(API, {
method: method,
credentials: "same-origin",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (j) {
if (!r.ok) throw new Error((j.error && j.error.message) || ("HTTP " + r.status));
return j;
});
});
}
var DATA = null;
// ---- peak window parsing -------------------------------------------------
//
// Parsed here as well as server-side so the operator is told WHICH row is
// wrong instead of getting a 400 that names a field. A malformed peak window
// that is silently dropped would bill peak traffic at off-peak rates, which
// is the failure this whole page exists to make impossible.
function parseHours(txt) {
txt = String(txt || "").trim();
if (!txt) return [];
var out = [];
var parts = txt.split(",");
for (var i = 0; i < parts.length; i++) {
var m = String(parts[i]).trim().match(/^(\d{1,2})\s*-\s*(\d{1,2})$/);
if (!m) return null;
var lo = parseInt(m[1], 10), hi = parseInt(m[2], 10);
if (lo < 0 || hi > 23 || lo > hi) return null;
out.push([lo, hi]);
}
return out;
}
function parseDays(txt) {
txt = String(txt || "").trim();
if (!txt) return [];
var out = [];
var parts = txt.split(",");
for (var i = 0; i < parts.length; i++) {
var n = parseInt(String(parts[i]).trim(), 10);
if (isNaN(n) || n < 0 || n > 6) return null;
out.push(n);
}
return out;
}
function hoursText(p) {
if (!p || !p.hours) return "";
return p.hours.map(function (h) { return h[0] + "-" + h[1]; }).join(",");
}
function daysText(p) {
if (!p || !p.weekdays || !p.weekdays.length) return "";
return p.weekdays.join(",");
}
// ---- row rendering -------------------------------------------------------
function modelBlock(models, T) {
var keys = Object.keys(models || {});
var rows = keys.map(function (m) {
var t = models[m] || {};
return "<div class='row' style='gap:6px;margin:3px 0'>" +
"<input class='m-name' value='" + esc(m) + "' style='flex:1;min-width:80px'>" +
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
"' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
"' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "'>&times;</button></div>";
}).join("");
return "<div class='m-rows'>" + rows + "</div>" +
"<button class='ghost small m-add'>" + esc(T.addModel) + "</button>";
}
function ruleRow(rule, known, T) {
var unknown = rule.url !== "*" && known.indexOf(rule.url) < 0;
var opts = [["free", T.mFree], ["token", T.mToken], ["subscription", T.mSub], ["unpriced", T.mUnpriced]]
.map(function (o) {
return "<option value='" + o[0] + "'" + (rule.mode === o[0] ? " selected" : "") + ">" + esc(o[1]) + "</option>";
}).join("");
var p = rule.peak;
return "<tr class='br-rule' data-url='" + esc(rule.url || "") + "'>" +
"<td><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%'>" +
(unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") +
"</td>" +
"<td><select class='r-mode'>" + opts + "</select></td>" +
"<td style='width:74px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD'></td>" +
"<td style='width:92px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "'></td>" +
"<td style='width:64px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "'></td>" +
"<td style='width:104px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10'></td>" +
"<td style='width:74px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3'></td>" +
"<td style='min-width:210px'>" + modelBlock(rule.models, T) + "</td>" +
"<td><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
}
// ---- collecting one rule back out of the DOM ----------------------------
function collectRule(tr, T) {
var url = tr.querySelector(".r-url").value.trim();
if (!url) throw new Error(T.needUrl);
var mode = tr.querySelector(".r-mode").value;
var out = { url: url, mode: mode };
var cur = tr.querySelector(".r-cur").value.trim();
if (cur) out.currency = cur;
if (mode === "subscription") {
var sub = parseFloat(tr.querySelector(".r-sub").value);
if (!isNaN(sub)) out.subscription = sub;
}
var hoursTxt = tr.querySelector(".r-hours").value.trim();
var daysTxt = tr.querySelector(".r-days").value.trim();
var mult = parseFloat(tr.querySelector(".r-mult").value);
if (hoursTxt || daysTxt || (!isNaN(mult) && mult > 0)) {
if (isNaN(mult) || mult <= 0) throw new Error(T.peakNeedsMult);
var hours = parseHours(hoursTxt);
var days = parseDays(daysTxt);
if (hours === null) throw new Error(T.badPeakHours);
if (days === null) throw new Error(T.badPeakDays);
out.peak = { multiplier: mult, weekdays: days, hours: hours };
}
if (mode === "token") {
var models = {};
var bad = null;
tr.querySelectorAll(".m-rows .row").forEach(function (r) {
if (bad) return;
var n = r.querySelector(".m-name").value.trim();
if (!n) return;
var pv = r.querySelector(".m-p").value.trim();
var cv = r.querySelector(".m-c").value.trim();
// Strings, not floats: the price must round-trip through YAML and the
// config file unchanged. 0.15 stored as a float can serialize as
// 0.15000000000000002 and the operator would not recognize their own
// price on the next visit.
if (!pv || !cv || isNaN(parseFloat(pv)) || isNaN(parseFloat(cv))) {
bad = n;
return;
}
models[n] = { prompt: pv, completion: cv };
});
if (bad) throw new Error(T.needPrices + ": " + bad);
if (Object.keys(models).length) out.models = models;
}
return out;
}
function collectProfile(pEl, T) {
var rules = [];
pEl.querySelectorAll("tr.br-rule").forEach(function (tr) {
rules.push(collectRule(tr, T));
});
return rules;
}
// ---- YAML export ---------------------------------------------------------
//
// Hand-rolled because the export target is a config.yaml the operator will
// paste into (or diff against) the live file. Only the shapes this page owns
// are emitted: maps, string/number scalars, and arrays of maps.
function yamlValue(v, indent) {
var pad = new Array(indent + 1).join(" ");
if (Array.isArray(v)) {
if (!v.length) return "";
var out = [pad + " []"];
var body = [];
v.forEach(function (item) {
if (item && typeof item === "object") {
var keys = Object.keys(item);
keys.forEach(function (k, idx) {
var val = item[k];
var prefix = idx === 0 ? pad + " - " : pad + " ";
if (val && typeof val === "object") {
body.push(prefix + k + ":");
body.push(yamlValue(val, indent + 3));
} else {
body.push(prefix + k + ": " + scalar(val));
}
});
} else {
body.push(pad + " - " + scalar(item));
}
});
return body.filter(Boolean).join("\n");
}
if (v && typeof v === "object") {
var keys = Object.keys(v);
if (!keys.length) return pad + " {}";
var res = [];
keys.forEach(function (k) {
var val = v[k];
if (val === undefined || val === null) return;
if (val && typeof val === "object") {
res.push(pad + k + ":");
res.push(yamlValue(val, indent + 1));
} else {
res.push(pad + k + ": " + scalar(val));
}
});
return res.filter(Boolean).join("\n");
}
return pad + scalar(v);
}
function scalar(v) {
if (typeof v === "number") return String(v);
if (typeof v === "boolean") return v ? "true" : "false";
// Quote every string: prices are strings by contract, but quoting the
// numeric-looking ones too means a hand-edited file can never turn "0.15"
// into 0.15 and lose the exact representation on the next round-trip.
return JSON.stringify(String(v));
}
function exportText() {
var dsl = { profiles: [] };
var body = document.getElementById("br-body");
var T = L();
body.querySelectorAll("[data-profile]").forEach(function (pEl) {
var id = pEl.dataset.profile;
var rules = collectProfile(pEl, T);
dsl.profiles.push({ id: id, rules: rules });
});
dsl.active = DATA && DATA.active ? DATA.active : (dsl.profiles[0] || {}).id;
return "billing:\n" + yamlValue(dsl, 1) + "\n";
}
// ---- render --------------------------------------------------------------
function say(msg, err) {
var el = document.getElementById("br-msg");
if (!el) return;
el.textContent = msg || "";
el.style.color = err ? "#e07a7a" : "rgba(200,180,220,.75)";
}
function render() {
var T = L();
var body = document.getElementById("br-body");
if (!body) return;
var dsl = (DATA && DATA.billing) || null;
var profiles = (dsl && dsl.profiles) || [];
var active = (DATA && DATA.active) || (profiles[0] && profiles[0].id) || "";
var known = (DATA && DATA.urls) || [];
var warn = "";
if (DATA && DATA.warnings && DATA.warnings.length) {
warn = "<div style='color:#e0a33e;font-size:12px;margin:0 0 10px'>&#9888; " +
esc(DATA.warnings.join(" &middot; ")) + "</div>";
}
var dl = known.map(function (u) { return "<option value='" + esc(u) + "'>"; }).join("");
if (!profiles.length) {
body.innerHTML = warn + "<div class='muted' style='padding:10px 0'>" + esc(T.noProfile) + "</div>" +
"<p><button class='ghost' data-act='newprofile'>+ " + esc(T.newProfile) + "</button></p>";
wire();
return;
}
var html = warn + "<datalist id='br-urls'>" + dl + "</datalist>";
profiles.forEach(function (p) {
var rules = p.rules || [];
html += "<div data-profile='" + esc(p.id) + "' style='margin:0 0 18px'>" +
"<div class='row' style='align-items:center;gap:8px;margin:0 0 6px'>" +
"<strong style='font-size:14px'>" + esc(p.id) + "</strong>" +
(p.id === active ? "<span class='okc' style='font-size:11px'>" + esc(T.active) + "</span>"
: "<button class='ghost small p-active' data-pid='" + esc(p.id) + "'>" + esc(T.setActive) + "</button>") +
"<span class='grow'></span>" +
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
"</div>" +
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;min-width:900px'>" +
"<tr style='text-align:left;opacity:.65'>" +
"<th>" + esc(T.url) + "</th><th>" + esc(T.mode) + "</th><th>" + esc(T.currency) + "</th>" +
"<th>" + esc(T.monthly) + "</th><th>" + esc(T.peakMult) + "</th><th>" + esc(T.peakHours) + "</th>" +
"<th>" + esc(T.peakDays) + "</th><th>" + esc(T.models) + "</th><th></th></tr>" +
(rules.length
? rules.map(function (r) { return ruleRow(r, known, T); }).join("")
: "<tr><td colspan='9' class='muted' style='padding:8px 0'>" + esc(T.noRules) + "</td></tr>") +
"</table></div></div>";
});
html += "<p style='margin:6px 0 0'>" +
"<button class='ghost' data-act='newprofile'>+ " + esc(T.newProfile) + "</button> " +
"<button class='ghost' data-act='export'>" + esc(T.exportYaml) + "</button> " +
"<button data-act='save'>" + esc(T.save) + "</button></p>";
body.innerHTML = html;
wire();
}
function wire() {
var body = document.getElementById("br-body");
if (!body) return;
body.querySelectorAll(".m-add").forEach(function (b) {
b.onclick = function () {
var box = this.closest("td").querySelector(".m-rows");
var T = L();
var row = document.createElement("div");
row.className = "row";
row.style.cssText = "gap:6px;margin:3px 0";
row.innerHTML =
"<input class='m-name' placeholder='model' style='flex:1;min-width:80px'>" +
"<input class='m-p' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"<input class='m-c' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<button class='ghost small m-del'>&times;</button>";
box.appendChild(row);
};
});
body.querySelectorAll(".m-del").forEach(function (b) {
b.onclick = function () { this.closest(".row").remove(); };
});
body.querySelectorAll(".r-del").forEach(function (b) {
b.onclick = function () {
var tr = this.closest("tr");
var pEl = tr.closest("[data-profile]");
var dsl = collectAll(pEl);
var idx = -1;
for (var i = 0; i < dsl.profiles.length; i++) {
if (dsl.profiles[i].id === pEl.dataset.profile) idx = i;
}
if (idx < 0) return;
dsl.profiles[idx].rules = dsl.profiles[idx].rules.filter(function (r) {
return r.url !== tr.dataset.url;
});
save(dsl);
};
});
body.querySelectorAll(".p-add").forEach(function (b) {
b.onclick = function () {
var pEl = b.closest("[data-profile]");
var dsl = collectAll(null);
var idx = -1;
for (var i = 0; i < dsl.profiles.length; i++) {
if (dsl.profiles[i].id === b.dataset.pid) idx = i;
}
if (idx < 0) return;
// Seed with the first configured URL: an empty url row would fail
// validation on save and the operator would not know why.
var first = ((DATA && DATA.urls) || [])[0] || "";
dsl.profiles[idx].rules.push({ url: first, mode: "free" });
save(dsl);
};
});
body.querySelectorAll(".p-active").forEach(function (b) {
b.onclick = function () {
var dsl = collectAll(null);
dsl.active = b.dataset.pid;
save(dsl);
};
});
var np = body.querySelector("[data-act='newprofile']");
if (np) np.onclick = newProfile;
var ex = body.querySelector("[data-act='export']");
if (ex) ex.onclick = doExport;
var sv = body.querySelector("[data-act='save']");
if (sv) sv.onclick = function () { save(collectAll(null)); };
}
function collectAll() {
var T = L();
var body = document.getElementById("br-body");
var dsl = { profiles: [] };
body.querySelectorAll("[data-profile]").forEach(function (pEl) {
dsl.profiles.push({ id: pEl.dataset.profile, rules: collectProfile(pEl, T) });
});
dsl.active = (DATA && DATA.active) || (dsl.profiles[0] || {}).id || "";
return dsl;
}
function save(dsl) {
var T = L();
say(T.saving);
call("PUT", { billing: dsl })
.then(function (j) {
DATA.billing = j.billing;
DATA.active = j.active;
DATA.warnings = j.warnings || [];
say(T.saved);
render();
// The billing page's totals come from these prices; refresh it so the
// operator sees the effect of the edit without hunting for the tab.
if (window.__billingRefresh) window.__billingRefresh();
})
.catch(function (e) { say(T.rejected + ": " + e.message, true); });
}
function newProfile() {
var dsl = collectAll();
var n = dsl.profiles.length + 1;
var id = "p" + n;
while (dsl.profiles.some(function (p) { return p.id === id; })) {
n++;
id = "p" + n;
}
dsl.profiles.push({ id: id, rules: [] });
if (!dsl.active) dsl.active = id;
save(dsl);
}
function doExport() {
var text = exportText();
var box = document.createElement("pre");
box.className = "configbox";
box.style.cssText = "margin-top:10px;max-height:46vh;overflow:auto;white-space:pre";
box.textContent = text;
var holder = document.getElementById("br-body");
var old = document.getElementById("br-export");
if (old) old.remove();
box.id = "br-export";
holder.appendChild(box);
var T = L();
var p = document.createElement("p");
p.style.margin = "6px 0 0";
p.innerHTML = "<button class='ghost small' id='br-dl'>" + esc(T.exportYaml) + " (.yaml)</button>";
holder.appendChild(p);
document.getElementById("br-dl").onclick = function () {
var blob = new Blob([text], { type: "text/yaml" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "billing-rules.yaml";
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
};
}
function load() {
call("GET")
.then(function (j) {
DATA = { billing: j.billing || null, active: j.active, urls: j.urls || [], warnings: j.warnings || [] };
render();
})
.catch(function (e) {
var body = document.getElementById("br-body");
if (body) body.innerHTML = "<div class='muted'>" + esc(e.message) + "</div>";
});
}
render();
load();
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(load);
})();
</script>
]==],
})
return plugin

View File

@ -233,14 +233,18 @@ return p
if err := loadPlugin(t, ps, "ui", code); err != nil {
t.Fatalf("load: %v", err)
}
// Every contributed page — including the single `page` field — is folded
// into one merged list. Reading ui.Page here would silently pass on a
// payload whose pages were all dropped.
ui := ps.UI()
if ui.Page == nil {
t.Fatal("no page contributed")
if len(ui.Pages) != 1 {
t.Fatalf("expected 1 merged page, got %d", len(ui.Pages))
}
if ui.Page.PageID != "billing" || ui.Page.Title != "Billing" {
t.Errorf("page = %+v", ui.Page)
pg := ui.Pages[0]
if pg.PageID != "billing" || pg.Title != "Billing" {
t.Errorf("page = %+v", pg)
}
if !strings.Contains(ui.Page.Mount, "console.log") {
if !strings.Contains(pg.Mount, "console.log") {
t.Error("mount lost its script content")
}
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {

View File

@ -993,9 +993,47 @@ func restorePcall(L *golua.State) {
}
}
// hookGuardName is the Lua global that wraps every plugin hook call.
const hookGuardName = "__llmsproxy_call_hook"
// hookGuardSrc defines the wrapper. It exists because of a hard constraint in
// the binding: golua's callEx calls L.StackTrace() on ANY pcall error, and
// StackTrace() calls lua_getinfo, which SIGSEGVs in this LuaJIT build once the
// stack is deep enough (a request_end payload with the AUTO chain trace does
// it). Go cannot recover from a C-level signal, so a single Lua mistake inside
// one plugin killed the whole gateway — the production outage this was written
// for. Repeated crashes proved the boundary is not theoretical.
//
// pcall INSIDE Lua catches the error before golua ever sees a non-zero
// pcall status, so the C stack-trace path is never entered. The failure comes
// back as an ordinary (nil, message) pair, which the Go side records in
// hook_errors and moves on from — the documented contract that "a broken plugin
// must not affect request forwarding" finally holds for script errors too, not
// just for Go panics.
//
// returns: (result, errorMessage) — both nil/"" on success.
const hookGuardSrc = `
function ` + hookGuardName + `(fn, payload)
local ok, res = pcall(fn, payload)
if not ok then
return nil, tostring(res)
end
return res, nil
end`
func registerHookGuard(L *golua.State) {
if err := L.DoString(hookGuardSrc); err != nil {
// Nothing useful to do here beyond leaving the global absent: invoke()
// checks for it and falls back to a direct call, which still works for
// correct plugins (only their ERRORS stop being survivable).
L.Pop(1)
}
}
func setupGlobals(L *golua.State) {
restorePcall(L)
buildJSONTable(L)
registerHookGuard(L)
registerFn(L, "hmac_sha256_hex", func(L *golua.State) int {
key := L.ToString(1)