mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -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 {
|
||||
|
||||
@ -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 ---
|
||||
//
|
||||
|
||||
@ -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) {
|
||||
|
||||
76
internal/lua/hook_guard_test.go
Normal file
76
internal/lua/hook_guard_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 { "&": "&", "<": "<", ">": ">", '"': """ }[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) + "'>×</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'>⚠ " + 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'>⚠ " +
|
||||
esc(DATA.warnings.join(" · ")) + "</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'>×</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
|
||||
@ -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" {
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user