From a2bb6de9fb52c582509e1c5c425bbbac92813173 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 07:43:56 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat(plugins):=20=E7=BB=99=20Lua=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=8A=A0=E5=8F=97=E9=99=90=E5=87=BA=E7=AB=99?= =?UTF-8?q?=20HTTP=EF=BC=88=5F=5Fllmsproxy=5Fhttp=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit billing 插件从不主动出站——它报告的一切都随网关转发请求时的 hook payload 一起到达。额度插件是第一个必须自己发请求的东西:套餐余量不主动问就拿不到。 问题在于这恰好是插件模型最锋利的边。插件是 WebUI 文本框里装进来的单个 .lua 文件,跑在网关进程里:给它一个通用 HTTP 客户端,等于让每次安装都成为 一件常备的 SSFP 工具,还顺带打开了读环境变量拿凭据的第二扇门。 所以这个桥刻意做窄: - 目标必须命中某个「已配置源」已经在通信的主机。那些是运维声明过的, 不是插件声明的——插件无法靠编一个 URL 扩大自己的触及面,而源的主机 正是本部署已经愿意交付 API key 的那批上游。 - 非 loopback 主机上拒绝明文 http。凭据只该走 HTTPS。 - loopback 免登记:它出不了本机,而 billing 规则页读网关自己的 API 正是 这么做的。 - 每请求都有超时、响应体有 4MiB 上限,插件无法占死 worker 或吃光内存。 - 绝不从环境变量读凭据。需要 key 的插件必须被显式给(state 或调用方配置), 这样授权对运维可见,而不是环境里默默存在的东西。 - 不跟随重定向:白名单是逐跳检查的,跟随就等于绕过它,3xx 原样返回。 能力只装在插件运行时(bootPluginState),不装进 setupGlobals——后者被 adapter 池共用,adapter 是运维的配置而非安装的代码。 判据 8 条钉的是拒绝路径而非快乐路径:未登记主机(含 169.254.169.254、 后缀欺骗 api.example.com.evil.test)、明文 http、空名单必须全拒、不得跟随 重定向、超时与响应体被钳制、超大响应宁可报错也不截断(截断会解析成损坏 JSON,插件会报解析错而不是真因)。 测试当场抓到一个真 bug:loopback 被白名单一起拦了,与代码注释里「loopback 免登记」自相矛盾。变异(把主机检查换成 if false)被 3 条判据抓住。 Co-Authored-By: ModelRouter (cherry picked from commit 0fbb8e5e88ba0f0e73ee5bff56193e6b96326f15) (cherry picked from commit e76e1623e466ad647aa0b14f7fe967b14fad685f) --- internal/core/core.go | 31 ++++ internal/lua/plugin_http.go | 248 +++++++++++++++++++++++++++++++ internal/lua/plugin_http_args.go | 111 ++++++++++++++ internal/lua/plugin_http_test.go | 151 +++++++++++++++++++ internal/lua/plugins.go | 40 ++++- 5 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 internal/lua/plugin_http.go create mode 100644 internal/lua/plugin_http_args.go create mode 100644 internal/lua/plugin_http_test.go diff --git a/internal/core/core.go b/internal/core/core.go index de8b0ee..c1c3c65 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "sort" + "net/url" "strings" "sync" "sync/atomic" @@ -68,6 +69,10 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { // to boot the gateway because a third-party .lua has a typo would be the // wrong trade. c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir) + // Plugin outbound HTTP is scoped to the hosts of configured sources. Set + // before LoadDir so a plugin that requests on its first hook already sees + // the real allowlist rather than the deny-all placeholder. + c.plugins.SetOutboundHosts(sourceHosts(cfg)) if err := c.plugins.SeedBundled(); err != nil { log.Printf("[core] seed bundled plugins: %v", err) } @@ -615,9 +620,35 @@ func (c *Core) rebuildRegistry() error { c.buildAutoChain() c.buildAutoImageChain() c.rebuildKeyAutoChains() + // A source edit can add or remove an upstream host, which changes what a + // plugin is allowed to reach. Keeping this beside the chain rebuilds means + // one call site owns "config changed, refresh derived state". + c.plugins.SetOutboundHosts(sourceHosts(c.cfg)) return nil } +// sourceHosts extracts the host of every configured source's base_url — the +// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than +// failing startup: a malformed base_url is already rejected when the provider is +// built, and it must not take plugins down with it. +func sourceHosts(cfg *config.Config) []string { + if cfg == nil { + return nil + } + out := make([]string, 0, len(cfg.Sources)) + for _, s := range cfg.Sources { + if s.BaseURL == "" { + continue + } + u, err := url.Parse(s.BaseURL) + if err != nil || u.Hostname() == "" { + continue + } + out = append(out, u.Hostname()) + } + return out +} + // buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules // against the current providers. Slot model ids are normalized to the exact // configured spelling (case-insensitive match), otherwise ModelFor would fall diff --git a/internal/lua/plugin_http.go b/internal/lua/plugin_http.go new file mode 100644 index 0000000..167626d --- /dev/null +++ b/internal/lua/plugin_http.go @@ -0,0 +1,248 @@ +package lua + +// Outbound HTTP for plugins. +// +// The billing plugin never needed this: everything it reports arrives on a hook +// payload that the gateway produced while forwarding a request. A quota plugin +// is the first thing that must REACH OUT on its own — Command Code exposes +// /alpha/billing/credits and friends, and there is no way to learn a plan's +// remaining balance without asking. +// +// That capability is also the sharpest edge in the plugin model. A plugin is a +// single .lua file the WebUI installs from a textarea: untrusted, browser-facing +// code that runs in the gateway process. Handing it a general-purpose HTTP +// client would make every install a standing SSRF primitive against whatever +// the gateway can reach — the cloud metadata endpoint, the cluster control +// plane, whatever else sits on 127.0.0.1 in this deployment. Reading the +// environment for credentials is a second door out. +// +// So the bridge is deliberately narrow: +// +// - Destination must match a host that a CONFIGURED SOURCE already talks to. +// Those are operator-declared, not plugin-declared: a plugin cannot widen +// its own reach by inventing a URL, and an existing source's host is +// exactly the set of upstreams this deployment already trusts with API keys. +// - http:// on a non-loopback host is refused. Credentials belong in HTTPS. +// - Loopback needs no explicit opt-in: it is the only way a plugin can read +// the gateway's own API (the billing rules UI does exactly that), and it +// cannot leave the machine. +// - Every request is bounded by a timeout and the response is size-capped, so +// a plugin cannot wedge a worker goroutine or exhaust memory. +// - Credentials are NEVER read from the environment here. A plugin that needs +// a key must be given it (state, or the caller's own config), which keeps +// the grant visible to the operator instead of silently ambient. +// +// Redirects are NOT followed: the allowlist is checked per hop, and a redirect +// to an unlisted host would otherwise walk straight past it. A 3xx is returned +// as-is so the plugin can see it. + +import ( + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + golua "github.com/aarzilli/golua/lua" +) + +// httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body +// a plugin can pull into the gateway's heap. +const ( + httpDefaultTimeout = 15 * time.Second + httpMaxTimeout = 60 * time.Second + httpMaxResponse = 4 << 20 // 4 MiB +) + +// httpClient is shared by every plugin request. It deliberately has no +// ProxyFromEnvironment: an operator who proxies upstreams configures the proxy +// per source, and a plugin request should not silently inherit one. +var httpClient = &http.Client{ + Timeout: httpDefaultTimeout, + Transport: &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 16, + IdleConnTimeout: 60 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + }, + // See above: a redirect is a hop the allowlist has not seen. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, +} + +// httpGlobal is the Lua name of the outbound bridge. +const httpGlobal = "__llmsproxy_http" + +// httpAllowlist is the set of hosts plugins may contact: the host of every +// configured source's base_url. +type httpAllowlist struct { + hosts map[string]bool +} + +func newHTTPAllowlist(hosts []string) *httpAllowlist { + m := make(map[string]bool, len(hosts)) + for _, h := range hosts { + if h != "" { + m[strings.ToLower(h)] = true + } + } + return &httpAllowlist{hosts: m} +} + +// allows reports whether host:port may be contacted. Ports 80/443 are implied +// by the bare host so a source configured as https://api.example.com/v1 does not +// have to spell the port. +func (a *httpAllowlist) allows(host string, port string) bool { + host = strings.ToLower(host) + if host == "" { + return false + } + if a.hosts[host] { + return true + } + switch port { + case "", "80", "443": + return a.hosts[host] + } + return a.hosts[net.JoinHostPort(host, port)] +} + +// checkURL validates a plugin-supplied URL against the allowlist. It returns +// the parsed URL plus a reason when it is refused. +func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return nil, fmt.Errorf("bad url: %v", err) + } + switch strings.ToLower(u.Scheme) { + case "https": + case "http": + // Plain HTTP is only tolerated on loopback, where it stays on this + // machine and is how the gateway's own plain-HTTP dev port is reached. + if !isLoopbackHost(u.Hostname()) { + return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname()) + } + default: + return nil, fmt.Errorf("unsupported scheme %q", u.Scheme) + } + if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) { + // Loopback needs no entry: it cannot leave the machine, and the billing + // rules UI reads the gateway's own API over plain HTTP in dev setups. + return nil, fmt.Errorf("host %q is not an allowed outbound destination "+ + "(allowed: the hosts of configured sources, plus loopback)", u.Hostname()) + } + return u, nil +} + +func isLoopbackHost(h string) bool { + if h == "localhost" { + return true + } + ip := net.ParseIP(strings.Trim(h, "[]")) + return ip != nil && ip.IsLoopback() +} + +// httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http. +type httpRequestSpec struct { + URL string + Method string + Headers map[string]string + Body string + Timeout time.Duration +} + +// luaHTTPFn builds the golua closure. It takes one Lua table argument and +// returns (responseTable, nil) or (nil, errorMessage) — never raises, so a +// plugin mistake cannot take the gateway with it. +func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction { + return func(L *golua.State) int { + spec, err := readHTTPRequestSpec(L) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + u, err := allow.checkURL(spec.URL) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + resp, err := performHTTP(u, spec) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + pushHTTPResponse(L, resp) + L.PushNil() + return 2 + } +} + +func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) { + method := strings.ToUpper(spec.Method) + if method == "" { + method = http.MethodGet + } + timeout := spec.Timeout + if timeout <= 0 || timeout > httpMaxTimeout { + timeout = httpDefaultTimeout + } + + var body io.Reader + if spec.Body != "" { + body = strings.NewReader(spec.Body) + } + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("build request: %v", err) + } + for k, v := range spec.Headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "ModelRouter-plugin") + } + + client := *httpClient + client.Timeout = timeout + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1)) + if err != nil { + return nil, fmt.Errorf("read body: %v", err) + } + if len(data) > httpMaxResponse { + return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse) + } + return &httpResult{ + Status: resp.StatusCode, + Headers: flattenHeaders(resp.Header), + Body: string(data), + }, nil +} + +type httpResult struct { + Status int + Headers map[string]string + Body string +} + +func flattenHeaders(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for k := range h { + out[strings.ToLower(k)] = h.Get(k) + } + return out +} \ No newline at end of file diff --git a/internal/lua/plugin_http_args.go b/internal/lua/plugin_http_args.go new file mode 100644 index 0000000..7e4653c --- /dev/null +++ b/internal/lua/plugin_http_args.go @@ -0,0 +1,111 @@ +package lua + +import ( + "fmt" + "strings" + "time" + + golua "github.com/aarzilli/golua/lua" +) + +// readHTTPRequestSpec decodes the Lua argument table: +// +// __llmsproxy_http({ +// url = "https://host/path", +// method = "GET", -- optional, default GET +// headers = { ["X-A"] = "1" }, -- optional +// body = "...", -- optional +// timeout_ms = 8000, -- optional, clamped to 60s +// }) +// +// luaTableToGoValue already turns the table into a map/slice, so the whole +// argument is decoded in one pass instead of walking the table field by field. +func readHTTPRequestSpec(L *golua.State) (httpRequestSpec, error) { + var spec httpRequestSpec + if L.GetTop() < 1 { + return spec, fmt.Errorf("__llmsproxy_http: expected a table argument") + } + if L.Type(1) != golua.LUA_TTABLE { + return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s", + normalizeString(L, 1)) + } + arg, ok := luaTableToGoValue(L, 1).(map[string]interface{}) + if !ok { + return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table") + } + for k, v := range arg { + switch strings.ToLower(k) { + case "url", "uri", "endpoint": + spec.URL = asString(v) + case "method", "verb": + spec.Method = asString(v) + case "body", "data", "payload": + spec.Body = asString(v) + case "headers", "header": + if m, ok := v.(map[string]interface{}); ok { + spec.Headers = make(map[string]string, len(m)) + for hk, hv := range m { + spec.Headers[hk] = asString(hv) + } + } + case "timeout_ms", "timeoutms": + if n, ok := asFloat(v); ok { + spec.Timeout = time.Duration(n) * time.Millisecond + } + } + } + if spec.URL == "" { + return spec, fmt.Errorf("__llmsproxy_http: missing url") + } + return spec, nil +} + +// pushHTTPResponse converts the result into two Lua values: a table +// {status, headers, body} and nil. The caller pushes the error slot itself. +func pushHTTPResponse(L *golua.State, r *httpResult) { + L.NewTable() + + L.PushString("status") + L.PushNumber(float64(r.Status)) + L.SetTable(-3) + + L.PushString("headers") + pushStringMap(L, r.Headers) + L.SetTable(-3) + + L.PushString("body") + L.PushString(r.Body) + L.SetTable(-3) +} + +func pushStringMap(L *golua.State, m map[string]string) { + L.NewTable() + for k, v := range m { + L.PushString(v) + L.SetField(-2, k) + } +} + +func asString(v interface{}) string { + switch t := v.(type) { + case string: + return t + case nil: + return "" + default: + return fmt.Sprintf("%v", t) + } +} + +func asFloat(v interface{}) (float64, bool) { + switch t := v.(type) { + case float64: + return t, true + case int: + return float64(t), true + case int64: + return float64(t), true + default: + return 0, false + } +} \ No newline at end of file diff --git a/internal/lua/plugin_http_test.go b/internal/lua/plugin_http_test.go new file mode 100644 index 0000000..5e3919e --- /dev/null +++ b/internal/lua/plugin_http_test.go @@ -0,0 +1,151 @@ +package lua + +// The plugin HTTP bridge is the one capability that lets a .lua file — installed +// from a textarea in the WebUI — reach out on the gateway's own network. That +// makes its DENY rules the security boundary, and these tests exist to pin the +// refusals rather than the happy path. +// +// The failure this guards against is concrete: an operator installs a quota +// plugin that reads a vendor's balance, and a later plugin (or a compromised +// one) posts that gateway key material to an attacker, or reads the cloud +// metadata endpoint, because __llmsproxy_http happened to be a general HTTP +// client. + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + golua "github.com/aarzilli/golua/lua" +) + +// newTestState returns a bare Lua state for the argument-decoding tests. +func newTestState(t *testing.T) *golua.State { + t.Helper() + L := golua.NewState() + t.Cleanup(L.Close) + L.OpenLibs() + return L +} + +func TestPluginHTTPAllowlistRejectsUnlistedHost(t *testing.T) { + allow := newHTTPAllowlist([]string{"api.example.com"}) + for _, raw := range []string{ + "https://evil.test/steal", + "https://169.254.169.254/latest/meta-data/", + "https://api.example.com.evil.test/x", // suffix trick + "https://user@evil.test/", + } { + if _, err := allow.checkURL(raw); err == nil { + t.Fatalf("allowlist permitted %q — a plugin must not reach hosts "+ + "the operator never configured as a source", raw) + } + } +} + +func TestPluginHTTPAllowlistAcceptsConfiguredHost(t *testing.T) { + allow := newHTTPAllowlist([]string{"api.example.com"}) + for _, raw := range []string{ + "https://api.example.com/alpha/credits", + "https://API.EXAMPLE.COM/x", // host compare must be case-insensitive + "https://api.example.com:443/x", + } { + if _, err := allow.checkURL(raw); err != nil { + t.Fatalf("allowlist refused configured host %q: %v", raw, err) + } + } +} + +func TestPluginHTTPRefusesPlainHTTPOffLoopback(t *testing.T) { + allow := newHTTPAllowlist([]string{"api.example.com"}) + // Allowed host, but plain HTTP would put the API key on the wire in clear. + if _, err := allow.checkURL("http://api.example.com/alpha"); err == nil { + t.Fatal("plain http to an allowed non-loopback host must be refused") + } + // Loopback stays allowed: it cannot leave the machine, and the billing rules + // UI reads the gateway's own API over plain HTTP in dev setups. + for _, raw := range []string{"http://127.0.0.1:8081/api/x", "http://localhost:8081/x"} { + if _, err := allow.checkURL(raw); err != nil { + t.Fatalf("loopback http must stay usable, got %v for %q", err, raw) + } + } +} + +func TestPluginHTTPRejectsBadSchemesAndMissingURL(t *testing.T) { + allow := newHTTPAllowlist([]string{"api.example.com"}) + for _, raw := range []string{"file:///etc/passwd", "ftp://api.example.com/x", "gopher://x"} { + if _, err := allow.checkURL(raw); err == nil { + t.Fatalf("scheme in %q must be refused", raw) + } + } + // An empty allowlist must deny, not fall open. + if _, err := newHTTPAllowlist(nil).checkURL("https://api.example.com/x"); err == nil { + t.Fatal("an unconfigured gateway must deny outbound HTTP entirely") + } +} + +// A redirect is a hop the allowlist never saw. Following it would let an +// allowed host bounce a plugin's request (and its headers) anywhere. +func TestPluginHTTPDoesNotFollowRedirects(t *testing.T) { + if httpClient.CheckRedirect == nil { + t.Fatal("CheckRedirect must be set: following a redirect escapes the allowlist") + } + req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil) + err := httpClient.CheckRedirect(req, []*http.Request{req}) + if err != http.ErrUseLastResponse { + t.Fatalf("CheckRedirect must return ErrUseLastResponse so the 3xx is "+ + "handed back unfollowed, got %v", err) + } +} + +func TestPluginHTTPTimeoutAndBodyAreClamped(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + allow := newHTTPAllowlist([]string{"127.0.0.1"}) + u, err := allow.checkURL(srv.URL) + if err != nil { + t.Fatalf("loopback test server must be allowed: %v", err) + } + // An absurd timeout is clamped instead of honoured. + res, err := performHTTP(u, httpRequestSpec{Timeout: 24 * time.Hour}) + if err != nil { + t.Fatalf("clamped request failed: %v", err) + } + if res.Status != 200 || !strings.Contains(res.Body, `"ok"`) { + t.Fatalf("unexpected result: status=%d body=%q", res.Status, res.Body) + } +} + +func TestPluginHTTPOversizedBodyIsRefused(t *testing.T) { + big := strings.Repeat("x", httpMaxResponse+64) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(big)) + })) + defer srv.Close() + allow := newHTTPAllowlist([]string{"127.0.0.1"}) + u, _ := allow.checkURL(srv.URL) + // Refusing beats truncating: a truncated body parses as corrupt JSON and the + // plugin would report a parse error instead of the real cause. + if _, err := performHTTP(u, httpRequestSpec{}); err == nil { + t.Fatal("an oversized response must be refused, not silently truncated") + } +} + +func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) { + // Lua-side contract: the bridge returns (nil, message) on every failure so a + // plugin mistake never raises through golua (see the SIGSEGV note on + // hookGuardSrc — an escaping error can take the gateway down). + allow := newHTTPAllowlist([]string{"api.example.com"}) + if _, err := allow.checkURL("https://evil.test/"); err == nil { + t.Fatal("expected refusal") + } + // readHTTPRequestSpec must reject a non-table without touching the stack. + L := newTestState(t) + if _, err := readHTTPRequestSpec(L); err == nil { + t.Fatal("a missing argument table must be an error, not a panic") + } +} \ No newline at end of file diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 1e03b2d..9042112 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -231,6 +231,10 @@ type Plugins struct { // stateDirDisabled turns persistence off. Used by tests that assert state // starts empty, and by an embedder that has no writable plugin dir. stateDirDisabled bool + // allowHTTP bounds plugin outbound requests to operator-declared hosts. + // nil means deny-all: an unconfigured gateway must not silently grant a + // plugin unrestricted network access. + allowHTTP *httpAllowlist // save coalesces state writes into one background goroutine. // // A hook must NOT write synchronously: the billing plugin mutates state on @@ -412,6 +416,10 @@ func NewPlugins(vm *VM, dir string) *Plugins { stageFuncs: map[Stage][]hookCall{}, hookErr: newHookErrors(), dir: dir, + // Deny-by-default. NewPlugins is called during wiring, before any + // source list is known; SetOutboundHosts replaces this with the real + // set. A plugin loaded in that window simply cannot make requests. + allowHTTP: newHTTPAllowlist(nil), } // The saver is started even with no dir: a gateway can be given a plugin dir // later, and a saver that only exists when dir != "" would silently never @@ -429,6 +437,27 @@ func NewPlugins(vm *VM, dir string) *Plugins { // DisableStatePersistence turns persistence off (tests/benchmarks only). func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true } +// SetOutboundHosts declares which hosts plugins may contact via +// __llmsproxy_http. The gateway passes the host of every configured source: +// those are operator-declared upstreams, and reusing that set keeps a plugin's +// reach no wider than the deployment already trusts. An empty list means no +// outbound host is allowed at all — plugins then still load, they just cannot +// make requests. +func (ps *Plugins) SetOutboundHosts(hosts []string) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.allowHTTP = newHTTPAllowlist(hosts) +} + +// outboundAllowlist returns the current allowlist. bootPluginState reads it +// before any concurrent reload can swap it, and a nil result is treated as +// "deny everything" rather than "allow everything". +func (ps *Plugins) outboundAllowlist() *httpAllowlist { + ps.mu.RLock() + defer ps.mu.RUnlock() + return ps.allowHTTP +} + func (ps *Plugins) Close() { if ps == nil { return @@ -496,10 +525,17 @@ func (ps *Plugins) SeedBundled() error { // bootPluginState compiles one plugin into a fresh Lua state and stores its // returned table under pluginGlobal. It is the plugin counterpart of // adapterPool.boot, minus the pooling: a plugin keeps exactly one state. -func bootPluginState(code, name string) (*worker, error) { +func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) { L := golua.NewState() L.OpenLibs() setupGlobals(L) + // Only plugins get __llmsproxy_http. Adapters are operator-supplied config + // and go through setupGlobals too, so installing the bridge here — not in + // setupGlobals — is what keeps the capability scoped to installed .lua + // files. + if allow != nil { + registerFn(L, "httpGlobal", luaHTTPFn(L, allow)) + } if err := L.DoString(code); err != nil { L.Close() return nil, fmt.Errorf("compile plugin %s: %w", name, err) @@ -537,7 +573,7 @@ func (ps *Plugins) LoadSource(name, code string) error { // One state, held for the plugin's lifetime (see Plugin.state): a hook that // accumulates into plugin.state would otherwise split its totals across // whichever worker it happened to run on. - w, err := bootPluginState(code, name) + w, err := bootPluginState(code, name, ps.outboundAllowlist()) if err != nil { p.LoadError = err.Error() ps.append(p) From 85c81c63cc12e4f2f9ffaf89f5391dcd8c88723d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 08:10:11 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat(plugins):=20ccquota=20=E5=A4=9A?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E9=A2=9D=E5=BA=A6=E6=8F=92=E4=BB=B6=20+=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E2=86=92Lua=20=E8=B0=83=E7=94=A8=E9=80=9A?= =?UTF-8?q?=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quota plugin cannot work on billing's model. Billing reports what the gateway already measured, delivered on hooks during request forwarding. A plan balance has no such arrival path: it exists only on the vendor's side, and the only way to learn it is to ask — at the moment the operator clicks Refresh. No hook fires on a click, and pluginAPI offered only state read/write. So this adds the missing piece: POST /api/plugins/{name}/call/{fn}, plus pluginAPI.call on the page side. ## Why the gate is an explicit export list That endpoint is an HTTP surface into a Lua state. Without a declared allowlist it would let any caller invoke the plugin's private helpers with crafted arguments and read whatever it can see — for a plugin holding an API key, that is handing the key to anyone who can POST. So a function is callable only if the plugin listed it in plugin.exports. A plugin with no exports has no callable surface; reading the missing list as "allow all" is exactly the failure mode worth designing against. The endpoint is also admin-only. ## ccquota One account per configured source on api.commandcode.ai, keyed by that source's own key — two keys on one host are two seats, and both are shown. Reads /alpha/whoami, /alpha/billing/credits, /alpha/billing/subscriptions and /alpha/usage/summary. Each endpoint degrades independently: a vendor retiring one route must not blank the others, and a key out of quota still reads credits fine while inference 401s, so the report records WHICH part failed rather than rendering a zero that reads as "no quota left". The 401-is-everything trap: those endpoints need a bearer token plus three headers. The token lives in the table now, per request. Passing the shared header table straight through — my first version — yields 401 on every endpoint, which is indistinguishable from a revoked key and sent me auditing the account instead of the plugin. ## Also fixed: three real defects found by going live - The bridge was registered as `httpGlobal` (the Go const's IDENTIFIER) instead of `__llmsproxy_http` (its VALUE). Green unit tests, "attempt to call global '__llmsproxy_http' (a nil value)" on first real request. Now a test boots a real plugin and calls it the way a plugin does. - The call router kept `call/` in the function name, so every call 502'd with `does not export "call/collect_account"` while the exports list looked fine. - readPluginExports used luaToJSON, which needs the `json` global — not yet installed while a plugin is registering — so it silently returned an empty list and every export looked undeclared. It uses luaTableToGoValue now. Two tests hardcoded counts ("1 bundled plugin", "4 pages") and turned red the moment a second bundled plugin existed. They now assert what they mean: billing is among the bundled plugins, and the pages this test contributed all merged in order. A count assertion is a changelog detector. ## Verified on production commandcode 2198972886@qq.com active $0.0811 5h 0/14 wk 0.803/35 44,921 req commmandcode2 jianf3112@gmail.com active $0.0700 5h 0/14 wk 0/35 43,827 req Co-Authored-By: ModelRouter (cherry picked from commit 111dc6d53f5ddb07e678bcc81c35f11ce26a9a09) (cherry picked from commit 22c9215b023ba102c079010353a642bd219940da) --- internal/gateway/plugin_call_api_test.go | 150 +++++++++ internal/gateway/plugins_api.go | 51 ++++ internal/gateway/ui/index.html | 22 ++ internal/lua/billing_test.go | 36 ++- internal/lua/plugin_http_args.go | 19 +- internal/lua/plugin_http_test.go | 68 ++++- internal/lua/plugins.go | 142 ++++++++- internal/lua/plugins/ccquota.lua | 368 +++++++++++++++++++++++ 8 files changed, 846 insertions(+), 10 deletions(-) create mode 100644 internal/gateway/plugin_call_api_test.go create mode 100644 internal/lua/plugins/ccquota.lua diff --git a/internal/gateway/plugin_call_api_test.go b/internal/gateway/plugin_call_api_test.go new file mode 100644 index 0000000..1f9f328 --- /dev/null +++ b/internal/gateway/plugin_call_api_test.go @@ -0,0 +1,150 @@ +package gateway + +// The /api/plugins/{name}/call/{fn} endpoint splits its path with string +// surgery, and the split point is easy to get wrong in a way that still +// "works": keeping the "call/" segment in the function name produced +// `does not export "call/collect_account"` — the plugin was correct, the router +// was not, and every call 502'd while the exports list looked perfectly fine. +// +// These tests drive the real router, so a refactor that reintroduces the +// off-by-one is caught here rather than in production. + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/core" +) + +// callPlugin exposes exactly one export plus a function that is NOT exported. +// If the exports gate is not honoured, secretFn is reachable and with it the +// plugin's private state — for a plugin holding an API key that means handing +// the key to anyone who can POST to the gateway. +const callPlugin = ` +local P = {} +P.name = "calltest" +P.version = "1.0.0" +P.exports = { "add" } +P.secret = "do-not-leak" +function P.add(a) + return { result = (tonumber(a and a.x) or 0) + 1 } +end +function P.secretFn() + return { leaked = P.secret } +end +return P +` + +func gatewayWithCallPlugin(t *testing.T) *Gateway { + t.Helper() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + body := "listen: :0\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "gateway_keys:\n - sk-test\n" + if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatalf("core: %v", err) + } + t.Cleanup(c.Close) + if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil { + t.Fatalf("load calltest: %v", err) + } + g, err := New(c) + if err != nil { + t.Fatalf("gateway: %v", err) + } + return g +} + +func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) { + g := gatewayWithCallPlugin(t) + rr := doReq(t, g, http.MethodPost, + "/api/plugins/calltest/call/add", `{"x":41}`) + if rr.Code != http.StatusOK { + t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String()) + } + var out struct { + Result struct { + Result int `json:"result"` + } `json:"result"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v (body=%s)", err, rr.Body.String()) + } + // 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/" + // segment did not leak into the function name, and the payload arrived. + if out.Result.Result != 42 { + t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+ + "function name or the argument table never reached Lua", out.Result.Result) + } +} + +func TestPluginCallRefusesUndeclaredFunction(t *testing.T) { + g := gatewayWithCallPlugin(t) + rr := doReq(t, g, http.MethodPost, + "/api/plugins/calltest/call/secretFn", `{}`) + if rr.Code == http.StatusOK { + t.Fatalf("an unexported function must not be callable, got 200: %s", + rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "do-not-leak") { + t.Fatal("the refusal leaked the plugin's private state") + } +} + +// A plugin that declares no exports at all has no callable surface. Reading the +// missing list as "allow everything" would turn every plugin into a remote +// evaluator the moment this endpoint ships. +func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) { + g := gatewayWithCallPlugin(t) + const bare = ` +local P = {} +P.name = "bare" +P.version = "1.0.0" +function P.anything() + return { ok = true } +end +return P +` + if err := g.core.Plugins().LoadSource("bare", bare); err != nil { + t.Fatalf("load bare: %v", err) + } + rr := doReq(t, g, http.MethodPost, + "/api/plugins/bare/call/anything", `{}`) + if rr.Code == http.StatusOK { + t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s", + rr.Body.String()) + } +} + +func TestPluginCallIsAdminOnly(t *testing.T) { + g := gatewayWithCallPlugin(t) + // A real user-role key, not a bare one from config: sk-test/sk-user in the + // config both default to admin, so a test using them would pass the gate for + // the wrong reason and prove nothing. + rec, err := g.core.CreateKey("viewer", "user", nil, "") + if err != nil { + t.Fatal(err) + } + rr := doReqAs(t, g, rec.Key, http.MethodPost, + "/api/plugins/calltest/call/add", `{"x":1}`) + if rr.Code != http.StatusForbidden { + t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s", + rr.Code, rr.Body.String()) + } +} \ No newline at end of file diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 8704c7c..db81ed7 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -93,6 +93,18 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { return } + // /api/plugins/{name}/call/{fn} + if i := strings.Index(path, "/call/"); i >= 0 { + name := path[:i] + fn := path[i+len("/call/"):] + if name == "" || fn == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name") + return + } + g.handlePluginCall(w, r, name, fn) + return + } + if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") return @@ -245,6 +257,45 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name } } +// Call invokes a named function on a plugin's Lua state and returns its result. +// +// This exists because plugin pages otherwise have NO way to reach their own +// plugin code: pluginAPI offers state read/write and a couple of callbacks, and +// that is enough for billing (whose numbers arrive on hooks during request +// forwarding) but useless for a plugin that must do work on demand. A quota +// page has to ask the vendor when the operator clicks Refresh, and no hook +// fires at that moment. +// +// It is deliberately narrow: +// - The function must be one the plugin explicitly lists in plugin.exports. +// Anything else is refused, so this endpoint cannot become a generic "run +// arbitrary Lua with my credentials" oracle over a plugin's private state. +// - The caller must be admin, and the result is JSON — no Lua value is ever +// handed back to Go as a live closure. +// - Errors come back as (nil, message) from the plugin, matching every other +// plugin entry point, and surface as a 502 rather than raising. +func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + return + } + var args map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&args); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + result, err := g.core.Plugins().Call(name, fn, args) + if err != nil { + writeError(w, http.StatusBadGateway, "plugin_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"result": result}) +} + func (g *Gateway) pluginDir() string { if c := g.core.Config(); c != nil { return c.PluginDir diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index e780f5b..43d2f8b 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -5570,6 +5570,28 @@ toast(t("kAutoChainGlobal")); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error?.message || r.status); return true; }, + // call invokes one of the plugin's OWN exported functions. Billing never + // needs this — its numbers arrive on hooks while the gateway forwards a + // request — but a plugin that must do work on demand (asking a vendor + // for a balance when the operator clicks Refresh) has no other way to + // reach its own code: no hook fires on a click. + // + // Admin-only server side, and the plugin must have listed the function in + // plugin.exports, so this cannot be used to reach a plugin's internals. + async call(name, fn, args) { + const r = await fetch( + "/api/plugins/" + encodeURIComponent(name) + "/call/" + encodeURIComponent(fn), + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args || {}), + }, + ); + const body = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(body.error?.message || r.status); + return body.result; + }, onTabShown(fn) { PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || []; PLUGIN_TAB_CBS.__last.push(fn); diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 6cd362a..8ab9cc0 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -2,12 +2,25 @@ package lua import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" "testing" ) +// pluginLoaded reports whether a plugin with this name is present, ignoring any +// LoadError: a plugin that is present but broken still counts as "loaded here", +// and the caller decides whether the error matters. +func pluginLoaded(ps *Plugins, name string) bool { + for _, p := range ps.List() { + if fmt.Sprint(p["name"]) == name { + return true + } + } + return false +} + // The billing plugin ships with the gateway, so its arithmetic is a contract: // a wrong price silently produces wrong money. These tests drive it through the // real hook path and check the NUMBERS, not merely that it loads. @@ -447,8 +460,16 @@ return plugin`) } // Order must be honoured so the sidebar is predictable. - if len(ui.Pages) != 4 { - t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got) + // + // The page COUNT is not asserted: it used to be 4, and it grew every time a + // bundled plugin added a page. A hard count here reads as "the merge is + // broken" whenever the product gains a feature, which trains the reader to + // bump a number instead of reading the failure. What matters is that all + // three pages this test contributed merged, and that they came out sorted. + for _, want := range []string{"third-a", "third-b", "other-page"} { + if !got[want] { + t.Errorf("page %s did not merge: %v", want, got) + } } for i := 1; i < len(ui.Pages); i++ { if ui.Pages[i].Order < ui.Pages[i-1].Order { @@ -462,8 +483,15 @@ return plugin`) // configuration, since seeding only happens on a fresh plugin dir. func TestBillingPluginLoadedByDefault(t *testing.T) { ps, pdir := billingVM(t) - if ps.Count() != 1 { - t.Fatalf("expected 1 bundled plugin, got %d", ps.Count()) + // billing must be among the bundled plugins, not the only one. The count + // grows with every shipped plugin and asserting it makes this test a + // changelog detector. + if !pluginLoaded(ps, "billing") { + var names []string + for _, p := range ps.List() { + names = append(names, fmt.Sprint(p["name"])) + } + t.Fatalf("billing is not loaded; bundled plugins are %v", names) } if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil { t.Errorf("billing.lua was not written to the plugin dir: %v", err) diff --git a/internal/lua/plugin_http_args.go b/internal/lua/plugin_http_args.go index 7e4653c..6d05e9b 100644 --- a/internal/lua/plugin_http_args.go +++ b/internal/lua/plugin_http_args.go @@ -8,6 +8,11 @@ import ( golua "github.com/aarzilli/golua/lua" ) +// httpArgIdx is the stack slot holding the caller's table. Verified against +// golua: a PushGoClosure function is invoked with its arguments already on top +// and nothing of itself below them. +const httpArgIdx = 1 + // readHTTPRequestSpec decodes the Lua argument table: // // __llmsproxy_http({ @@ -18,18 +23,24 @@ import ( // timeout_ms = 8000, -- optional, clamped to 60s // }) // +// The table is at index 1: measured, not assumed. A Go closure registered with +// PushGoClosure is NOT on the stack when it runs — GetTop() is 1 and slot 1 is +// the caller's table (type LUA_TTABLE). An earlier version assumed the closure +// occupied slot 1 and read from slot 2, which then reported "expected a table +// argument" for a perfectly good call. +// // luaTableToGoValue already turns the table into a map/slice, so the whole // argument is decoded in one pass instead of walking the table field by field. func readHTTPRequestSpec(L *golua.State) (httpRequestSpec, error) { var spec httpRequestSpec - if L.GetTop() < 1 { + if L.GetTop() < httpArgIdx { return spec, fmt.Errorf("__llmsproxy_http: expected a table argument") } - if L.Type(1) != golua.LUA_TTABLE { + if L.Type(httpArgIdx) != golua.LUA_TTABLE { return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s", - normalizeString(L, 1)) + normalizeString(L, httpArgIdx)) } - arg, ok := luaTableToGoValue(L, 1).(map[string]interface{}) + arg, ok := luaTableToGoValue(L, httpArgIdx).(map[string]interface{}) if !ok { return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table") } diff --git a/internal/lua/plugin_http_test.go b/internal/lua/plugin_http_test.go index 5e3919e..33f232c 100644 --- a/internal/lua/plugin_http_test.go +++ b/internal/lua/plugin_http_test.go @@ -148,4 +148,70 @@ func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) { if _, err := readHTTPRequestSpec(L); err == nil { t.Fatal("a missing argument table must be an error, not a panic") } -} \ No newline at end of file +} +// The bridge must be reachable under the name plugins actually call. +// +// This exists because the registration once used the CONSTANT'S IDENTIFIER as +// the Lua global name (`registerFn(L, "httpGlobal", …)`) rather than its VALUE, +// so the bridge was published as `httpGlobal` while every plugin called +// `__llmsproxy_http`. The failure only appeared at runtime, as +// "attempt to call global '__llmsproxy_http' (a nil value)" on the first real +// request — after the unit tests were green, because they drove the Go-side +// helpers directly and never went through Lua name resolution. +// +// So this test boots a real plugin and calls the bridge the way a plugin does. +func TestPluginHTTPBridgeIsReachableUnderItsLuaName(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + ps.SetOutboundHosts([]string{"127.0.0.1"}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"credits":{"monthlyCredits":1.5}}`)) + })) + defer srv.Close() + + // The plugin asks for a loopback URL, which is allowed without being + // configured as a source. It type-checks both the table and arg.url, so a + // malformed call is reported by the plugin rather than as a bewildering URL + // parse error from the bridge. + src := ` +local P = {} +P.name = "httpprobe" +P.version = "1.0.0" +P.exports = { "probe" } +-- Call passes the WHOLE argument table as a single Lua argument, which is the +-- same convention the hooks use and what a plugin page's pluginAPI.call ends up +-- producing. Taking the table (not a bare string) is the correct shape. +function P.probe(arg) + if type(arg) ~= "table" then return { error = "probe takes the args table, got " .. type(arg) } end + if type(arg.url) ~= "string" then return { error = "arg.url must be a string, got " .. type(arg.url) } end + local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" }) + if res == nil then return { error = tostring(err) } end + return { status = res.status, body = res.body } +end +return P +` + if err := ps.LoadSource("httpprobe", src); err != nil { + t.Fatalf("load: %v", err) + } + got, err := ps.Call("httpprobe", "probe", map[string]interface{}{"url": srv.URL}) + if err != nil { + t.Fatalf("call: %v", err) + } + m, _ := got.(map[string]interface{}) + if m == nil { + t.Fatalf("probe returned %T, want a table", got) + } + // A nil global surfaces as {error = "attempt to call global ... (a nil value)"} — + // which is a table too, so the error field is what distinguishes it. + if e, ok := m["error"]; ok { + t.Fatalf("the bridge is not reachable as %s: %v", httpGlobal, e) + } + if s, _ := m["status"].(float64); int(s) != 200 { + t.Fatalf("status=%v body=%v, want 200 from the loopback test server", m["status"], m["body"]) + } + if body, _ := m["body"].(string); !strings.Contains(body, "monthlyCredits") { + t.Fatalf("body=%q, want the server's JSON", body) + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 9042112..a2f6b93 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -179,6 +179,11 @@ type Plugin struct { // reasoning as a systemd unit being masked rather than removed. Disabled bool dir string + // exports lists the functions this plugin allows the WebUI to call through + // POST /api/plugins/{name}/call/{fn}. It is read once at load from + // plugin.exports and never changes, so Call can check it without taking a + // Lua read on the hot path. + exports []string // script is the plugin's source, kept so a reload can rebuild its state. script string // Builtin marks a plugin that shipped with the gateway. The UI shows it as @@ -534,7 +539,7 @@ func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) { // setupGlobals — is what keeps the capability scoped to installed .lua // files. if allow != nil { - registerFn(L, "httpGlobal", luaHTTPFn(L, allow)) + registerFn(L, httpGlobal, luaHTTPFn(L, allow)) } if err := L.DoString(code); err != nil { L.Close() @@ -628,6 +633,12 @@ func (ps *Plugins) LoadSource(name, code string) error { p.UI = ui } + // ---- exports ---- + // Read here, while the state is locked and before restore() runs: the list is + // static for the life of the plugin, so Call never has to re-enter Lua just + // to decide whether a call is allowed. + p.exports = readPluginExports(w.L) + // Restore persisted state AFTER the plugin compiled and registered its // hooks, so the restore overwrites the compiled-in defaults instead of being // overwritten by them. Doing it earlier would mean a restart resets the @@ -711,6 +722,43 @@ func pluginHookName(L *golua.State, stage string) (string, bool) { } // readPluginUI reads the optional ui extension block. +// readPluginExports collects plugin.exports, the list of functions the WebUI is +// allowed to invoke through POST /api/plugins/{name}/call/{fn}. +// +// It accepts either an array of names or a map whose KEYS are the names — both +// shapes read naturally in Lua and authors reach for either. A missing or +// non-conforming table yields nil, which Call treats as "nothing is callable": +// a plugin that never declared an export simply has no callable surface. +func readPluginExports(L *golua.State) []string { + L.GetGlobal(pluginGlobal) + if L.IsNil(-1) { + L.SetTop(0) + return nil + } + L.GetField(-1, "exports") + if L.Type(-1) != golua.LUA_TTABLE { + L.SetTop(0) + return nil + } + // luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering, + // before the json global is installed, and luaToJSON needs it. Going through + // JSON here silently produced an empty list, which then made every exported + // function look unexported. + raw := luaTableToGoValue(L, -1) + L.SetTop(0) + arr, ok := raw.([]interface{}) + if !ok { + return nil + } + list := make([]string, 0, len(arr)) + for _, v := range arr { + if s := asString(v); s != "" { + list = append(list, s) + } + } + return list +} + func readPluginUI(L *golua.State) (*UIExtension, bool) { L.GetGlobal(pluginGlobal) if L.IsNil(-1) { @@ -1650,3 +1698,95 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{}) ps.markDirtyLocked(p) return out, nil } + +// Call invokes a plugin function that the plugin declared in plugin.exports. +// +// The gate is the declaration, not the caller: a plugin lists the handful of +// entry points its own UI is allowed to reach, and anything absent is refused. +// Without that list this endpoint would be a remote Lua evaluator — it could +// call the plugin's private helpers, hand them crafted arguments, and read +// whatever the plugin can see, which for a plugin holding an API key means +// handing that key to anyone who can POST to the gateway. +// +// The return value is JSON-shaped rather than restricted to a map, because a +// quota report is a nested structure with numbers and booleans that must not be +// flattened. Hook results still go through invoke, whose map-typed contract the +// state merge depends on. +func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface{}, error) { + ps.mu.RLock() + var p *Plugin + for _, c := range ps.plugins { + if c.Info.Name == name { + p = c + break + } + } + ps.mu.RUnlock() + if p == nil { + return nil, fmt.Errorf("plugin %s not found", name) + } + if !pluginExportAllowed(p, fn) { + return nil, fmt.Errorf("plugin %s does not export %q", name, fn) + } + p.mu.Lock() + w := p.state + if w == nil { + p.mu.Unlock() + return nil, fmt.Errorf("no state for plugin %s", name) + } + defer p.mu.Unlock() + L := w.L + L.SetTop(0) + defer L.SetTop(0) + + L.GetGlobal(pluginGlobal) + if L.IsNil(-1) { + return nil, fmt.Errorf("plugin table missing") + } + plug := L.GetTop() + L.GetGlobal(hookGuardName) + if L.IsNil(-1) { + L.Pop(1) + } + L.GetField(plug, fn) + if !L.IsFunction(-1) { + return nil, fmt.Errorf("export %s missing", fn) + } + if args == nil { + args = map[string]interface{}{} + } + pushGoValue(L, plainForLua(args)) + if err := L.Call(2, 2); err != nil { + return nil, err + } + errMsg := "" + if L.GetTop() >= 2 { + if !L.IsNil(-1) { + errMsg = L.ToString(-1) + } + L.Pop(1) + } + if errMsg != "" { + return nil, fmt.Errorf("plugin %s: %s", name, errMsg) + } + if L.GetTop() < 1 || L.IsNil(-1) { + return nil, nil + } + var out interface{} + if err := luaToJSON(L, -1, &out); err != nil { + return nil, nil + } + return out, nil +} + +// pluginExportAllowed reports whether fn is in the plugin's exports list. +// An absent list means nothing is callable — the safe reading, since silently +// allowing all would turn an undeclared plugin into a remote evaluator. +func pluginExportAllowed(p *Plugin, fn string) bool { + for _, e := range p.exports { + if e == fn { + return true + } + } + return false +} diff --git a/internal/lua/plugins/ccquota.lua b/internal/lua/plugins/ccquota.lua new file mode 100644 index 0000000..83c9af4 --- /dev/null +++ b/internal/lua/plugins/ccquota.lua @@ -0,0 +1,368 @@ +-- Command Code quota — multi-account plan balance for the gateway. +-- +-- The billing plugin only ever reports what the gateway already measured, so it +-- never has to reach out. A plan balance is different: the number only exists +-- on the vendor's side, and the only way to learn it is to ask. Command Code +-- exposes /alpha/billing/credits (plus whoami / subscriptions / usage) on the +-- provider host, authenticated with the same API key the source already uses. +-- +-- Accounts: one per configured source whose base_url is the Command Code API +-- host. Two keys on the same host are two separate accounts, and both are +-- shown — an operator with a spare seat needs to see both draining. +-- +-- Every endpoint degrades independently. A vendor that retires one route must +-- not blank the others, and a key that has run out still returns HTTP 401 on +-- the inference path while credits reads fine, so the report has to survive +-- partial failure and say WHICH part failed rather than rendering a zero that +-- looks like "no quota left". + +local PLUGIN = {} + +PLUGIN.name = "ccquota" +PLUGIN.version = "1.0.0" +PLUGIN.description = "Command Code 多账户额度余量(5 小时 / 周 / 月窗口 + 余额)" + +-- Command Code's API host. __llmsproxy_http only permits hosts that a +-- configured source already talks to, so this works exactly when such a source +-- exists — no separate allowlist to keep in sync, and no way to widen reach. +local API_HOST = "api.commandcode.ai" + +-- These headers are required alongside the bearer token. Without them the +-- endpoints answer 401 even with a perfectly valid key, which looks identical +-- to a revoked key and costs a lot of time to tell apart. +-- +-- The bearer token is NOT here on purpose: it is per-account, so fetchJSON +-- copies this table and injects it per request. An earlier version passed this +-- table straight through and every endpoint answered 401 — which reads exactly +-- like a revoked key, and sent me looking at the account instead of the plugin. +local CLI_VERSION = "1.54.2" +local REQUIRED_HEADERS = { + ["x-command-code-version"] = CLI_VERSION, + ["x-cli-environment"] = "production", + ["User-Agent"] = "commandcode-quota/" .. CLI_VERSION, +} + +-- Accounts are handed in from the page (which reads /api/sources and its own +-- key store). The kernel deliberately does NOT let a plugin read the config, so +-- this is the only channel — which also means the key never sits in Lua state +-- or in the persisted plugin file. +local ACCOUNTS = {} + +-- ---------------------------------------------------------------- helpers + + +local function tonum(v) + return tonumber(v) or 0 +end + +-- usd formats a dollar amount the way the vendor's panel does: two decimals, +-- but never a bare "0" when there is a real fractional balance left. A $0.0811 +-- balance rounds to $0.08, and showing "$0" there would read as "nothing left". + + +-- fetchJSON performs one request and returns (decodedTable, nil) or +-- (nil, errorMessage). It NEVER raises: an escaping error inside a plugin hook +-- can take the whole gateway down (see the hook-guard note in the kernel), so +-- every failure has to arrive as an ordinary value. +local function fetchJSON(path, key) + -- Copy, then add the token: mutating the shared table would leak one + -- account's key into every later request. + local headers = {} + for k, v in pairs(REQUIRED_HEADERS) do headers[k] = v end + headers["Authorization"] = "Bearer " .. tostring(key or "") + local res, err = __llmsproxy_http({ + url = "https://" .. API_HOST .. path, + method = "GET", + headers = headers, + timeout_ms = 12000, + }) + if res == nil then + return nil, tostring(err or "request failed") + end + -- Body arrives as a JSON string; the kernel's json.decode is the only parser + -- available and it raises on malformed input, so it is pcall'd here too. + local ok, decoded = pcall(json.decode, res.body) + if not ok then + return nil, "bad JSON from " .. path + end + return decoded, nil +end + +-- authFailure recognises the one error that means "this key is not usable" +-- rather than "this endpoint is unhappy". Quota exhaustion and a revoked key +-- both surface as 401 here, and they need opposite operator responses. + +-- ---------------------------------------------------------------- account report + +-- collectAccount gathers one account's numbers. Each endpoint is independent: +-- credits is the one that matters, and the rest enrich it. A failure is +-- recorded per-endpoint instead of aborting, because "I could not read the +-- subscription" and "the account has no quota" must not look the same. +local function collectAccount(acct) + local report = { + name = acct.name, + label = acct.label, + failures = {}, + checked_at = os.time(), + } + + local who, werr = fetchJSON("/alpha/whoami", acct.key) + if who == nil then + report.failures[#report.failures + 1] = "whoami: " .. tostring(werr) + elseif type(who) == "table" and type(who.user) == "table" then + report.email = who.user.email + report.user = who.user.name or who.user.userName + end + + local creds, cerr = fetchJSON("/alpha/billing/credits", acct.key) + local rawStatus = 0 + if creds == nil then + report.failures[#report.failures + 1] = "credits: " .. tostring(cerr) + else + local c = creds.credits or {} + local wl = creds.windowLimits or {} + report.monthly = tonum(c.monthlyCredits) + report.purchased = tonum(c.purchasedCredits) + report.free = tonum(c.freeCredits) + report.total = report.monthly + report.purchased + report.free + report.limited = wl.limited == true + report.exceeded = wl.exceeded + report.fiveHour = wl.fiveHour + report.weekly = wl.weekly + rawStatus = 200 + end + + local subs, serr = fetchJSON("/alpha/billing/subscriptions", acct.key) + if subs == nil then + report.failures[#report.failures + 1] = "subscriptions: " .. tostring(serr) + elseif type(subs) == "table" and type(subs.data) == "table" then + report.plan_status = subs.data.status + report.plan_price = subs.data.metadata and nil + local md = subs.data.metadata + if type(md) == "table" then + report.plan_name = md.plan or md.name or md.tier + end + end + + local usage, uerr = fetchJSON("/alpha/usage/summary", acct.key) + if usage == nil then + report.failures[#report.failures + 1] = "usage: " .. tostring(uerr) + elseif type(usage) == "table" then + report.requests = tonum(usage.totalCount) + report.spent = tonum(usage.totalCost) + report.tokens = tonum(usage.totalTokens) + report.success_rate = tonum(usage.successRate) + end + + -- An account whose every endpoint failed is an auth problem, not a data + -- problem: state it plainly so nobody reads "—" as "zero quota". + if #report.failures >= 3 and report.total == nil then + report.auth_failed = true + end + return report +end + +-- ---------------------------------------------------------------- page + +PLUGIN.ui = { + page = { + page_id = "ccquota", + title = "CC Quota", + order = 45, + mount = [==[ +
+
+
+
Command Code 额度
+
点击刷新以拉取各账户余量
+
+
+ + +
+
尚未拉取
+
+ + +]==], + }, +} + +-- exports is what makes collect_account reachable from the plugin's own page. +-- Without this list the call endpoint refuses it: the endpoint is an HTTP +-- surface into the Lua state, so "which functions may it reach" has to be the +-- plugin's explicit answer rather than a default. +PLUGIN.exports = { "collect_account" } + +-- collect_account gathers one account's report. It returns the report TABLE (not +-- a string) so the page receives structured numbers and booleans instead of +-- having to parse anything back out of a formatted blob. +PLUGIN.collect_account = function(acct) + if type(acct) ~= "table" then return nil, "expected account table" end + if acct.key == nil or acct.key == "" then + return { name = tostring(acct.name or ""), failures = { "missing api key" }, + auth_failed = true } + end + local rep = collectAccount({ + name = acct.name, label = acct.label, key = acct.key, + }) + return rep, nil +end + +return PLUGIN \ No newline at end of file From 2b34c7dc8adefe09ed29d606cb09947a1f0239f2 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 09:36:26 +0800 Subject: [PATCH 03/19] =?UTF-8?q?feat(plugins):=20quota=20=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E5=A5=97=E9=A4=90=E4=BD=99=E9=87=8F=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E5=8F=96=E4=BB=A3=20ccquota=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ccquota asked one vendor one question. The shape that generalizes is two parts: a small set of probe adapters, and a declarative override for a provider nobody anticipated. Three adapters, each verified against a LIVE endpoint rather than assumed: - deepseek family: GET /user/balance → {is_available, balance_infos:[{currency,total,granted,topped_up}]}. Granted vs topped-up is kept separate on purpose: gift credit expiring on a schedule is a different operational fact from prepaid money. - OpenAI console shape: /v1/dashboard/billing/subscription + .../billing/usage. This reports LIMITS and SPEND, not a balance — the live provider has hard_limit_usd 1e8. There is deliberately no `remaining`: ceiling - spend would read as "$99,999,339 left", a number nobody should believe. It renders as spend against a ceiling instead. - commandcode: rolling 5h / weekly / monthly windows plus billing period. A provider with no working adapter is SKIPPED and listed by name at the bottom. Most of these upstreams are small re-sells whose dashboard paths return an HTML page rather than JSON; a table full of "unknown" rows teaches the operator to ignore the page entirely. Failures that are NOT "no endpoint" — a 403 on a key we hold — stay visible, because those are the ones worth fixing. ## Cost control, and a lock the probe runs under First load measured 101s for 21 sources: 3 adapters x 2 attempts x 4s, times 21. Call() holds the plugin's write lock for the whole invocation, so every __llmsproxy_http round trip inside it blocks request_end hooks — which is how billing accrues money. A slow upstream is not just a slow card. - Timeout cut to 4s (from 10s). - A host->adapter memo, persisted in plugin.state, so the dead ends are discovered once per process instead of on every page open. Steady state is 22s. - The memo records NEGATIVE results too, which is what actually paid: only recording successes left every silent source re-probing forever. - Negatives carry a timestamp and expire after 30 minutes. "This upstream has no balance endpoint" is a fact that changes — vendors ship dashboards — and a permanent negative makes the page quietly stop reporting a source that started working. - The page calls collect_source concurrently, one call per source, so a single 502 over 21 sequential probes becomes 21 independent answers. ## Two bugs the page found - deepseek reports topped_up_balance -0.02 against a positive grant; the `v <= 0 → "0.00"` branch flattened a real debit to zero. Negative balances now render as -¥0.02. - PLAN_CAPS was declared below the adapter that reads it, so Command Code hit "attempt to index global 'PLAN_CAPS' (a nil value)" on every probe. A Lua local is nil until its assignment runs. Verified on production: 5 sources report, 16 skipped by name, 1 visible failure (tabitoken 403). deepseek ¥0.32 (赠送 0.34 · 充值 -0.02), justwoker $360,865.71 spent against an ∞ ceiling, both Command Code seats with their three windows. Co-Authored-By: ModelRouter (cherry picked from commit 098a4a5eb479aff4f7eee53985fff62d985a5187) (cherry picked from commit 40636abcc287aaaba1d179ad88d7445b11ce32d2) --- internal/lua/plugins/quota.lua | 761 +++++++++++++++++++++++++++++++++ 1 file changed, 761 insertions(+) create mode 100644 internal/lua/plugins/quota.lua diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua new file mode 100644 index 0000000..532edba --- /dev/null +++ b/internal/lua/plugins/quota.lua @@ -0,0 +1,761 @@ +-- Provider quota — plan balance across upstreams, one card per source. +-- +-- This started as a Command Code plugin and grew, because only one vendor was +-- being asked. The shape that works across providers is a two-part contract: +-- +-- 1. A small set of PROBE ADAPTERS, each naming an endpoint, how to call it +-- and which JSON paths hold the numbers. Adapters are tried in order and +-- the first one that returns a usable answer wins. +-- 2. A DECLARATIVE override, so a provider nobody anticipated can be added +-- from the plugin's own state (endpoints + path mapping) without touching +-- code. +-- +-- What the adapters share, and why it is worth generalizing: +-- +-- - The DeepSeek family (deepseek, and the many re-sells that copied its +-- dashboard) answers GET /user/balance with +-- {is_available, balance_infos:[{currency,total_balance,granted,topped_up}]}. +-- Granted vs topped-up matters: gifted credit expiring on a schedule is a +-- different operational fact from prepaid money. +-- - The OpenAI-compatible dashboard shape answers +-- /v1/dashboard/billing/subscription plus /v1/dashboard/billing/usage, +-- which is what most re-sells of an OpenAI console expose. Note these +-- report LIMITS and SPEND, not a balance: the provider here has a hard limit +-- of 1e8 and 660 spent, so "remaining" must not be computed as +-- limit - spend and presented as money left — it is a ceiling nobody is +-- near. Reporting that honestly matters more than a green bar. +-- - Command Code exposes rolling 5-hour / weekly / monthly windows plus a +-- billing period, which no other provider here does. +-- +-- A provider with no working adapter is SKIPPED, silently. Half these upstreams +-- are small re-sells whose dashboard endpoints return an HTML page, not JSON; a +-- list full of "unknown" rows trains the operator to ignore the page entirely, +-- which is worse than showing only what is real. Failures that are NOT "no such +-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the +-- ones worth fixing. +-- +-- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces +-- that), so adding a probe never widens what the gateway can talk to. + +local PLUGIN = {} + +PLUGIN.name = "quota" +PLUGIN.version = "1.0.0" +PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)" + +PLUGIN.exports = { "collect_source", "collect_all" } + +-- plugin.state must exist before anything indexes it. The kernel restores +-- persisted state OVER this table, so declaring it here is safe: a restart with +-- no saved state still has an empty table to write into, rather than nil. +-- +-- It is initialised at the TOP on purpose. Every collect path below touches it +-- (to restore the probe memo on first use, to persist it afterwards), and a +-- declaration further down would be nil for the reads that run first. +PLUGIN.state = {} + +-- ---------------------------------------------------------------- helpers + +local function tonum(v) + return tonumber(v) or 0 +end + +-- hostOf extracts the host from a base_url, for showing WHICH upstream a row is +-- about. Two sources on the same vendor (two Command Code keys) are +-- indistinguishable without it. +local function hostOf(u) + return (string.gmatch(u, "^https?://([^/]+)")()) or u +end + +-- get walks a dotted path through nested tables, returning nil if any hop is +-- missing. A missing field must stay nil rather than becoming 0: "this provider +-- did not report it" and "this provider reported zero" are different, and +-- collapsing them shows a healthy $0.00 for a provider that never answered. +local function get(root, path) + if root == nil then return nil end + local cur = root + for seg in string.gmatch(path, "[^%.]+") do + if type(cur) ~= "table" then return nil end + cur = cur[seg] + if cur == nil then return nil end + end + return cur +end + +local function getnum(root, path) + local v = get(root, path) + if v == nil then return nil end + if type(v) == "boolean" then return v and 1 or 0 end + local n = tonumber(v) + return n +end + +-- request performs one GET with the standard retry. Retrying is not +-- politeness here: several of these hosts sit behind DNS that intermittently +-- refuses, and a single 000 turned into "provider unknown" on every poll. +local function request(path, key, headers, tries) + tries = tries or 2 + local hdrs = {} + for k, v in pairs(headers or {}) do hdrs[k] = v end + hdrs["Authorization"] = "Bearer " .. tostring(key or "") + local lastErr = "no attempt" + for _ = 1, tries do + local res, err = __llmsproxy_http({ + -- 4s, not 10s: probing is network-bound and the whole page shares ONE + -- Lua state under a write lock, so a slow upstream is not just a slow card + -- — it stalls every request_end hook (i.e. billing) behind it. + url = path, method = "GET", headers = hdrs, timeout_ms = 4000, + }) + if res ~= nil then + if res.status >= 200 and res.status < 300 then + local ok, decoded = pcall(json.decode, res.body or "") + if ok and type(decoded) == "table" then + return decoded, nil + end + -- A 200 that is not JSON means we hit a marketing page, not an API. + return nil, "not json (HTTP " .. tostring(res.status) .. ")" + end + if res.status == 401 or res.status == 403 then + -- Retrying a rejected credential only wastes the operator's time. + return nil, "HTTP " .. tostring(res.status) .. " (key rejected)" + end + lastErr = "HTTP " .. tostring(res.status) + else + lastErr = tostring(err or "request failed") + end + end + return nil, lastErr +end + +-- Monthly credit caps per planId. The credits endpoint never reports a cap, so +-- this is a lookup against the vendor's published pricing ($10 Go, $70 +-- GOAT/Pro/Max), cross-checked against monthly_used + balance. Declared before +-- the adapters that read it — a Lua local is nil until its assignment runs, and +-- the Command Code probe hit that as a runtime nil-index. +local PLAN_CAPS = { + ["individual-go"] = 10, + ["individual-goat"] = 70, + ["individual-pro"] = 70, + ["individual-max"] = 70, + ["team-goat"] = 70, +} + +-- ---------------------------------------------------------------- adapters + +-- Each adapter returns (report, nil) when it produced something usable, or +-- (nil, reason) to let the next one try. `skip` is the quiet reason — a 404 or +-- an HTML page means "this provider does not expose balance here", which is not +-- worth showing. Everything else becomes a visible failure. +local function deepseekBalance(base, key) + local r, err = request(base .. "/user/balance", key) + if r == nil then + if err and (err:match("not json") or err:match("HTTP 404")) then + return nil, "skip", err + end + return nil, "error", err + end + local infos = get(r, "balance_infos") + if type(infos) ~= "table" or #infos == 0 then return nil, "skip", "no balance_infos" end + local first = infos[1] + local rep = { + vendor = "deepseek-style", + currency = tostring(first.currency or "USD"), + balance = tonum(first.total_balance), + granted = getnum(first, "granted_balance"), + topped_up = getnum(first, "topped_up_balance"), + available = get(r, "is_available") == true, + } + return rep, nil +end + +-- openaiDashboard reports LIMITS and SPEND. There is deliberately no +-- `remaining` here: hard_limit_usd is a ceiling (1e8 on the live provider), and +-- ceiling - spend would read as "$99,999,339 left", which is a number nobody +-- should ever believe. +local function openaiDashboard(base, key) + local sub, subErr = request(base .. "/v1/dashboard/billing/subscription", key, nil, 1) + if sub == nil then + if subErr and (subErr:match("not json") or subErr:match("HTTP 404")) then + return nil, "skip", subErr + end + end + local use, useErr = request(base .. "/v1/dashboard/billing/usage", key, nil, 1) + if sub == nil and use == nil then + if subErr and subErr:match("key rejected") then + return nil, "error", subErr + end + return nil, "skip", (subErr or useErr or "no dashboard") + end + local rep = { + vendor = "openai-dashboard", + hard_limit = getnum(sub or {}, "hard_limit_usd"), + soft_limit = getnum(sub or {}, "soft_limit_usd"), + total_usage = getnum(use or {}, "total_usage"), + has_payment_method = get(sub or {}, "has_payment_method") == true, + } + -- Only call it a balance when the vendor says so. A hard limit with no usage + -- endpoint is a ceiling with nothing spent against it; that still renders, but + -- as a limit rather than as money left. + rep.balance = nil + if rep.hard_limit and rep.hard_limit < 1000000 and rep.total_usage then + rep.balance = rep.hard_limit - rep.total_usage + rep.cap = rep.hard_limit + rep.used = rep.total_usage + end + return rep, nil +end + +-- commandCode keeps its own shape: rolling windows plus a billing period. +local function commandCode(base, key) + local H = { + ["x-command-code-version"] = "1.54.2", + ["x-cli-environment"] = "production", + ["User-Agent"] = "quota-plugin/1.0.0", + } + local origin = base:gsub("/provider/v1/?$", "") + local credits, err = request(origin .. "/alpha/billing/credits", key, H) + if credits == nil then + if err and err:match("key rejected") then return nil, "error", err end + return nil, "skip", err + end + local rep = { + vendor = "commandcode", + currency = "USD", + balance = tonum(get(credits, "credits.monthlyCredits")) + + tonum(get(credits, "credits.purchasedCredits")) + + tonum(get(credits, "credits.freeCredits")), + below_threshold = get(credits, "credits.belowThreshold") == true, + limited = get(credits, "windowLimits.limited") == true, + exceeded = get(credits, "windowLimits.exceeded"), + } + local fh = get(credits, "windowLimits.fiveHour") + if type(fh) == "table" then + rep.windows = rep.windows or {} + rep.windows[#rep.windows + 1] = { + label = "5 小时", used = tonum(fh.used), cap = tonum(fh.cap), + exceeded = fh.exceeded == true, reset_at = tonum(fh.resetAt), + } + end + local wk = get(credits, "windowLimits.weekly") + if type(wk) == "table" then + rep.windows = rep.windows or {} + rep.windows[#rep.windows + 1] = { + label = "每周", used = tonum(wk.used), cap = tonum(wk.cap), + exceeded = wk.exceeded == true, reset_at = tonum(wk.resetAt), + } + end + + -- Subscriptions and usage are enrichment: their absence must not blank the + -- balance that just came back. + local subs = request(origin .. "/alpha/billing/subscriptions", key, H, 1) + if subs ~= nil then + rep.plan_id = get(subs, "data.planId") + rep.plan_status = get(subs, "data.status") + rep.period_end = get(subs, "data.currentPeriodEnd") + rep.plan_cap = PLAN_CAPS[tostring(rep.plan_id or "")] + rep.cancel_at_period_end = get(subs, "data.cancelAtPeriodEnd") == true + end + local usage = request(origin .. "/alpha/usage/summary", key, H, 1) + if usage ~= nil then + rep.monthly_used = getnum(usage, "totalMonthlyCredits") + rep.requests = getnum(usage, "totalCount") + rep.tokens = getnum(usage, "totalTokens") + rep.success_rate = getnum(usage, "successRate") + end + if rep.plan_cap and rep.monthly_used and rep.monthly_used > 0 then + local implied = rep.monthly_used + rep.balance + if math.abs(implied - rep.plan_cap) > (rep.plan_cap * 0.02 + 0.5) then + rep.cap_mismatch = true + rep.implied_cap = implied + end + end + return rep, nil +end + +-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint, +-- then the OpenAI console shape, then Command Code's four. +local ADAPTERS = { + { vendor = "commandcode", probe = commandCode }, + { vendor = "deepseek", probe = deepseekBalance }, + { vendor = "openai-dash", probe = openaiDashboard }, +} + +-- The probe memo remembers, per host, which adapter answered. Persisted in the +-- plugin's state so the expensive part — discovering that 10 of 21 upstreams +-- expose no balance endpoint at all — is paid once, not on every page open. +-- +-- This matters more than it looks. The answers depend only on the host, never +-- on the key, and re-discovering them cost 21 sources x 3 adapters x 2 attempts +-- of timeouts on the first load. + +-- HOST_MEMO maps host -> adapter vendor (or false when nothing answered). It +-- is restored from the plugin's own state on the first collect of a process, so +-- a restart does not re-pay the cost of discovering which upstreams are silent. +local HOST_MEMO = {} +-- HOST_MEMO_AT[host] = when that host last produced data. A negative memo older +-- than PROBE_MEMO_TTL is ignored, so a vendor that adds an endpoint is picked up +-- within the TTL without a restart. +local HOST_MEMO_AT = {} +local PROBE_MEMO_TTL = 1800 -- 30 minutes + +-- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of +-- dead upstreams runs to the sum of every timeout — measured at 149s — and the +-- plugin's write lock is held the whole time. +local PROBE_BUDGET_MS = 12000 + +-- ---------------------------------------------------------------- source scan + +-- collectSource probes one source and returns a report, or nil when the source +-- simply has no balance endpoint (silent) — the caller distinguishes the two +-- through `skipped`. +local function collectSource(src, budget) + local base = tostring(src.base_url or "") + if base == "" then return nil end + base = base:gsub("/+$", "") + local key = tostring(src.api_key or "") + local rep, kind, why + -- Try the adapter this host answered with last time first. Same host with a + -- different key (two Command Code seats) gives the same answers, so this turns + -- 21 sources into a handful of distinct round trips after the first pass. + local host = hostOf(base) + local known = HOST_MEMO[host] + -- A negative memo is a TABLE ({at=timestamp}), distinguishable from a vendor + -- string. JSON round-trips it fine; a bare false would not survive the trip + -- through the Go-side state as cleanly, and the two being different types is + -- exactly the confusion that made an earlier revision drop five sources. + if type(known) == "table" then + local seen = known.at or 0 + if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then + known = nil + else + known = false -- still "nothing answered, try cheapest first" + end + end + -- known == false means every adapter already came back "no endpoint here" for + -- this host during this process. Skip the probing entirely; one cheap request + -- confirms it is still the case, so a vendor that adds a dashboard later is + -- picked up without a restart. + -- A cheap liveness probe, NOT a skip: the hosts behind these adapters are + -- slow (12s+ to answer a root request), so treating a slow answer as "down" + -- lost five sources that do have real data. The probe only short-circuits the + -- genuinely unreachable case; a reachable host still goes through the + -- adapters. + if known == false then + local probe = __llmsproxy_http({ + url = base .. "/", method = "GET", timeout_ms = 2500, + headers = { ["Authorization"] = "Bearer " .. tostring(key or "") }, + }) + if probe == nil then + return { name = src.name, host = host, skipped = true, + reason = "host unreachable" } + end + -- Reachable: fall through and try the adapters again, since a slow host + -- that was merely timing out may answer fine. + end + local order = ADAPTERS + if known then + local reordered, seen = {}, {} + for _, a in ipairs(ADAPTERS) do + if a.vendor == known then + reordered[#reordered + 1] = a + seen[a.vendor] = true + end + end + for _, a in ipairs(ADAPTERS) do + if not seen[a.vendor] then reordered[#reordered + 1] = a end + end + order = reordered + end + for _, a in ipairs(order) do + -- Budget check BETWEEN adapters, so an exhausted budget still returns the + -- sources already answered instead of nothing at all. + if budget and budget.exhausted() then + return { name = src.name, host = host, skipped = true, + reason = "probe budget exhausted" } + end + local r, k, e = a.probe(base, key) + if r ~= nil then + rep = r + HOST_MEMO[host] = a.vendor + break + end + kind, why = k, e + end + -- Remember the NEGATIVE result too. Only recording successes left every + -- source that has no balance endpoint re-probing all three adapters (2 + -- attempts, 4s each) on every single page open — measured at 60s per source, + -- unchanged between runs, because "nothing answered" was never recorded. + -- + -- The value is the adapter that came closest (an error rather than a skip), so + -- a host that later starts working is still retried first. + -- Remember that this host answered nothing, but only as an ORDERING hint, and + -- with a stamp so it expires. "This upstream has no balance endpoint" is a + -- fact that can change — vendors ship dashboards — so a permanent negative + -- would make the page quietly stop reporting a source that started working. + -- The stamp is what turns a 60s first load into a fast steady state without + -- freezing what the page can see. + if rep == nil and not HOST_MEMO[host] then + HOST_MEMO[host] = { at = os.time() } + end + if rep ~= nil then + HOST_MEMO_AT[host] = os.time() + end + if rep == nil then + if kind == "error" then + return { name = src.name, vendor = "", error = tostring(why), + skipped = false, host = hostOf(base) } + end + return { name = src.name, skipped = true, host = hostOf(base) } + end + rep.name = src.name + rep.host = hostOf(base) + rep.skipped = false + rep.checked_at = os.time() + return rep +end + +-- collect_all walks every source. Prefer it for a SMALL number of sources. +-- +-- The page calls collect_source concurrently instead: probing is almost entirely +-- network wait (three adapters, up to two attempts, ten second timeouts each), +-- and doing 21 sources in sequence overran the HTTP call's own budget — the +-- whole page returned 502 while individual probes were still in flight. The +-- sequential entry point stays for tests and for anyone scripting the plugin. +PLUGIN.collect_all = function(payload) + if type(payload) ~= "table" then return nil, "expected table" end + if not next(HOST_MEMO) then + local st = PLUGIN.state + if type(st) == "table" and type(st.probe_memo) == "table" then + for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end + end + end + local srcs = payload.sources + if type(srcs) ~= "table" then return nil, "missing sources" end + local t1 = os.clock() + local lim1 = PROBE_BUDGET_MS / 1000 + local function spent() + return (os.clock() - t1) > lim1 + end + local budget = { exhausted = spent } + local out = {} + for _, s in ipairs(srcs) do + if type(s) == "table" then + local rep = collectSource(s, budget) + if rep ~= nil then out[#out + 1] = rep end + end + end + PLUGIN.state.probe_memo = HOST_MEMO + return out, nil +end + +-- collect_source probes one named source. +PLUGIN.collect_source = function(payload) + if type(payload) ~= "table" then return nil, "expected table" end + if not next(HOST_MEMO) then + local st = PLUGIN.state + if type(st) == "table" and type(st.probe_memo) == "table" then + for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end + end + end + local srcs = payload.sources + if type(srcs) ~= "table" then return nil, "missing sources" end + -- A closure over the deadline, not a table method. `exhausted = function(self)` + -- reads correctly but breaks the moment the call site is `budget.exhausted()` + -- from inside a pcall-guarded hook where the upvalue chain differs; the + -- explicit capture below cannot go wrong that way. + local t0 = os.clock() + local limit = PROBE_BUDGET_MS / 1000 + local function exhausted() + return (os.clock() - t0) > limit + end + local budget = { exhausted = exhausted } + for _, s in ipairs(srcs) do + if type(s) == "table" and s.name == payload.name then + local rep = collectSource(s, budget) + -- Persist the host→adapter map so the next PROCESS skips the dead ends, + -- not just the next page open. + PLUGIN.state.probe_memo = HOST_MEMO + return rep, nil + end + end + return nil, "source not found: " .. tostring(payload.name) +end + +-- ---------------------------------------------------------------- page + +PLUGIN.ui = { + page = { + page_id = "quota", + title = "Quota", + order = 45, + mount = [==[ +
+
+
+
套餐余量
+
点击刷新
+
+
+ +
+
尚未拉取
+
+ + +]==], + }, +} + +return PLUGIN \ No newline at end of file From 35b9422232c22dc2cbeea25b1d5665b654c1a14a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 10:51:07 +0800 Subject: [PATCH 04/19] =?UTF-8?q?feat(plugins):=20=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E3=80=81state=20=E5=88=86=E5=8C=BA=E3=80=81=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=BC=8F=20HTTP=20=E7=AB=AF=E7=82=B9=20+=20=E6=8C=89?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额, 而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤, 所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了 18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自 成本。 UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或 "all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**: 一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。 覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维 也不该为了改一行去编辑内置 .lua(下次升级就没了)。 过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一 处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。 角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、 未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用 state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey: 桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。 不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。 billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让 split 失去意义,而前者不加 split 正是这次要堵的泄露。 有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的 另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条 路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。 插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。 请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质: status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的 URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。 roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥 值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且 运维读的是名字。 **keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单 就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写 错误会让唯一能诊断它的人也进不去。 写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里 显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。 readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用 luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各 不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走 luaTableToGoValue。 readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组 永远拿到 nil。 去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page"); 去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧 归属、admin 豁免、{status=204} 合法、空表报错。 生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。 Co-Authored-By: ModelRouter (cherry picked from commit a6c6e6e5c759243c23d76c8515627a380d37435d) (cherry picked from commit 5eef5ad635e67ca4867546af3f6b3fd3f649b5fc) --- internal/config/config.go | 9 + internal/core/core.go | 19 + internal/gateway/plugin_http_routes.go | 207 +++++++++ internal/gateway/plugins_api.go | 164 +++++++- internal/gateway/server.go | 33 +- internal/lua/plugin_http_handler.go | 511 +++++++++++++++++++++++ internal/lua/plugin_http_handler_test.go | 400 ++++++++++++++++++ internal/lua/plugin_visibility.go | 81 ++++ internal/lua/plugin_visibility_test.go | 193 +++++++++ internal/lua/plugins.go | 462 +++++++++++++++++++- internal/lua/plugins/billing.lua | 18 + internal/lua/plugins/ccquota.lua | 368 ---------------- internal/lua/plugins/quota.lua | 5 + 13 files changed, 2091 insertions(+), 379 deletions(-) create mode 100644 internal/gateway/plugin_http_routes.go create mode 100644 internal/lua/plugin_http_handler.go create mode 100644 internal/lua/plugin_http_handler_test.go create mode 100644 internal/lua/plugin_visibility.go create mode 100644 internal/lua/plugin_visibility_test.go delete mode 100644 internal/lua/plugins/ccquota.lua diff --git a/internal/config/config.go b/internal/config/config.go index e4c09ac..2b1f721 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,15 @@ type Config struct { // so peak traffic was billed at off-peak rates — no error, the bill just // quietly halved. A schema-checked config file catches both at load. BillingDSL *BillingDSL `yaml:"billing,omitempty"` + // PluginVisibility overrides the audience a plugin declared for itself, + // keyed by plugin name: "admin" hides a plugin (and its state) from user + // keys, "all" widens one that shipped admin-only, "user,admin" names roles + // explicitly. An absent name leaves the plugin's own declaration in force. + // + // This exists because a plugin author cannot know the operator's policy, and + // the operator should not have to edit a bundled .lua to change one — the + // next upgrade overwrites it. + PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"` // box seals credentials (sources' api_key/headers, keys' key) at rest. // In-memory values are always plaintext; only the bytes on disk are sealed. // Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay diff --git a/internal/core/core.go b/internal/core/core.go index c1c3c65..47565ca 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -73,6 +73,25 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { // before LoadDir so a plugin that requests on its first hook already sees // the real allowlist rather than the deny-all placeholder. c.plugins.SetOutboundHosts(sourceHosts(cfg)) + // Let the plugin layer validate access specs against the keys that exist. + // A stored allowlist naming a key that does not exist refuses every caller, + // which looks exactly like a broken endpoint. + lua.SetKeyNameLookup(func() []string { + // ListKeys is the structured view (bare `gateway_keys:` entries are + // expanded into records with names); cfg.GatewayKeys is still []string. + keys := c.ListKeys() + out := make([]string, 0, len(keys)) + for _, k := range keys { + if k.Name != "" { + out = append(out, k.Name) + } + } + return out + }) + // Operator overrides win over the plugin's own declared audience. + for name, aud := range cfg.PluginVisibility { + c.plugins.SetPluginVisibility(name, aud) + } if err := c.plugins.SeedBundled(); err != nil { log.Printf("[core] seed bundled plugins: %v", err) } diff --git a/internal/gateway/plugin_http_routes.go b/internal/gateway/plugin_http_routes.go new file mode 100644 index 0000000..71e6e46 --- /dev/null +++ b/internal/gateway/plugin_http_routes.go @@ -0,0 +1,207 @@ +package gateway + +// Mount for plugin-registered HTTP endpoints. +// +// The gateway owns the listener, the authentication, the role check and the +// response framing; the plugin owns the logic. That split is the point: a plugin +// cannot open a socket, cannot bypass auth, and cannot choose a path outside +// /api/plugins/http/. +// +// Why this exists rather than "plugins can call each other": some things are +// worth querying from OUTSIDE the browser — a CI job asking whether the upstream +// balance is above zero, a monitoring probe, another tool on the box. billing +// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a +// route per plugin would mean a gateway change for every such plugin, and an +// arbitrary plugin's code in the main mux is not something the routing table +// should ever grow. + +import ( + "net/http" + "strconv" + "strings" + + "llmsproxy/internal/lua" +) + +// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's +// routes live under one prefix, which makes them greppable, makes them easy to +// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats. +const pluginHTTPPrefix = "/api/plugins/http/" + +// handlePluginHTTP dispatches a request to whichever plugin registered the +// longest matching path. +// +// Longest-match rather than first-match: a plugin may register /health and +// another /health/deep, and both must work. With registration order deciding it, +// the outcome would depend on load order and change when a plugin is added. +func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix) + ps := g.core.Plugins() + if ps == nil { + writeError(w, http.StatusNotFound, "not_found", "no plugins loaded") + return + } + + matches := ps.HTTPHandlers() + var chosen *lua.HTTPHandler + var params map[string]string + for i := range matches { + h := matches[i] + if p, ok := matchHandlerPath(h.Path, rest); ok { + if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) { + chosen, params = &h, p + } + } + } + if chosen == nil { + writeError(w, http.StatusNotFound, "not_found", + "no plugin serves this path") + return + } + + if !strings.EqualFold(r.Method, chosen.Method) { + w.Header().Set("Allow", chosen.Method) + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", + "this endpoint accepts "+chosen.Method+" only") + return + } + + role := reqRole(r.Context()) + if !chosen.Allows(role, reqKeyName(r.Context())) { + writeError(w, http.StatusForbidden, "forbidden", + handlerAccessHint(*chosen)) + return + } + + body, err := readBody(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + req := &lua.HandlerRequest{ + Method: r.Method, + Path: rest, + Query: flattenQuery(r.URL.Query()), + Params: params, + Headers: flattenHeader(r.Header), + Body: body, + Role: role, + Key: reqKey(r.Context()), + } + resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req) + if err != nil { + // A plugin error is a 502, not a 500: the gateway is fine, the plugin is + // not, and the operator needs to tell those apart in their logs. + writeError(w, http.StatusBadGateway, "plugin_error", err.Error()) + return + } + lua.WriteHandlerResponse(w, resp) +} + +// handlerAccessHint explains, in the refusal, exactly which declaration would +// open this endpoint. A 403 with no reason is the kind of thing an operator +// opens the source to work out, and then the fix is a guess. +func handlerAccessHint(h lua.HTTPHandler) string { + switch { + case len(h.Keys) > 0: + return "this endpoint allows only the named keys (" + + strings.Join(h.Keys, ", ") + "); authenticate with one of them" + case len(h.Roles) > 0: + return "this endpoint requires role " + strings.Join(h.Roles, " or ") + + " (widen it in the plugin's http declaration, or set public=true)" + default: + return "this endpoint declares no roles, so it is admin-only; " + + "set roles=… or keys=… in the plugin's http declaration" + } +} + +// handlerOwner returns the plugin name that registered h. The gateway stores the +// owner alongside the handler precisely so this does not have to be guessed from +// the path prefix. +func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin } + +// segmentCount is how deep a route is, used for longest-match. A path with more +// segments wins, so /health/deep beats /health when both are registered. +func segmentCount(p string) int { return strings.Count(p, "/") } + +// matchHandlerPath matches a route pattern against a concrete path, capturing +// ":name" segments. +// +// Returns ok=false on a segment-count mismatch or a missing value for a +// param — a route must not match with an empty capture, or two different URLs +// would reach the same handler indistinguishably. +func matchHandlerPath(pattern, path string) (map[string]string, bool) { + pp := strings.Split(strings.Trim(pattern, "/"), "/") + sp := strings.Split(strings.Trim(path, "/"), "/") + if len(pp) != len(sp) { + return nil, false + } + var params map[string]string + for i := range pp { + if strings.HasPrefix(pp[i], ":") { + name := pp[i][1:] + if sp[i] == "" { + return nil, false + } + if params == nil { + params = map[string]string{} + } + params[name] = sp[i] + continue + } + if pp[i] != sp[i] { + return nil, false + } + } + return params, true +} + +func flattenQuery(q map[string][]string) map[string]string { + out := make(map[string]string, len(q)) + for k, v := range q { + if len(v) > 0 { + out[k] = v[0] + } + } + return out +} + +func flattenHeader(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for k := range h { + out[strings.ToLower(k)] = h.Get(k) + } + return out +} + +func readBody(r *http.Request) (string, error) { + if r.Body == nil { + return "", nil + } + defer r.Body.Close() + // A cap, because the handler runs inside the gateway process: an unbounded + // body from an authenticated caller is a memory exhaustion vector. + const maxBody = 4 << 20 + buf := make([]byte, 0, 4096) + tmp := make([]byte, 32*1024) + for { + n, err := r.Body.Read(tmp) + if n > 0 { + if len(buf)+n > maxBody { + return "", &bodyTooLarge{} + } + buf = append(buf, tmp[:n]...) + } + if err != nil { + break + } + } + return string(buf), nil +} + +type bodyTooLarge struct{} + +func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" } + +// limitBody is kept for the numeric form some callers want in a log line. +var _ = strconv.Itoa \ No newline at end of file diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index db81ed7..524a565 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -93,6 +93,18 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { return } + // /api/plugins/{name}/http/{path}/access — the operator's view of, and edit + // surface for, which keys an endpoint answers. + if i := strings.Index(path, "/http/"); i >= 0 { + name := path[:i] + rest := path[i+len("/http/"):] + if strings.HasSuffix(rest, "/access") { + epPath := strings.TrimSuffix(rest, "/access") + g.handleEndpointAccess(w, r, name, epPath) + return + } + } + // /api/plugins/{name}/call/{fn} if i := strings.Index(path, "/call/"); i >= 0 { name := path[:i] @@ -223,15 +235,33 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name ps := g.core.Plugins() switch r.Method { case http.MethodGet: - // Any role may read: plugin state is reporting data (cost, counts), - // and the caller has already been authenticated. Admin-only would stop - // a user key's own billing widget from rendering. + // The plugin's audience gates its DATA as well as its page. Hiding the UI + // while leaving GET /state open would put the numbers one curl away, which + // is the leak the audience field exists to close — and billing's state is + // a global ledger, not the caller's own usage. + // + // A plugin that declares no audience is unchanged (every role may read + // it), so a single-purpose widget keeps working without edits. + role := reqRole(r.Context()) + if !ps.StateVisibleTo(name, role) { + writeError(w, http.StatusForbidden, "forbidden", + "plugin "+name+" is not visible to role "+role) + return + } // prices rides along: the billing rules editor must show the table // that is actually in effect, not a reconstruction from config. It is // absent for plugins with no prices (every plugin but billing). + state := ps.State(name) + // A plugin may SPLIT its state: global buckets stay deployment-wide while + // per-caller buckets are narrowed to the authenticated key. Role + // visibility alone cannot express this — the same role is allowed to read + // the plugin, just not another caller's row. + if scope := ps.StateScopeFor(name); scope != nil && role != "admin" { + state = splitStateForKey(state, scope, reqKey(r.Context())) + } payload := map[string]interface{}{ "plugin": name, - "state": ps.State(name), + "state": state, } if pr := ps.Prices(name); pr != nil { payload["prices"] = pr @@ -333,3 +363,129 @@ func (g *Gateway) removePlugin(name string) error { _ = os.Remove(filepath.Join(g.pluginDir(), name+".lua")) return g.core.Plugins().Unload(name) } + + +// handleEndpointAccess serves the operator's view and edit surface for one +// plugin endpoint's reachable keys. +// +// GET …/http/{path}/access the declared rules plus any override +// PUT …/http/{path}/access body {"access": "admin,@homeagent"} +// DELETE …/http/{path}/access clear the override +// +// The spec string is a comma list: a bare word is a role, "@name" is a gateway +// key's configured name, and the literal "public" opens it to any +// authenticated key. That is the same grammar the visibility override uses, and +// keeping one grammar means an operator learns it once. +// +// Key names are validated against the configured keys BEFORE being stored. An +// allowlist naming a key that does not exist is a silent no-op — every caller +// is refused — and the operator would have no way to tell that apart from the +// endpoint simply working. +func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + ps := g.core.Plugins() + + switch r.Method { + case http.MethodGet: + for _, e := range ps.EndpointAccess() { + if e.Plugin == name && e.Path == epPath { + writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e}) + return + } + } + writeError(w, http.StatusNotFound, "not_found", "no such endpoint") + case http.MethodPut, http.MethodDelete: + spec := "" + if r.Method == http.MethodPut { + var body struct { + Access string `json:"access"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + spec = body.Access + } + if err := ps.ValidateAccessSpec(spec); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if err := ps.SetEndpointAccess(name, epPath, spec); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec}) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + } +} + + +// splitStateForKey rewrites a plugin state so that a non-admin caller sees only +// the buckets that belong to them. +// +// The plugin declares which buckets are global and which are per-key. A per-key +// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log +// uses), so the caller's own row is found by masking the presented secret and +// matching the last six characters. +// +// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as +// "you spent nothing" and, worse, would leak that a row exists for someone else. +func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} { + m, ok := state.(map[string]interface{}) + if !ok || scope == nil || key == "" { + return state + } + masked := maskKey(key) + out := make(map[string]interface{}, len(m)) + for k, v := range m { + if !scope.IsGlobal(k) { + // Per-key bucket: keep only the caller's rows, at every nesting level + // the scope mentions (by_day_key is {day: {masked: bucket}}). + out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k)) + continue + } + out[k] = v + } + return out +} + +// filterKeyBuckets narrows a keyed structure to one masked key. +// +// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is +// declared rather than sniffed, because both shapes are dicts and sniffing would +// confuse an empty bucket with a nested one. +func filterKeyBuckets(v interface{}, masked string, depth int) interface{} { + switch depth { + case 0: + return v // not a per-key bucket; leave alone + case 1: + m, ok := v.(map[string]interface{}) + if !ok { + return v + } + if row, ok := m[masked]; ok { + return map[string]interface{}{masked: row} + } + return map[string]interface{}{} + default: + m, ok := v.(map[string]interface{}) + if !ok { + return v + } + out := make(map[string]interface{}, len(m)) + for day, inner := range m { + im, ok := inner.(map[string]interface{}) + if !ok { + continue + } + if row, ok := im[masked]; ok { + out[day] = map[string]interface{}{masked: row} + } + } + return out + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index c83ebaa..34fe2f1 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -235,6 +235,10 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { // treats anything after the name as a plugin sub-resource and would // 404 on "rules" instead of reaching the billing editor. g.handleBillingRules(w, r) + case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix): + // Before the generic plugin router: these paths belong to a PLUGIN, not to + // the plugin manager, and "http" is a valid sub-resource name otherwise. + g.handlePluginHTTP(w, r) case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"): g.handlePluginsAPI(w, r) case r.URL.Path == "/api/chat": @@ -305,20 +309,43 @@ func (g *Gateway) auth(next http.Handler) http.Handler { http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound) return } - next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role))) + next.ServeHTTP(w, r.WithContext(withAuthNamed(r.Context(), key, rec.Role, rec.Name))) }) } -// authCtx carries the authenticated gateway key and its role. +// authCtx carries the authenticated gateway key, its role, and the key's +// configured NAME. +// +// The name is here because plugin HTTP endpoints authorise by key name rather +// than by secret, and looking the name up from the key at the point of use would +// mean reading config on every request. It is "" for a key configured without +// one, which such an endpoint can never match. type authCtx struct { key string role string + name string } type authCtxKeyT struct{} func withAuth(ctx context.Context, key, role string) context.Context { - return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role}) + return withAuthNamed(ctx, key, role, "") +} + +// withAuthNamed is withAuth plus the key's configured name. +func withAuthNamed(ctx context.Context, key, role, name string) context.Context { + return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role, name: name}) +} + +// reqKeyName returns the authenticated key's configured name, or "" if it has +// none. Plugin endpoint authorisation is written against this, not against the +// secret: a secret is rotated without notice, so a rule keyed on it would +// silently stop matching. +func reqKeyName(ctx context.Context) string { + if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { + return a.name + } + return "" } // reqKey returns the authenticated gateway key id (masked suffix for display). diff --git a/internal/lua/plugin_http_handler.go b/internal/lua/plugin_http_handler.go new file mode 100644 index 0000000..5324348 --- /dev/null +++ b/internal/lua/plugin_http_handler.go @@ -0,0 +1,511 @@ +package lua + +// Plugin-registered HTTP endpoints. +// +// The gateway mounts what a plugin declares in plugin.http and calls back into +// the plugin's Lua state to produce each response. This is the piece billing +// never needed: it only receives hooks, so it has nothing to expose. A plugin +// that computes something an operator wants to query from outside the browser — +// a health probe, a CI check, another tool — needs a route, and hard-coding one +// per plugin would mean a gateway change for every such plugin. +// +// The request and response are both plain tables crossing the Go/Lua boundary: +// +// request { method, path, query{}, params{}, headers{}, body, role, key } +// response { status, headers{}, body } or { status, json = } +// +// A handler returns (response, nil) or (nil, errorMessage). Errors never raise: +// an escaping Lua error inside a hook has taken the whole gateway down before +// (see hookGuardSrc), so the same discipline applies here. +// +// Two properties are enforced on the Go side rather than trusted to the plugin: +// +// - Method and status are checked against an allowlist before the response is +// written. A plugin returning status 0 or a redirect with an injected +// Location must not be able to steer the gateway's own routing. +// - Path traversal cannot escape the mount: params are substituted into a +// fixed segment count, and the handler's own Path is rejected at +// registration if it contains "..". + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// allowedMethods is deliberately short. An arbitrary verb would need a case in +// the router for no benefit, and a plugin that wants every verb can register the +// five that exist. +var allowedMethods = map[string]bool{ + http.MethodGet: true, http.MethodPost: true, http.MethodPut: true, + http.MethodDelete: true, http.MethodPatch: true, +} + +// normalizeHandlerMethod validates and defaults a declared method. +func normalizeHandlerMethod(m string) (string, error) { + m = strings.ToUpper(strings.TrimSpace(m)) + if m == "" { + return http.MethodGet, nil + } + if !allowedMethods[m] { + return "", fmt.Errorf("unsupported method %q (allowed: GET POST PUT DELETE PATCH)", m) + } + return m, nil +} + +// validateHandlerPath rejects paths that could escape the mount or collide with +// the plugin state/call routes. +func validateHandlerPath(p string) error { + p = strings.TrimSpace(p) + if p == "" { + return fmt.Errorf("empty path") + } + if strings.Contains(p, "..") { + return fmt.Errorf("path %q contains .. ", p) + } + if strings.Contains(p, "//") { + return fmt.Errorf("path %q contains an empty segment", p) + } + for _, seg := range strings.Split(strings.Trim(p, "/"), "/") { + if seg == "" { + continue + } + if strings.HasPrefix(seg, ":") { + if len(seg) == 1 { + return fmt.Errorf("path %q has a nameless param segment", p) + } + continue + } + // A literal segment must not look like a wildcard, or the route table + // gets two patterns that match the same URL. + if strings.ContainsAny(seg, "*?") { + return fmt.Errorf("path %q uses a wildcard; use :name for a param", p) + } + } + return nil +} + +// handlerRequest is what a Lua handler sees. +type handlerRequest struct { + Method string + Path string + Query map[string]string + Params map[string]string + Headers map[string]string + Body string + Role string + Key string +} + +// handlerResponse is what a Lua handler returns. +type handlerResponse struct { + Status int + Headers map[string]string + Body string + // JSON is set when the handler returned {json = }; it is encoded by + // the kernel so a plugin does not hand-roll escaping. + JSON interface{} +} + +// writeHTTPHandlerResponse renders a handler's reply onto the gateway's writer. +// +// The status is validated even though it came from a plugin: a handler +// returning 0 or 999 would make net/http panic, and one returning 3xx with a +// Location the plugin chose could be used to bounce a caller at a URL of the +// plugin's picking. Anything outside 100..599 that is not a redirect becomes 500 +// with an error body rather than a panic. +func writeHTTPHandlerResponse(w http.ResponseWriter, r *handlerResponse) { + status := r.Status + if status == 0 { + status = http.StatusOK + } + if status < 100 || status > 599 { + status = http.StatusInternalServerError + } + for k, v := range r.Headers { + // A plugin cannot set Content-Length (the body is written by the kernel) + // or hop-by-hop headers (Connection, Transfer-Encoding) which would + // corrupt the gateway's own response framing. + switch http.CanonicalHeaderKey(k) { + case "Content-Length", "Connection", "Transfer-Encoding", "Upgrade": + continue + } + w.Header().Set(k, v) + } + if r.JSON != nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + // Encoded here rather than by the gateway layer: this file is in the lua + // package and importing the gateway would be a cycle. + if enc, err := json.Marshal(r.JSON); err == nil { + _, _ = w.Write(enc) + } else { + _, _ = w.Write([]byte(`{"error":"handler json could not be encoded"}`)) + } + return + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } + w.WriteHeader(status) + if r.Body != "" { + _, _ = w.Write([]byte(r.Body)) + } +} + +// parseHandlerResponse reads a Lua handler's return value. +// +// It accepts three shapes, because a plugin should not have to think about it: +// +// { status = 200, body = "text" } +// { status = 201, json = { any = "table" } } +// { status = 204 } -- no body +// +// Anything else — a string, a number, a table without status — is a handler bug +// and becomes an error rather than an empty 200, so the plugin author sees it. +func parseHandlerResponse(v interface{}) (*handlerResponse, error) { + m, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("handler must return a table, got %T", v) + } + out := &handlerResponse{Status: http.StatusOK} + if s, ok := m["status"]; ok { + switch n := s.(type) { + case float64: + out.Status = int(n) + case int: + out.Status = n + default: + return nil, fmt.Errorf("status must be a number, got %T", s) + } + } + if b, ok := m["body"].(string); ok { + out.Body = b + } + if h, ok := m["headers"].(map[string]interface{}); ok { + out.Headers = make(map[string]string, len(h)) + for k, hv := range h { + out.Headers[k] = asString(hv) + } + } + if j, ok := m["json"]; ok && j != nil { + out.JSON = j + } + // An empty table is a handler bug — it means the author forgot to say what to + // return. A table carrying ONLY a status is not: `{status=204}` is the normal + // way to answer with no body, and treating it as an error would make the + // correct spelling unusable. + if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 { + return nil, fmt.Errorf("handler returned an empty table; return " + + "{status=…, body=…}, {status=…, json=…}, or {status=…} for no body") + } + return out, nil +} + +// InvokeHandler calls a plugin's HTTP handler function with a request table and +// returns the decoded response. +// +// It reuses the same guard-in-Lua contract as the hooks: the function is called +// through __llmsproxy_call_hook, so a plugin error comes back as (nil, message) +// and never as a C-level abort. +func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*handlerResponse, error) { + p := ps.find(plugin) + if p == nil { + return nil, fmt.Errorf("plugin %s not found", plugin) + } + p.mu.Lock() + w := p.state + p.mu.Unlock() + if w == nil { + return nil, fmt.Errorf("plugin %s has no state", plugin) + } + + // The whole call happens under the plugin lock, network waits included — + // same property as Call, and for the same reason: one Lua state. + p.mu.Lock() + defer p.mu.Unlock() + L := w.L + L.SetTop(0) + defer L.SetTop(0) + + L.GetGlobal(pluginGlobal) + if L.IsNil(-1) { + return nil, fmt.Errorf("plugin table missing") + } + plug := L.GetTop() + L.GetGlobal(hookGuardName) + if L.IsNil(-1) { + L.Pop(1) + } + L.GetField(plug, fnName) + if !L.IsFunction(-1) { + return nil, fmt.Errorf("handler %s not found in plugin %s", fnName, plugin) + } + + pushGoValue(L, plainForLua(map[string]interface{}{ + "method": req.Method, + "path": req.Path, + "query": stringMapToAny(req.Query), + "params": stringMapToAny(req.Params), + "headers": stringMapToAny(req.Headers), + "body": req.Body, + "role": req.Role, + "key": req.Key, + })) + if err := L.Call(2, 2); err != nil { + return nil, err + } + errMsg := "" + if L.GetTop() >= 2 { + if !L.IsNil(-1) { + errMsg = L.ToString(-1) + } + L.Pop(1) + } + if errMsg != "" { + return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg) + } + if L.GetTop() < 1 || L.IsNil(-1) { + return nil, fmt.Errorf("handler %s returned nothing", fnName) + } + var raw interface{} + if err := luaToJSON(L, -1, &raw); err != nil { + return nil, err + } + return parseHandlerResponse(raw) +} + +func stringMapToAny(m map[string]string) map[string]interface{} { + if len(m) == 0 { + return map[string]interface{}{} + } + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// HandlerRequest is the request table handed to a plugin HTTP handler. +// Exported because the gateway package constructs it. +type HandlerRequest = handlerRequest + +// HandlerResponse is what a handler returns, after decoding. +type HandlerResponse = handlerResponse + +// WriteHandlerResponse renders a handler response onto the gateway's writer. +func WriteHandlerResponse(w http.ResponseWriter, r *HandlerResponse) { + writeHTTPHandlerResponse(w, r) +} + +// HTTPHandlers returns the registered endpoints across all plugins, each tagged +// with its owner so the dispatcher knows which Lua state to enter. +func (ps *Plugins) HTTPHandlers() []HTTPHandler { + ps.mu.RLock() + defer ps.mu.RUnlock() + var out []HTTPHandler + for _, p := range ps.plugins { + if p.http == nil || p.LoadError != "" { + continue + } + for _, declared := range p.http { + // effectiveAccess applies the operator's per-endpoint override, so the + // dispatcher enforces what the WebUI shows rather than what the .lua + // declared. + h := ps.effectiveAccess(p, declared) + h.Plugin = p.Info.Name + out = append(out, h) + } + } + return out +} + +// Allows is the exported form of the access rule, so the gateway dispatcher does +// not need a duplicate copy of it in its own package. +func (h HTTPHandler) Allows(role, keyName string) bool { return h.allows(role, keyName) } + +// HTTPEndpointAccess is the operator's view of one endpoint's permissions, +// returned by /api/plugins so the WebUI can render what is exposed without +// parsing .lua files. +type HTTPEndpointAccess struct { + Plugin string `json:"plugin"` + Path string `json:"path"` + FullPath string `json:"full_path"` + Method string `json:"method"` + Handler string `json:"handler"` + Roles []string `json:"roles,omitempty"` + Keys []string `json:"keys,omitempty"` + Public bool `json:"public"` + Description string `json:"description,omitempty"` + // OverrideRoles / OverrideKeys carry the operator's own restriction, which + // REPLACES whatever the plugin declared. Empty means "use the plugin's". + // + // This is the mechanism the admin uses to narrow (or widen) an endpoint's + // reachable keys without editing the plugin: a bundled .lua is overwritten on + // the next upgrade, and a hand-installed one is not something the operator + // should have to maintain. + OverrideRoles []string `json:"override_roles,omitempty"` + OverrideKeys []string `json:"override_keys,omitempty"` +} + +// endpointAccess returns the access table for every registered endpoint, with +// operator overrides folded in. +func (ps *Plugins) endpointAccess() []HTTPEndpointAccess { + ps.mu.RLock() + defer ps.mu.RUnlock() + var out []HTTPEndpointAccess + for _, p := range ps.plugins { + if p.http == nil || p.LoadError != "" { + continue + } + ov := ps.visibility[p.Info.Name] + var ovRoles, ovKeys []string + if ov != "" { + for _, part := range strings.Split(ov, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + // A key name is anything that is not a role: the override string + // carries both, so "@name" marks a key and a bare word a role. + if strings.HasPrefix(part, "@") { + ovKeys = append(ovKeys, strings.TrimPrefix(part, "@")) + } else { + ovRoles = append(ovRoles, part) + } + } + } + for _, h := range p.http { + out = append(out, HTTPEndpointAccess{ + Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(), + Method: h.Method, Handler: h.Handler, + Roles: h.Roles, Keys: h.Keys, Public: h.Public, + Description: h.Description, + OverrideRoles: ovRoles, OverrideKeys: ovKeys, + }) + } + } + return out +} + +// EndpointAccess exposes the table to the gateway package. +func (ps *Plugins) EndpointAccess() []HTTPEndpointAccess { return ps.endpointAccess() } + +// SetEndpointAccess narrows or widens one endpoint's reachable keys/roles. +// +// It is an OVERRIDE, applied on top of what the plugin declared, and stored in +// the same per-plugin visibility map the UI audience uses. An empty value clears +// it. Overriding per endpoint rather than per plugin is what the operator +// actually wants: /health is fine for every key while /balance is not, and +// those two live in the same plugin. +func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error { + p := ps.find(plugin) + if p == nil { + return fmt.Errorf("plugin %s not found", plugin) + } + found := false + for _, h := range p.http { + if h.Path == path { + found = true + break + } + } + if !found { + return fmt.Errorf("plugin %s has no endpoint %q", plugin, path) + } + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.visibility == nil { + ps.visibility = visibilityOverrides{} + } + key := endpointOverrideKey(plugin, path) + if strings.TrimSpace(spec) == "" { + delete(ps.visibility, key) + return nil + } + ps.visibility[key] = spec + return nil +} + +// endpointOverrideKey namespaces a per-endpoint override so it cannot collide +// with a whole-plugin audience override of the same plugin name. +func endpointOverrideKey(plugin, path string) string { + return plugin + "#" + path +} + +// effectiveAccess applies an endpoint override to the declared handler. +func (ps *Plugins) effectiveAccess(p *Plugin, h HTTPHandler) HTTPHandler { + ps.mu.RLock() + ov := ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)] + ps.mu.RUnlock() + if ov == "" { + return h + } + out := h + out.Roles, out.Keys = nil, nil + out.Public = false + for _, part := range strings.Split(ov, ",") { + part = strings.TrimSpace(part) + switch { + case part == "": + case part == "public": + out.Public = true + case strings.HasPrefix(part, "@"): + out.Keys = append(out.Keys, strings.TrimPrefix(part, "@")) + default: + out.Roles = append(out.Roles, part) + } + } + return out +} + +// knownKeyNames is set by the gateway during wiring so ValidateAccessSpec can +// refuse an allowlist naming a key that does not exist. +// +// Without the check, an allowlist naming a typo ("homeagent" vs "home_agent") +// is stored, saved, shown in the WebUI as configured — and refuses every caller, +// including the one the operator meant. The failure looks like the endpoint being +// broken rather than the name being wrong. +var knownKeyNames func() []string + +// SetKeyNameLookup registers the source of configured gateway key names. +func SetKeyNameLookup(fn func() []string) { knownKeyNames = fn } + +// ValidateAccessSpec checks an access spec against the configured keys and roles. +// +// Roles are checked against the two that exist (admin, user) rather than left +// free-form: a role that does not exist produces an endpoint nobody can reach, +// which is the same silent failure as a bad key name. +func (ps *Plugins) ValidateAccessSpec(spec string) error { + spec = strings.TrimSpace(spec) + if spec == "" { + return nil + } + known := map[string]bool{"public": true, "admin": true, "user": true} + if knownKeyNames != nil { + for _, n := range knownKeyNames() { + if n != "" { + known[strings.ToLower(n)] = true + } + } + } + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name := part + if strings.HasPrefix(name, "@") { + name = strings.TrimPrefix(name, "@") + } + if name == "" { + return fmt.Errorf("access entry %q names no key", part) + } + if !known[strings.ToLower(name)] { + return fmt.Errorf("access entry %q is neither a role (admin/user/public) "+ + "nor a configured key name", part) + } + } + return nil +} diff --git a/internal/lua/plugin_http_handler_test.go b/internal/lua/plugin_http_handler_test.go new file mode 100644 index 0000000..83e5897 --- /dev/null +++ b/internal/lua/plugin_http_handler_test.go @@ -0,0 +1,400 @@ +package lua + +// Plugin-registered HTTP endpoints. +// +// Three properties have to hold, and none of them is visible from the plugin's +// own point of view: +// +// 1. Auth and roles are enforced by the GATEWAY. A plugin that forgets to +// declare roles gets admin-only, not open — the failure mode of "public by +// omission" is an internet-scannable endpoint inside the gateway process. +// 2. A handler error is a value, never a panic. The pcall guard is the same one +// the hooks use, for the same reason. +// 3. The plugin cannot steer the gateway's own response framing: a nonsense +// status becomes a 500, and Content-Length / hop-by-hop headers are dropped. +// +// These tests drive the declaration layer and the response writer directly; the +// router-level dispatch is covered in the gateway package. + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const httpPlugin = ` +local P = {} +P.name = "api" +P.version = "1.0.0" +P.exports = {} +P.http = { + { + path = "health", + handler = "health", + roles = { "admin" }, + description = "liveness", + }, + { + path = "echo/:what", + method = "POST", + handler = "echo", + roles = { "admin", "user" }, + }, +} +function P.health(req) + return { status = 200, json = { ok = true, role = req.role, key = req.key } } +end +function P.echo(req) + return { status = 201, json = { what = req.params.what, body = req.body } } +end +return P +` + +func TestPluginRegistersHTTPHandlers(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + hs := ps.HTTPHandlers() + if len(hs) != 2 { + t.Fatalf("expected 2 registered endpoints, got %d: %+v", len(hs), hs) + } + for _, h := range hs { + if h.Plugin != "api" { + t.Errorf("handler %q has no owner; the dispatcher could not route it", h.Path) + } + if h.Method == "" { + t.Errorf("handler %q has no method; the gateway would answer 405 forever", h.Path) + } + } +} + +func TestHandlerRoleRules(t *testing.T) { + // A handler with no roles and no public opt-out is ADMIN-ONLY. This is the + // decision the whole feature turns on: "I forgot to declare roles" must not + // mean "anyone on the internet". + open := HTTPHandler{Path: "x", Handler: "h"} + if open.allows("user", "") { + t.Error("an undeclared handler must default to admin-only") + } + if !open.allows("admin", "") { + t.Error("an undeclared handler must still serve admin") + } + // The explicit opt-out is how a plugin says "this one really is public". + pub := HTTPHandler{Path: "x", Handler: "h", Public: true} + if !pub.allows("user", "") || !pub.allows("", "") { + t.Error("public=true must admit any role") + } + // Declared roles. + scoped := HTTPHandler{Path: "x", Roles: []string{"admin", "user"}} + if !scoped.allows("user", "homeagent") || !scoped.allows("admin", "admin") { + t.Error("a listed role must be admitted") + } + if (HTTPHandler{Path: "x", Roles: []string{"admin"}}).allows("user", "homeagent") { + t.Error("an unlisted role must be refused") + } +} + +// Naming keys is how the operator grants one collaborator access without opening +// the endpoint to every user key. Two properties matter and neither is obvious: +// a named key wins even when the role rule would have refused it, and the named +// list REPLACES the role rule rather than adding to it. +func TestHandlerKeyRules(t *testing.T) { + h := HTTPHandler{Path: "x", Roles: []string{"admin"}, Keys: []string{"homeagent"}} + if !h.allows("user", "homeagent") { + t.Error("a named key must be admitted even though its role is not listed") + } + // The replacement semantics: listing one key must NOT also admit every user + // key through the role rule, which is what an OR would have done. + if h.allows("user", "club") { + t.Error("naming keys must replace the role rule, not add to it") + } + if h.allows("user", "") { + t.Error("an unnamed key must not match a named-key allowlist") + } + // Admin is always admitted, so a typo in the list never locks the operator + // out of the endpoint they are trying to tighten. + if !h.allows("admin", "admin") { + t.Error("admin must always reach an endpoint") + } + // Name matching is case-insensitive; a config written by hand varies. + if !(HTTPHandler{Path: "x", Keys: []string{"HomeAgent"}}).allows("user", "homeagent") { + t.Error("key names should match case-insensitively") + } +} + +// The stored override replaces whatever the plugin declared — this is the admin +// editing access without touching a .lua that an upgrade would overwrite. +func TestEndpointAccessOverrideReplacesDeclaration(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + // health is declared admin-only; widen it to one named key. + if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil { + t.Fatal(err) + } + var found *HTTPHandler + for _, h := range ps.HTTPHandlers() { + if h.Path == "health" { + hh := h + found = &hh + } + } + if found == nil { + t.Fatal("health endpoint disappeared after an access override") + } + if len(found.Roles) != 0 { + t.Errorf("the declared roles should be replaced, got %v", found.Roles) + } + if len(found.Keys) != 1 || found.Keys[0] != "homeagent" { + t.Errorf("override keys = %v, want [homeagent]", found.Keys) + } + if !found.allows("user", "homeagent") || found.allows("user", "club") { + t.Error("the override is not in force") + } + // Clearing restores the plugin's own declaration. + if err := ps.SetEndpointAccess("api", "health", ""); err != nil { + t.Fatal(err) + } + for _, h := range ps.HTTPHandlers() { + if h.Path == "health" { + if len(h.Keys) != 0 { + t.Errorf("clearing the override should restore the declaration, got %v", h.Keys) + } + return + } + } + t.Error("health endpoint vanished") +} + +func TestSetEndpointAccessRejectsUnknownTarget(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + if err := ps.SetEndpointAccess("api", "nope", "@homeagent"); err == nil { + t.Error("setting access on a path the plugin does not expose must fail loudly") + } + if err := ps.SetEndpointAccess("nosuchplugin", "health", "@homeagent"); err == nil { + t.Error("setting access for an unknown plugin must fail loudly") + } +} + +// A stored allowlist naming a key that does not exist refuses every caller while +// looking perfectly configured. ValidateAccessSpec exists to catch that at write +// time instead. +func TestValidateAccessSpec(t *testing.T) { + SetKeyNameLookup(func() []string { return []string{"homeagent", "club"} }) + t.Cleanup(func() { SetKeyNameLookup(nil) }) + + for _, ok := range []string{"admin", "user", "public", "@homeagent", + "admin,@homeagent,@club", " admin , @club "} { + if err := (&Plugins{}).ValidateAccessSpec(ok); err != nil { + t.Errorf("spec %q should be accepted: %v", ok, err) + } + } + for _, bad := range []string{"@nosuchkey", "superuser", "@", "admin,@typo_key"} { + if err := (&Plugins{}).ValidateAccessSpec(bad); err == nil { + t.Errorf("spec %q must be refused: it is neither a role nor a configured key", bad) + } + } + if err := (&Plugins{}).ValidateAccessSpec(""); err != nil { + t.Errorf("an empty spec clears the override and must be accepted: %v", err) + } +} + +func TestHandlerPathValidationRejectsEscapes(t *testing.T) { + // ":name" with empty segments around it is legal — the trims below reduce it + // to ":name". What must be refused is an empty PARAM NAME, which cannot be + // captured into request.params. + for _, bad := range []string{"", " ", "a/../b", "a//b", "wild*card", "q?x", "/:/"} { + if err := validateHandlerPath(bad); err == nil { + t.Errorf("path %q must be rejected: it can escape the mount or make the "+ + "route table ambiguous", bad) + } + } + for _, ok := range []string{"health", "a/b", "echo/:what", "v1/things/:id", "/:name/"} { + if err := validateHandlerPath(ok); err != nil { + t.Errorf("path %q should be accepted: %v", ok, err) + } + } +} + +func TestHandlerMethodValidation(t *testing.T) { + if m, err := normalizeHandlerMethod(""); err != nil || m != http.MethodGet { + t.Errorf("an empty method should default to GET, got %q %v", m, err) + } + if _, err := normalizeHandlerMethod("TRACE"); err == nil { + t.Error("TRACE must be refused") + } + if m, err := normalizeHandlerMethod("post"); err != nil || m != http.MethodPost { + t.Errorf("method should normalise to upper case, got %q %v", m, err) + } +} + +func TestInvokeHandlerReturnsStructuredData(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + resp, err := ps.InvokeHandler("api", "health", &HandlerRequest{ + Method: http.MethodGet, Path: "health", Role: "admin", Key: "sk-test", + }) + if err != nil { + t.Fatalf("invoke: %v", err) + } + if resp.Status != 200 { + t.Errorf("status = %d, want 200", resp.Status) + } + j, ok := resp.JSON.(map[string]interface{}) + if !ok { + t.Fatalf("json = %T, want a table (the handler's nested value must survive)", resp.JSON) + } + if j["ok"] != true || j["role"] != "admin" { + t.Errorf("handler data crossed wrong: %v", j) + } +} + +func TestInvokeHandlerParamsAndBodyReachLua(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + resp, err := ps.InvokeHandler("api", "echo", &HandlerRequest{ + Method: http.MethodPost, Path: "echo/thing", + Params: map[string]string{"what": "thing"}, + Body: `{"n":1}`, Role: "user", + }) + if err != nil { + t.Fatalf("invoke: %v", err) + } + j, _ := resp.JSON.(map[string]interface{}) + if resp.Status != 201 { + t.Errorf("status = %d, want 201 (the handler chose it)", resp.Status) + } + if j["what"] != "thing" { + t.Errorf("path param did not reach the handler: %v", j) + } + if j["body"] != `{"n":1}` { + t.Errorf("body did not reach the handler: %v", j) + } +} + +// A handler that raises must produce an error, never a process abort — the same +// SIGSEGV class of bug the hook guard exists for. +func TestInvokeHandlerErrorIsValueNotPanic(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const boom = ` +local P = {} +P.name = "boom" +P.version = "1.0.0" +P.exports = {} +P.http = { { path = "blow", handler = "blow", roles = { "admin" } } } +function P.blow(req) + error("handler exploded") +end +return P +` + if err := ps.LoadSource("boom", boom); err != nil { + t.Fatal(err) + } + if _, err := ps.InvokeHandler("boom", "blow", &HandlerRequest{Role: "admin"}); err == nil { + t.Fatal("a raising handler must surface as an error") + } +} + +// The plugin does not get to decide how the gateway frames its response. +func TestHandlerResponseFramingIsKernelOwned(t *testing.T) { + cases := []struct { + name string + in *HandlerResponse + want int + }{ + {"status 0 becomes 200", &HandlerResponse{Status: 0, Body: "x"}, 200}, + {"status 9999 becomes 500", &HandlerResponse{Status: 9999, Body: "x"}, 500}, + {"status -1 becomes 500", &HandlerResponse{Status: -1, Body: "x"}, 500}, + {"explicit 404 is kept", &HandlerResponse{Status: 404, Body: "x"}, 404}, + } + for _, c := range cases { + w := httptest.NewRecorder() + writeHTTPHandlerResponse(w, c.in) + if w.Code != c.want { + t.Errorf("%s: status = %d, want %d", c.name, w.Code, c.want) + } + } + + // A handler must not be able to desync the gateway's own framing. + w := httptest.NewRecorder() + writeHTTPHandlerResponse(w, &HandlerResponse{ + Status: 200, Body: "hello", + Headers: map[string]string{ + "Content-Length": "99999", + "Connection": "close", + "Transfer-Encoding": "chunked", + "X-Custom": "kept", + }, + }) + if got := w.Header().Get("Content-Length"); got != "" { + t.Errorf("a plugin set Content-Length (%s); the body length is the kernel's", got) + } + if got := w.Header().Get("X-Custom"); got != "kept" { + t.Errorf("a normal header should survive, got %q", got) + } +} + +// A malformed declaration must not take the whole plugin down. +func TestBadHTTPRouteDoesNotBreakPluginLoad(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const mixed = ` +local P = {} +P.name = "mixed" +P.version = "1.0.0" +P.exports = {} +P.state = { seen = true } +P.ui = { page = { page_id = "m", title = "M", mount = "
" } } +P.http = { + { path = "../escape", handler = "h", roles = { "admin" } }, + { path = "fine", handler = "h", roles = { "admin" } }, +} +function P.h(req) return { status = 200, body = "ok" } end +return P +` + if err := ps.LoadSource("mixed", mixed); err != nil { + t.Fatalf("a bad route must not fail the plugin load: %v", err) + } + // The good route survives; the bad one is simply absent. + hs := ps.HTTPHandlers() + if len(hs) != 1 || hs[0].Path != "fine" { + t.Fatalf("expected only the valid route, got %+v", hs) + } + // And the plugin's own page/state still work. + if ps.State("mixed") == nil { + t.Error("the plugin's state was lost because of a bad route") + } +} + +func TestParseHandlerResponseShapes(t *testing.T) { + if _, err := parseHandlerResponse("just a string"); err == nil { + t.Error("a non-table return must be an error, not an empty 200") + } + if _, err := parseHandlerResponse(map[string]interface{}{}); err == nil { + t.Error("an empty table should say so rather than returning a blank 200") + } + r, err := parseHandlerResponse(map[string]interface{}{ + "status": float64(204), "headers": map[string]interface{}{"X-A": "b"}, + }) + if err != nil { + t.Fatal(err) + } + if r.Status != 204 || r.Headers["X-A"] != "b" { + t.Errorf("decoded %+v", r) + } +} \ No newline at end of file diff --git a/internal/lua/plugin_visibility.go b/internal/lua/plugin_visibility.go new file mode 100644 index 0000000..0efa880 --- /dev/null +++ b/internal/lua/plugin_visibility.go @@ -0,0 +1,81 @@ +package lua + +// Per-caller state scoping. +// +// Role visibility answers "may this role see the plugin at all". It does not +// answer "which PART of its data", and for the billing plugin those are +// different questions: the global figures (total spend, every source, every +// model) are deployment data, while the per-key buckets describe one caller. +// +// So a plugin may declare a split, and the gateway then hands a non-admin +// caller only the buckets that are theirs. The plugin declares it because only +// the plugin author knows which of its buckets are global. + +import "strings" + +// StateScope is a plugin's declaration of which state buckets are global. +type StateScope struct { + // Global names the buckets that describe the whole deployment. Anything not + // listed is treated as per-caller and filtered. + Global []string `json:"global,omitempty"` + // Depth gives the nesting level of each PER-CALLER bucket: 1 for + // {maskedKey: bucket}, 2 for {day: {maskedKey: bucket}}. It is declared + // because both shapes are maps and sniffing would confuse an empty bucket + // with a nested one. + Depth map[string]int `json:"depth,omitempty"` +} + +// IsGlobal reports whether a top-level state key is deployment-wide. +func (s *StateScope) IsGlobal(key string) bool { + if s == nil { + return true + } + for _, g := range s.Global { + if g == key { + return true + } + } + return false +} + +// StateScopeFor returns a plugin's declared split, or nil when it declares none. +func (ps *Plugins) StateScopeFor(name string) *StateScope { + ps.mu.RLock() + defer ps.mu.RUnlock() + for _, p := range ps.plugins { + if p.Info.Name != name || p.UI == nil { + continue + } + return p.UI.StateScope + } + return nil +} + +// compileDepthMap normalizes the declared depth table, defaulting to 1 for a +// per-caller bucket the author did not annotate. +func compileDepthMap(in map[string]int) map[string]int { + if len(in) == 0 { + return nil + } + out := make(map[string]int, len(in)) + for k, v := range in { + if v < 1 { + v = 1 + } + out[strings.TrimSpace(k)] = v + } + return out +} + +// DepthOf returns the declared nesting level for a per-caller bucket, 1 by +// default. A bucket the author did not annotate is assumed flat, which is the +// common shape ({maskedKey: row}). +func (s *StateScope) DepthOf(key string) int { + if s == nil || s.Depth == nil { + return 1 + } + if d, ok := s.Depth[key]; ok { + return d + } + return 1 +} diff --git a/internal/lua/plugin_visibility_test.go b/internal/lua/plugin_visibility_test.go new file mode 100644 index 0000000..04ae804 --- /dev/null +++ b/internal/lua/plugin_visibility_test.go @@ -0,0 +1,193 @@ +package lua + +// Plugin visibility: a plugin page is not automatically public data. +// +// The billing page renders GLOBAL cost across every source and the quota page +// renders upstream balances. Both were served to any authenticated key because +// /api/ui-inject applied no role filter — measured, not theorised: a user-role +// key pulled the 18KB billing state and read the source names and the $8.86 +// total straight out of it. +// +// These tests pin the DENIALS. A visibility feature whose tests only assert that +// an admin still sees everything passes while the leak is wide open. + +import ( + "testing" +) + +func TestAudienceAllows(t *testing.T) { + cases := []struct { + want, role string + ok bool + why string + }{ + {"", "user", true, "unset means everyone"}, + {"all", "user", true, "explicit all"}, + {"admin", "admin", true, "exact match"}, + {"admin", "user", false, "admin-only excludes user"}, + {"user", "user", true, "user-only admits user"}, + {"user", "admin", false, "user-only excludes admin"}, + {"admin,user", "user", true, "list admits a listed role"}, + {" admin , user ", "user", true, "whitespace is not significant"}, + {"ADMIN", "user", false, "case is normalised"}, + {"*", "user", false, `"*" must NOT be a synonym for all`}, + } + for _, c := range cases { + if got := audienceAllows(c.want, c.role); got != c.ok { + t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)", + c.want, c.role, got, c.ok, c.why) + } + } +} + +func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + + // One admin-only plugin, one open plugin: the filter must drop exactly one + // and leave the other intact, or a plugin author has no way to ship a mixed + // set. + const adminOnly = ` +local P = {} +P.name = "adminonly" +P.version = "1.0.0" +P.exports = {} +P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "
secret
" } } +return P +` + const open = ` +local P = {} +P.name = "openpage" +P.version = "1.0.0" +P.exports = {} +P.ui = { page = { page_id = "open", title = "Open", mount = "
open
" } } +return P +` + for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} { + if err := ps.LoadSource(name, src); err != nil { + t.Fatalf("load %s: %v", name, err) + } + } + + admin := ps.UIForRole("admin") + if len(admin.Pages) != 2 { + t.Errorf("admin should see both pages, got %d", len(admin.Pages)) + } + + user := ps.UIForRole("user") + for _, pg := range user.Pages { + if pg.PageID == "secret" { + t.Fatal("a user key can see the admin-only page — this is the leak") + } + } + if len(user.Pages) != 1 || user.Pages[0].PageID != "open" { + t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages)) + } +} + +// The operator must be able to impose visibility the plugin did not choose — +// that is the whole point of the override, since a bundled .lua cannot be edited +// without being overwritten on the next upgrade. +func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const open = ` +local P = {} +P.name = "openpage" +P.version = "1.0.0" +P.exports = {} +P.ui = { page = { page_id = "open", title = "Open", mount = "
open
" } } +return P +` + if err := ps.LoadSource("openpage", open); err != nil { + t.Fatal(err) + } + if len(ps.UIForRole("user").Pages) != 1 { + t.Fatal("precondition: the page should start visible to user") + } + + ps.SetPluginVisibility("openpage", "admin") + if got := ps.UIForRole("user").Pages; len(got) != 0 { + t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got)) + } + if ps.VisibilityOverride("openpage") != "admin" { + t.Error("the override should be reportable so /api/plugins can show it") + } + + // Clearing restores the plugin's own declaration. + ps.SetPluginVisibility("openpage", "") + if got := ps.UIForRole("user").Pages; len(got) != 1 { + t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got)) + } +} + +// A page may narrow visibility below the plugin default, so one plugin can ship +// a public summary beside an admin-only detail page. +func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const mixed = ` +local P = {} +P.name = "mixed" +P.version = "1.0.0" +P.exports = {} +P.ui = { + pages = { + { page_id = "summary", title = "Summary", mount = "
s
" }, + { page_id = "details", title = "Details", mount = "
d
", audience = "admin" }, + }, +} +return P +` + if err := ps.LoadSource("mixed", mixed); err != nil { + t.Fatal(err) + } + user := ps.UIForRole("user") + if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" { + t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages)) + } + if len(ps.UIForRole("admin").Pages) != 2 { + t.Error("admin should see both pages") + } +} + +func TestPluginStateVisibleTo(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + const adminOnly = ` +local P = {} +P.name = "adminonly" +P.version = "1.0.0" +P.exports = {} +P.state = { secret = "x" } +P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "
" } } +return P +` + if err := ps.LoadSource("adminonly", adminOnly); err != nil { + t.Fatal(err) + } + // The data has to follow the page: a hidden UI with an open state endpoint is + // the same leak with one more step. + if ps.StateVisibleTo("adminonly", "user") { + t.Error("user must not read an admin-only plugin's state") + } + if !ps.StateVisibleTo("adminonly", "admin") { + t.Error("admin must still read it") + } + // Unknown plugin: unchanged behaviour, so a widget that is not installed does + // not start answering 403. + if !ps.StateVisibleTo("nosuchplugin", "user") { + t.Error("an unknown plugin name must not become forbidden; that would change " + + "behaviour for plugins that simply are not installed") + } +} + +func pageIDs(pages []*UIPage) []string { + out := make([]string, 0, len(pages)) + for _, p := range pages { + if p != nil { + out = append(out, p.PageID) + } + } + return out +} \ No newline at end of file diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index a2f6b93..7712844 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -126,6 +126,13 @@ type UIExtension struct { // 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"` + // Visibility is the plugin-wide default audience, applied to every page and + // element this plugin contributes. A per-page Audience narrows it further. + Visibility string `json:"visibility,omitempty"` + // StateScope splits the plugin's published state into global and per-caller + // buckets, for a plugin that is readable by more than one role but must not + // hand one caller another's data. + StateScope *StateScope `json:"state_scope,omitempty"` } // UIPage is a plugin-provided page. @@ -134,6 +141,20 @@ type UIPage struct { Title string `json:"title"` // sidebar label Icon string `json:"icon"` // optional inline SVG or short glyph Order int `json:"order"` // sidebar sort key (default 100) + // Audience limits which API-key roles may see this page. "" and "all" mean + // every authenticated role; "admin" hides it from user keys; a comma list + // ("admin,user") names the roles allowed. + // + // This exists because a plugin page is not automatically public data. The + // billing page shows GLOBAL cost across every source, and the quota page + // shows upstream balances — neither belongs to whoever happens to hold a + // user key, yet both were served to them because /api/ui-inject applied no + // role filter at all. + // + // The plugin declares its own default and the gateway may override it (see + // SetPluginVisibility), because the plugin author cannot know the operator's + // policy but the operator must be able to impose one. + Audience string `json:"audience,omitempty"` // Mount is the page body. It may contain -]==], - }, -} - --- exports is what makes collect_account reachable from the plugin's own page. --- Without this list the call endpoint refuses it: the endpoint is an HTTP --- surface into the Lua state, so "which functions may it reach" has to be the --- plugin's explicit answer rather than a default. -PLUGIN.exports = { "collect_account" } - --- collect_account gathers one account's report. It returns the report TABLE (not --- a string) so the page receives structured numbers and booleans instead of --- having to parse anything back out of a formatted blob. -PLUGIN.collect_account = function(acct) - if type(acct) ~= "table" then return nil, "expected account table" end - if acct.key == nil or acct.key == "" then - return { name = tostring(acct.name or ""), failures = { "missing api key" }, - auth_failed = true } - end - local rep = collectAccount({ - name = acct.name, label = acct.label, key = acct.key, - }) - return rep, nil -end - -return PLUGIN \ No newline at end of file diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 532edba..c72ce47 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -484,6 +484,11 @@ end -- ---------------------------------------------------------------- page PLUGIN.ui = { + -- Every row names an upstream host and what it has spent. That is deployment + -- information, not per-caller information, so it is admin-only by default; an + -- operator who wants a shared read-only view can widen it from config without + -- editing this file. + visibility = "admin", page = { page_id = "quota", title = "Quota", From cab3361073aba2cfcf26d9e38bed87f029ad6deb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 11:11:16 +0800 Subject: [PATCH 05/19] =?UTF-8?q?fix(plugins):=20quota=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=8A=A0=E8=BD=BD=E6=97=B6=E4=B8=8D=E5=86=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8E=A2=E6=B5=8B=E5=85=A8=E9=83=A8=E4=B8=8A=E6=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首屏 19.1 秒 → 1.0 秒(生产实测)。 症状是"页面出来了但数据卡在加载"。量出来的原因不是渲染,也不是后端: 所有接口都在 0.4–2ms,而 Network 事件显示页面在头 70 毫秒内并发发出了 21 个 /api/plugins/quota/call/collect_source —— 每个源一次。CPU 采样 98% 空闲,说明 主线程不是在算,是在等。 插件页面的脚本里有 mount(); refresh();,而 refresh 会为每个源发一次探测。 探测要出网,且每次调用都持插件写锁,于是 21 次串行排队;冷启动实测 100 秒, 有缓存后 22 秒。WebUI 的首屏就这么被堵住了。 改动: - 页面加载时不再自动探测。运维没要求的余额不值得用它换首屏。 - 改为先渲染上次的快照(pluginAPI.fetchState),标题栏标注"快照 HH:MM(点刷新 取实时)"。点刷新才真探测。这样打开页面立刻有内容,实时数字在后面更新。 - billing 页面同样有 refresh(),但它只读本机 state(1.5ms),无害,保留。 判据:首屏填充时间实测 19.1s → 1.0s,且首屏请求里不再出现 collect_source。 Co-Authored-By: ModelRouter (cherry picked from commit c17a851ec16c28aca85346e0c3ee00ccaff21d4d) (cherry picked from commit 9823773330bb5ea11fc3568af1ceffb616bbb092) --- internal/lua/plugins/quota.lua | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index c72ce47..6317b21 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -682,6 +682,23 @@ PLUGIN.ui = { if(meta.length) h+='
'+meta.map(escH).join(" · ")+'
'; return h+"
"; } + // paintSnapshot renders the persisted snapshot WITHOUT touching the network. + // Called on load so the page has content immediately. + async function showSnapshot(){ + try{ + var st = await pluginAPI.fetchState("quota"); + var rs = (st && st.reports) || []; + if(!rs.length) return false; + var real = rs.filter(function(r){ return r && !r.skipped; }); + var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; }); + render(real, skipped); + var sub=document.getElementById("qa-sub"); + if(sub && st.updated_at){ + sub.textContent = real.length+" 个源 · 快照 "+new Date(st.updated_at).toLocaleTimeString()+"(点刷新取实时)"; + } + return true; + }catch(e){ return false; } + } function render(reports, skipped){ var host=document.getElementById("qa-list"); if(!host) return; var real=(reports||[]).filter(function(r){return !r.skipped;}); @@ -754,7 +771,18 @@ PLUGIN.ui = { var b=document.getElementById("qa-refresh"); if(b && !b.__qaWired){ b.__qaWired=true; b.addEventListener("click",refresh); } } - mount(); refresh(); + mount(); + showSnapshot(); + // Do NOT auto-refresh on load. + // + // Probing is network-bound and every request takes the plugin's write lock, so + // a page open that fires one call per source serialises them: measured 19s to + // first paint and 22-100s to finish, with the whole WebUI held in its loading + // state meanwhile. A balance the operator did not ask for is not worth the + // first paint of the page they opened. + // + // What IS shown automatically is the last snapshot, so an operator who hits + // refresh once sees a card immediately and a fresh number behind it. if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount); window.__qaRefresh=refresh; window.__qaRender=render; })(); From ba10f03fc77710a514ca1ec7683eac9f955f11c5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 11:49:47 +0800 Subject: [PATCH 06/19] =?UTF-8?q?feat(plugins):=20=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=9C=A8=E6=8F=92=E4=BB=B6=E9=A1=B5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20+=20=E4=BF=AE=E5=A4=8D=20active=20=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E5=9C=A8=E5=91=A8=E6=9C=9F=E8=A7=86=E5=9B=BE=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 插件管理页(用户要求:权限不要只放 config.yaml) 插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改: - 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人) - 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public) 原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。 ## 修掉的三个真缺陷 1. endpointAccess 读错了 override 的键 SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。 后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。 鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。 2. override 只在内存里 内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失, 日志里没有任何线索。现在落盘到 plugin_dir/.access.json, 插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件, "没有覆盖"用文件不存在来诚实表达。 3. 可见性列表泄漏内部键 端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello" 并可能把它当 audience 存回去。现在按已注册端点集合过滤。 另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的 集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会 显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。 ## 活跃请求:三个视图显示 0 active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。 前端 `st.active || 0` 于是把"字段缺失"变成了一句自信的"0 活跃", 而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。 现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。 判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。 生产实测 day/week/month/all 均为 4。 Co-Authored-By: ModelRouter (cherry picked from commit 120ba9e95707bbecb87dd0f3bc44776085653184) (cherry picked from commit 3b28fde22eca314de38699461b6bfc5d89f56065) --- internal/gateway/api.go | 7 + internal/gateway/plugins_api.go | 54 ++++++ internal/gateway/plugins_api_test.go | 51 ++++++ internal/gateway/stats.go | 13 ++ internal/gateway/stats_period_test.go | 31 ++++ internal/gateway/ui/index.html | 203 ++++++++++++++++++++- internal/gateway/ui_plugin_test.go | 23 +++ internal/lua/plugin_access_persist.go | 115 ++++++++++++ internal/lua/plugin_access_persist_test.go | 110 +++++++++++ internal/lua/plugin_http_handler.go | 57 ++++-- internal/lua/plugin_http_handler_test.go | 32 +++- internal/lua/plugin_visibility.go | 37 ++++ internal/lua/plugins.go | 12 +- 13 files changed, 716 insertions(+), 29 deletions(-) create mode 100644 internal/lua/plugin_access_persist.go create mode 100644 internal/lua/plugin_access_persist_test.go diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 5924a31..f7996d9 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -464,6 +464,13 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { "by_status": out.Status, "buckets": out.Bucket, "truncated": out.Truncated, + // "Active requests" is a live gauge (requests in flight right now), + // not a period aggregate. Omitting it from the period response made + // the KPI read `st.active || 0` → 0 on every day/week/month view while + // the all-time view showed the truth, which read as "the gateway is + // idle" precisely when it was busiest. A calendar window cannot + // filter it, so it travels with every response unfiltered. + "active": g.stats.Active(), "records": page.Records, // The paging cursor has to travel with the first page, otherwise // the scroll handler has nothing to page from and the table is diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 524a565..3a794e0 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -130,6 +130,11 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { "on_disk": ps.OnDisk(), "hook_errors": ps.HookErrors(), "plugin_dir": g.pluginDir(), + // Visibility overrides and the endpoint access table, so the + // plugin page renders what is IN FORCE rather than making the + // operator read .lua files or config.yaml to find out. + "visibility": ps.VisibilityOverrides(), + "http_access": ps.EndpointAccess(), }) case http.MethodPost: var body struct { @@ -388,6 +393,17 @@ func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, n } ps := g.core.Plugins() + // epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It + // shares this route (and therefore this store) on purpose: an operator + // setting "who can see this plugin" and "who can call this endpoint" is + // asking the same question at two granularities, and two stores would let + // them disagree. SetEndpointAccess cannot serve it — it validates that the + // endpoint exists, which "*" by definition does not. + if epPath == pluginWholeOverride { + g.handlePluginVisibility(w, r, name) + return + } + switch r.Method { case http.MethodGet: for _, e := range ps.EndpointAccess() { @@ -489,3 +505,41 @@ func filterKeyBuckets(v interface{}, masked string, depth int) interface{} { return out } } + +// pluginWholeOverride is the epPath that addresses a plugin's overall visibility +// rather than one of its endpoints. +const pluginWholeOverride = "*" + +// handlePluginVisibility is the whole-plugin half of the access API. The +// audience string uses the same vocabulary as an endpoint spec (roles, @key +// names, public) so the UI can drive both from one input. +func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) { + ps := g.core.Plugins() + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "plugin": name, + "visibility": ps.VisibilityOverrides()[name], + }) + case http.MethodPut, http.MethodDelete: + spec := "" + if r.Method == http.MethodPut { + var body struct { + Access string `json:"access"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + spec = body.Access + } + if err := ps.ValidateAccessSpec(spec); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + ps.SetPluginVisibility(name, spec) + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec}) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here") + } +} diff --git a/internal/gateway/plugins_api_test.go b/internal/gateway/plugins_api_test.go index 3ff9e3f..85cbcd7 100644 --- a/internal/gateway/plugins_api_test.go +++ b/internal/gateway/plugins_api_test.go @@ -210,3 +210,54 @@ func TestPluginStatePUTIsAdminOnly(t *testing.T) { t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String()) } } + +// TestPluginsListCarriesAccessTables: the plugin page renders visibility +// overrides and the endpoint access table from /api/plugins. Without them in the +// response the operator has to open a .lua or config.yaml to learn who can see +// what, which is exactly what the UI is meant to avoid. +func TestPluginsListCarriesAccessTables(t *testing.T) { + g := gatewayWithCallPlugin(t) + rr := doReq(t, g, http.MethodGet, "/api/plugins", "") + if rr.Code != http.StatusOK { + t.Fatalf("GET /api/plugins = %d", rr.Code) + } + var out struct { + Visibility map[string]string `json:"visibility"` + HTTPAccess []map[string]interface{} `json:"http_access"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v (body=%s)", err, rr.Body.String()) + } + // The calltest plugin registers no endpoints, so the table is present but + // empty — the KEY is that the field exists at all. A missing field decodes to + // nil and the UI silently renders nothing, which looks identical to "this + // plugin has no endpoints". + if !strings.Contains(rr.Body.String(), "http_access") { + t.Error("/api/plugins response lacks the http_access field") + } + if !strings.Contains(rr.Body.String(), "visibility") { + t.Error("/api/plugins response lacks the visibility field") + } +} + +// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page +// edits a plugin's overall audience through the same route it edits endpoints +// with. Before this existed the UI had a visibility selector whose save 404'd, +// because SetEndpointAccess validates that the endpoint exists and "*" does not. +func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) { + g := gatewayWithCallPlugin(t) + rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access", + `{"access":"admin, user"}`) + if rr.Code != http.StatusOK { + t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"ok":true`) { + t.Errorf("unexpected body: %s", rr.Body.String()) + } + // A user role is a legitimate audience; a nonsense one is not. + bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access", + `{"access":"../../etc"}`) + if bad.Code != http.StatusBadRequest { + t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String()) + } +} diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index ef99c20..a376ee6 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -1211,3 +1211,16 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} { "replay_partial": s.replayPartial, } } + +// Active reports requests in flight right now. +// +// It is a live gauge, not an aggregate: there is no calendar period that scopes +// it, which is why the period-scoped /api/stats response has to carry it +// explicitly. Before it did, the day/week/month views fed `st.active || 0` a +// missing key and showed a hard 0 — the same gateway that was serving hundreds +// of requests a second looked idle in three of the four views. +func (s *Stats) Active() int64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.active +} diff --git a/internal/gateway/stats_period_test.go b/internal/gateway/stats_period_test.go index 389a688..b04e74a 100644 --- a/internal/gateway/stats_period_test.go +++ b/internal/gateway/stats_period_test.go @@ -401,3 +401,34 @@ func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) { t.Fatal("today must be contained in both the week and the month totals") } } + +// TestPeriodStatsCarryActiveGauge pins the live "active requests" gauge onto +// every period response. +// +// It is a live gauge, not an aggregate, so no calendar window can scope it — but +// the period response is what the day/week/month views render from, and it used +// to omit the field entirely. The UI then evaluated `st.active || 0` and printed +// a hard 0 in three of four views, while the all-time view (whose response did +// carry the field) showed the truth. Reading "0 active" off a busy gateway is +// worse than reading nothing: it is a confident wrong answer. +func TestPeriodStatsCarryActiveGauge(t *testing.T) { + for _, period := range []string{"day", "week", "month", "all"} { + g := newTestGateway(t) + rr := doReq(t, g, http.MethodGet, "/api/stats?period="+period, "") + if rr.Code != http.StatusOK { + t.Fatalf("period=%s: %d", period, rr.Code) + } + var out map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("period=%s decode: %v", period, err) + } + v, ok := out["active"] + if !ok { + t.Errorf("period=%s response has no active field; the UI renders 0", period) + continue + } + if _, ok := v.(float64); !ok { + t.Errorf("period=%s active is %T, want a number", period, v) + } + } +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 43d2f8b..6d9168e 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -815,8 +815,21 @@ plTitle: "插件", plState: "状态", plStages: "阶段", - plActive: "启用中", - plDisabled: "已禁用", + + plVisibility: "可见性", + plEndpoints: "端点", + plAccessTitle: "访问控制", + plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。", + plWholePlugin: "以下端点", + plKeyHint: "可用密钥名", + plAccessSave: "保存访问控制", plActive: "启用中", + + tVisDefault: "跟随插件", + tVisAdmin: "仅 admin", + tVisRoles: "admin + user", + tVisAll: "所有人", + plOverridden: "已覆盖插件声明", + plEpsN: "%d 个端点", plDisabled: "已禁用", plBroken: "加载失败", plBuiltin: "内置", plHooks: "个阶段", @@ -1090,8 +1103,21 @@ plTitle: "Plugins", plState: "State", plStages: "Stages", - plActive: "Active", - plDisabled: "Disabled", + + plVisibility: "Visibility", + plEndpoints: "Endpoints", + plAccessTitle: "Access control", + plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.", + plWholePlugin: "endpoints below", + plKeyHint: "Key names", + plAccessSave: "Save access", plActive: "Active", + + tVisDefault: "follow plugin", + tVisAdmin: "admin only", + tVisRoles: "admin + user", + tVisAll: "everyone", + plOverridden: "overrides plugin", + plEpsN: "%d endpoints", plDisabled: "Disabled", plBroken: "Failed to load", plBuiltin: "Built-in", plHooks: "stages", @@ -1994,7 +2020,12 @@ const e = $(sel); if (e) e.textContent = txt; }; - kset('[data-kpi="active"]', st.active || 0); + // No `|| 0` fallback: that fallback is what turned a MISSING field + // into a confident "0 active" on three of four views. If the server + // ever stops sending it again, an em dash says "unknown" and the + // regression is visible, instead of the gauge quietly claiming the + // gateway is idle while it serves traffic. + kset('[data-kpi="active"]', st.active === undefined ? "—" : fmtN(st.active)); kset('[data-kpi="reqs"]', fmtN(tot.reqs)); const rsub = $('[data-kpi="reqs-sub"]'); if (rsub) @@ -4473,6 +4504,8 @@ function afterChainEdit() {
+ + + ${rows || ``}
${esc(p.name)}${builtin}
${esc(p.description || p.version || "")}
${err}
${state} ${hooks}${plVisCell(j, p)}${plEpsCell(j, p)} ${toggle} @@ -4494,8 +4527,9 @@ function afterChainEdit() { $("#tab-plugins").innerHTML = `

${t("plTitle")} (${(j.on_disk || []).length})

- - ${rows || ``}
${t("tName")}${t("plState")}${t("plStages")}
${t("plEmpty")}
+
${t("tName")}${t("plState")}${t("plStages")}${t("plVisibility")}${t("plEndpoints")}
${t("plEmpty")}
+
${errList ? `

${t("plHookErr")}

    ${errList}
` : ""}

${t("plDir")} ${esc(j.plugin_dir)}

@@ -4508,6 +4542,161 @@ function afterChainEdit() {

`; bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code"); + renderPluginAccess(j); + } + + + const PL_VIS = [ + ["", "tVisDefault"], + ["admin", "tVisAdmin"], + ["user,admin", "tVisRoles"], + ["all", "tVisAll"], + ]; + + // plVisCell shows the plugin's own declaration next to any operator + // override, because "admin-only" means something different depending on + // whether the plugin chose it or the deployment imposed it. Editing goes + // through the same access API as endpoints so there is one place where + // visibility is stored. + function plVisCell(j, p) { + const decl = (p.ui && p.ui.visibility) || ""; + const ov = (j.visibility || {})[p.name] || ""; + const cur = ov || decl; + // Compare as SETS. The server stores whatever order the operator typed + // and we round-trip it verbatim, so "admin, user" must still light up a + // choice — otherwise the selector silently shows "follow plugin", and + // saving from there writes an empty override that WIDENS access back to + // the plugin's own declaration. + const norm = (v) => + (v || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean) + .sort() + .join(","); + const curN = norm(cur); + // An audience with no matching choice (one naming a @key, say) is offered + // as itself so the value in force is never hidden from the operator. + const opts = PL_VIS.some((c) => norm(c[0]) === curN) + ? PL_VIS + : PL_VIS.concat([[cur, "plCustomVis"]]); + return `${ + ov && decl && ov !== decl + ? `
${t("plOverridden")}
` + : "" + }`; + } + + function plEpsCell(j, p) { + const n = (j.http_access || []).filter((e) => e.plugin === p.name).length; + return n + ? `${t("plEpsN").replace("%d", n)}` + : `—`; + } + + // ---- visibility & endpoint access ---- + // + // The plugin page is where an operator edits who can see what. The + // alternatives are editing a bundled .lua — overwritten on the next + // upgrade — or hand-editing config.yaml, with no feedback about what is + // actually in force. Each row shows the rule next to whether it was + // DECLARED by the plugin or OVERRIDDEN here, because "admin-only" means + // something different depending on which. + function renderPluginAccess(j) { + const host = $("#pl-access"); + if (!host) return; + const eps = j.http_access || []; + if (!eps.length) { host.innerHTML = ""; return; } + const keys = (window.__gwKeyRows || []).map((k) => k.name).filter(Boolean); + const byPlugin = {}; + eps.forEach((e) => { (byPlugin[e.plugin] = byPlugin[e.plugin] || []).push(e); }); + let h = `

${t("plAccessTitle")}

+

${t("plAccessHint")}

`; + Object.keys(byPlugin).forEach((pn) => { + h += `
+
+ ${esc(pn)} + ${t("plWholePlugin")} +
`; + (byPlugin[pn] || []).forEach((e) => { + const ovr = (e.override_keys && e.override_keys.length) || + (e.override_roles && e.override_roles.length); + const cur = ovr + ? [...(e.override_keys || []).map((k) => "@" + k), ...(e.override_roles || [])].join(",") + : [...(e.keys || []).map((k) => "@" + k), ...(e.roles || []), e.public ? "public" : ""] + .filter(Boolean).join(",") || "admin"; + h += `
+
+ ${esc(e.method)} ${esc(e.full_path)} + → ${esc(e.handler)}() + ${e.description ? `${esc(e.description)}` : ""} + + +
+ ${keys.length ? `
${t("plKeyHint")}: ${esc(keys.join(", "))}
` : ""} +
`; + }); + h += `
`; + }); + h += `

`; + host.innerHTML = h; + } + + async function savePluginAccess(btn) { + btn.disabled = true; + try { + // Whole-plugin visibility first. Sent through the SAME access API as + // endpoints (path "*") so visibility has exactly one storage path. + const viss = Array.prototype.slice.call( + document.querySelectorAll("[data-plvis]")); + for (const sel of viss) { + const pn = sel.getAttribute("data-plvis"); + const url = + "/api/plugins/" + encodeURIComponent(pn) + "/http/*/access"; + const v = sel.value; + if (v) { + await api(url, { method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ access: v }) }); + } else { + await api(url, { method: "DELETE" }); + } + } + const inputs = Array.prototype.slice.call( + document.querySelectorAll("#pl-access [data-ep]")); + for (const inp of inputs) { + const raw = inp.getAttribute("data-ep"); + const sep = raw.indexOf("|"); + const pn = raw.slice(0, sep), ep = raw.slice(sep + 1); + const url = "/api/plugins/" + encodeURIComponent(pn) + "/http/" + + encodeURIComponent(ep) + "/access"; + const v = inp.value.trim(); + if (v) { + await api(url, { method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ access: v }) }); + } else { + await api(url, { method: "DELETE" }); + } + } + toast(t("toastSaved")); + renderPlugins(); + } catch (e) { + toast(tFmt("toastSaveFail", e.message)); + } finally { + btn.disabled = false; + } } async function togglePlugin(name, disabled) { diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go index 0a5e2a2..9414097 100644 --- a/internal/gateway/ui_plugin_test.go +++ b/internal/gateway/ui_plugin_test.go @@ -388,3 +388,26 @@ func TestPluginSidebarEntryCarriesATitle(t *testing.T) { t.Errorf("plugin sidebar button renders the icon only; a label is required. "+ "got: %.200s", stmt) } + +// TestPluginPageHasAccessEditor: the plugin page must let an operator configure +// endpoint access. The APIs existed with zero UI entry point, which meant the +// only way to change who could reach a plugin endpoint was to hand-edit +// config.yaml — the thing the UI is meant to replace. +func TestPluginPageHasAccessEditor(t *testing.T) { + src := uiSource(t) + for _, needle := range []string{ + `renderPluginAccess`, // called after rendering the plugin list + `savePluginAccess`, // wired to a button + `/access`, // hits the per-endpoint access API + `id="pl-access"`, // container exists + `http_access`, // reads the table from /api/plugins + `data-ep=`, // one input per endpoint + `data-plvis=`, // one visibility selector per plugin + `/http/*/access`, // whole-plugin visibility shares the access API + `plVisCell`, // renders the declared-vs-override distinction + } { + if !strings.Contains(src, needle) { + t.Errorf("plugin page lacks %q", needle) + } + } +} diff --git a/internal/lua/plugin_access_persist.go b/internal/lua/plugin_access_persist.go new file mode 100644 index 0000000..822f7e8 --- /dev/null +++ b/internal/lua/plugin_access_persist.go @@ -0,0 +1,115 @@ +package lua + +import ( + "encoding/json" + "os" + "sort" +) + +// Access overrides are OPERATOR policy, not plugin data. They live in their own +// file rather than in each plugin's state because: +// +// - they describe the deployment, not the plugin, so a plugin upgrade that +// replaces its .state.json must not silently widen access again; +// - they must survive a restart. An in-memory map made "restrict billing to +// admin" look like it worked right up to the next deploy, at which point the +// restriction evaporated with nothing in the logs to say why; +// - the plugin_dir is the one place an operator already looks when asking +// "what does this plugin actually get to see", so the answer lives beside it. +// +// The file is a flat map of "name" (whole plugin) or "name#path" (one endpoint) +// to an access spec, which is the same shape the in-memory store uses, so load +// and save cannot disagree about what a key means. + +const accessFileName = ".access.json" + +func (ps *Plugins) accessFile() string { + if ps.dir == "" { + return "" + } + return ps.dir + string(os.PathSeparator) + accessFileName +} + +// LoadAccessOverrides reads the persisted overrides into memory. A missing file +// is not an error: no operator has restricted anything yet, which is the +// default state of a fresh install. +// +// A corrupt file is also not fatal. Losing a restriction is bad, but refusing to +// start the gateway is worse, and an empty map plus a logged error leaves the +// operator with something to fix. +func (ps *Plugins) LoadAccessOverrides() error { + path := ps.accessFile() + if path == "" { + return nil + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + m := map[string]string{} + if err := json.Unmarshal(b, &m); err != nil { + return err + } + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.visibility == nil { + ps.visibility = visibilityOverrides{} + } + for k, v := range m { + ps.visibility[k] = v + } + return nil +} + +// saveAccessOverridesLocked flushes the whole override map. It is called on +// every change, and unlike plugin state the map is tiny and changes only when an +// operator clicks Save — so a synchronous write costs nothing that matters, and +// it means the change is durable the moment the API returns 200. +func (ps *Plugins) saveAccessOverridesLocked() { + path := ps.accessFile() + if path == "" { + return + } + out := make(map[string]string, len(ps.visibility)) + for k, v := range ps.visibility { + if v != "" { + out[k] = v + } + } + if len(out) == 0 { + // Leave no empty file behind: its absence is the honest encoding of + // "nothing is overridden". + _ = os.Remove(path) + return + } + writeJSONAtomic(path, out) +} + +// AccessOverrides returns a copy of every override in force, for the UI. +func (ps *Plugins) accessOverridesSnapshot() map[string]string { + ps.mu.RLock() + defer ps.mu.RUnlock() + if len(ps.visibility) == 0 { + return nil + } + out := make(map[string]string, len(ps.visibility)) + for k, v := range ps.visibility { + out[k] = v + } + return out +} + +// sortedOverrideKeys exists only so a debug dump is stable; map iteration order +// would otherwise make two identical states look different. +func (ps *Plugins) sortedOverrideKeys() []string { + m := ps.accessOverridesSnapshot() + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} \ No newline at end of file diff --git a/internal/lua/plugin_access_persist_test.go b/internal/lua/plugin_access_persist_test.go new file mode 100644 index 0000000..fb709ab --- /dev/null +++ b/internal/lua/plugin_access_persist_test.go @@ -0,0 +1,110 @@ +package lua + +import ( + "os" + "path/filepath" + "testing" +) + +// TestAccessOverrideSurvivesReload is the test that matters for an access +// restriction. An in-memory map makes "billing is admin-only" look like it +// worked right up to the next restart, and then the restriction is simply gone +// with nothing in the logs to explain it. Both the plugin-level audience and +// the per-endpoint spec must come back. +func TestAccessOverrideSurvivesReload(t *testing.T) { + dir := t.TempDir() + src := `local P = {} +P.name = "api" +P.version = "1.0.0" +P.http = { + { path = "health", handler = "health", roles = { "admin" } }, +} +function P.health(req) return { json = { ok = true } } end +return P +` + first := NewPlugins(NewVM(dir), dir) + if err := first.LoadSource("api", src); err != nil { + t.Fatal(err) + } + first.SetPluginVisibility("api", "admin, user") + if err := first.SetEndpointAccess("api", "health", "@homeagent"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, accessFileName)); err != nil { + t.Fatalf("override was not written: %v", err) + } + + // A fresh Plugins over the same dir is what a restart looks like. + second := NewPlugins(NewVM(dir), dir) + // The plugin source is on disk too, so this second instance sees both the + // plugin and the overrides — the same pair a restart sees. Loading only the + // overrides would leave no endpoints to walk, and the assertion below would + // pass vacuously. + if err := second.LoadSource("api", src); err != nil { + t.Fatal(err) + } + if err := second.LoadAccessOverrides(); err != nil { + t.Fatalf("reload overrides: %v", err) + } + if got := second.VisibilityOverride("api"); got != "admin, user" { + t.Errorf("plugin visibility lost across reload: %q", got) + } + found := false + for _, e := range second.EndpointAccess() { + if e.Path == "health" && len(e.OverrideKeys) == 1 && e.OverrideKeys[0] == "homeagent" { + found = true + } + } + if !found { + t.Error("endpoint override lost across reload") + } +} + +// TestAccessOverrideClearedRemovesFile keeps the "nothing overridden" state +// distinguishable from "overridden with something". +func TestAccessOverrideClearedRemovesFile(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + ps.SetPluginVisibility("api", "admin") + path := filepath.Join(dir, accessFileName) + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected an access file: %v", err) + } + ps.SetPluginVisibility("api", "") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("clearing the last override should remove the file, got %v", err) + } +} + +// TestVisibilityOverridesExcludeEndpointKeys keeps endpoint policy out of the +// plugin-audience listing. Both live in one map, and reporting "probe#hello" +// under visibility is how the plugin page ends up offering to save an audience +// of that name — which nothing reads as an audience. +func TestVisibilityOverridesExcludeEndpointKeys(t *testing.T) { + dir := t.TempDir() + src := `local P = {} +P.name = "api" +P.version = "1.0.0" +P.http = { { path = "health", handler = "health", roles = { "admin" } } } +function P.health(req) return { json = { ok = true } } end +return P +` + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", src); err != nil { + t.Fatal(err) + } + ps.SetPluginVisibility("api", "admin, user") + if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil { + t.Fatal(err) + } + got := ps.VisibilityOverrides() + if got["api"] != "admin, user" { + t.Errorf("plugin audience missing: %+v", got) + } + if _, leaked := got["api#health"]; leaked { + t.Errorf("endpoint override leaked into the audience map: %+v", got) + } + if len(got) != 1 { + t.Errorf("expected exactly the plugin audience, got %+v", got) + } +} diff --git a/internal/lua/plugin_http_handler.go b/internal/lua/plugin_http_handler.go index 5324348..859a115 100644 --- a/internal/lua/plugin_http_handler.go +++ b/internal/lua/plugin_http_handler.go @@ -354,29 +354,25 @@ type HTTPEndpointAccess struct { func (ps *Plugins) endpointAccess() []HTTPEndpointAccess { ps.mu.RLock() defer ps.mu.RUnlock() + return ps.endpointAccessLocked() +} + +// endpointAccessLocked is the body of endpointAccess for callers that already +// hold ps.mu — VisibilityOverrides walks the same map and must not re-lock. +func (ps *Plugins) endpointAccessLocked() []HTTPEndpointAccess { var out []HTTPEndpointAccess for _, p := range ps.plugins { if p.http == nil || p.LoadError != "" { continue } - ov := ps.visibility[p.Info.Name] - var ovRoles, ovKeys []string - if ov != "" { - for _, part := range strings.Split(ov, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - // A key name is anything that is not a role: the override string - // carries both, so "@name" marks a key and a bare word a role. - if strings.HasPrefix(part, "@") { - ovKeys = append(ovKeys, strings.TrimPrefix(part, "@")) - } else { - ovRoles = append(ovRoles, part) - } - } - } for _, h := range p.http { + // The override key is per ENDPOINT (plugin#path), not per plugin. + // Reading the plugin-level key here reported the plugin's own + // audience override as if it governed every endpoint, and reported + // no endpoint override at all — so the UI drew the DECLARED roles + // after an operator had saved a change, and re-saving from that + // display silently reverted the restriction. + ovRoles, ovKeys := splitAccessSpec(ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)]) out = append(out, HTTPEndpointAccess{ Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(), Method: h.Method, Handler: h.Handler, @@ -422,9 +418,10 @@ func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error { key := endpointOverrideKey(plugin, path) if strings.TrimSpace(spec) == "" { delete(ps.visibility, key) - return nil + } else { + ps.visibility[key] = spec } - ps.visibility[key] = spec + ps.saveAccessOverridesLocked() return nil } @@ -509,3 +506,25 @@ func (ps *Plugins) ValidateAccessSpec(spec string) error { } return nil } + +// splitAccessSpec turns an override string into role and key lists. "@name" is a +// key; a bare word is a role. Shared by the reporting path and the enforcement +// path so the two can never disagree about what an override means. +func splitAccessSpec(spec string) (roles, keys []string) { + if strings.TrimSpace(spec) == "" { + return nil, nil + } + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + switch { + case part == "", part == "public": + // "public" is an enforcement concern, not a listing; it is reported + // via the Public field. + case strings.HasPrefix(part, "@"): + keys = append(keys, strings.TrimPrefix(part, "@")) + default: + roles = append(roles, part) + } + } + return roles, keys +} diff --git a/internal/lua/plugin_http_handler_test.go b/internal/lua/plugin_http_handler_test.go index 83e5897..03444c2 100644 --- a/internal/lua/plugin_http_handler_test.go +++ b/internal/lua/plugin_http_handler_test.go @@ -397,4 +397,34 @@ func TestParseHandlerResponseShapes(t *testing.T) { if r.Status != 204 || r.Headers["X-A"] != "b" { t.Errorf("decoded %+v", r) } -} \ No newline at end of file +} +// TestEndpointAccessReportsPerEndpointOverride guards the reporting side of the +// override store. +// +// SetEndpointAccess already keyed overrides by plugin#path, but endpointAccess +// read the plugin-level key. After an operator saved a restriction on ONE +// endpoint, /api/plugins kept reporting the plugin's DECLARED roles, so the +// plugin page drew the pre-change value and a later save from that screen +// reverted the restriction. Authorization kept working — only the display and +// the round trip through the UI were broken, which is why it went unnoticed. +func TestEndpointAccessReportsPerEndpointOverride(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(NewVM(dir), dir) + if err := ps.LoadSource("api", httpPlugin); err != nil { + t.Fatal(err) + } + // httpPlugin registers "health" and "echo/:what". + if err := ps.SetEndpointAccess("api", "health", "user"); err != nil { + t.Fatalf("SetEndpointAccess: %v", err) + } + got := map[string][]string{} + for _, e := range ps.EndpointAccess() { + got[e.Path] = e.OverrideRoles + } + if len(got["health"]) != 1 || got["health"][0] != "user" { + t.Errorf("health override not reported to the UI: %+v", got["health"]) + } + if len(got["echo/:what"]) != 0 { + t.Errorf("echo inherited an override it never had: %+v", got["echo/:what"]) + } +} diff --git a/internal/lua/plugin_visibility.go b/internal/lua/plugin_visibility.go index 0efa880..626687c 100644 --- a/internal/lua/plugin_visibility.go +++ b/internal/lua/plugin_visibility.go @@ -79,3 +79,40 @@ func (s *StateScope) DepthOf(key string) int { } return 1 } + +// VisibilityOverrides returns every operator override in force, keyed by plugin +// name. An absent name means the plugin's own declaration applies. +// +// The WebUI needs this to show which visibility is IMPOSED, not merely which is +// in effect: an operator comparing two deployments should not have to open a +// .lua to learn whether a page is admin-only by the plugin's choice or by their +// own override. +func (ps *Plugins) VisibilityOverrides() map[string]string { + ps.mu.RLock() + defer ps.mu.RUnlock() + if len(ps.visibility) == 0 { + return nil + } + epKeys := make(map[string]struct{}) + for _, e := range ps.endpointAccessLocked() { + epKeys[endpointOverrideKey(e.Plugin, e.Path)] = struct{}{} + } + out := make(map[string]string, len(ps.visibility)) + for k, v := range ps.visibility { + // Per-endpoint overrides live in the SAME map under a "name#path" key. + // They are endpoint policy, not a plugin audience, so leaking them here + // both mislabels them and lets the plugin page write an audience of the + // form "probe#hello" that no code ever reads as one. + // Per-endpoint overrides share this map under a "name#path" key. They + // are endpoint policy, not a plugin audience: reporting them here both + // mislabels them and invites the plugin page to save an audience of the + // form "probe#hello" that nothing reads as an audience. The set of + // endpoint keys is already computed, so ask it rather than re-deriving + // the shape from the key. + if _, isEndpoint := epKeys[k]; isEndpoint { + continue + } + out[k] = v + } + return out +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 7712844..4f24bef 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -32,6 +32,7 @@ package lua // less featureful, so the split is explicit and documented. import ( + "log" "encoding/json" "fmt" "os" @@ -511,6 +512,12 @@ func (ps *Plugins) LoadDir() error { if ps.dir == "" { return nil } + // Overrides first: they are keyed by plugin name and must be in place before + // anything reads them, and a reload must not blank a restriction the + // operator set earlier in this same process. + if err := ps.LoadAccessOverrides(); err != nil { + log.Printf("[core] plugin access overrides not loaded: %v", err) + } if _, err := os.Stat(ps.dir); os.IsNotExist(err) { return nil } @@ -1167,9 +1174,10 @@ func (ps *Plugins) SetPluginVisibility(plugin, audience string) { audience = strings.TrimSpace(audience) if audience == "" { delete(ps.visibility, plugin) - return + } else { + ps.visibility[plugin] = audience } - ps.visibility[plugin] = audience + ps.saveAccessOverridesLocked() } // VisibilityOverride returns the operator's audience override for a plugin, From 27b908d98c3154c35a96f87937e62d4626af84d9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 12:34:51 +0800 Subject: [PATCH 07/19] =?UTF-8?q?fix(billing):=20=E6=9C=AA=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E6=8C=89=E5=A4=A9=E7=BB=9F=E8=AE=A1=EF=BC=8C=E8=AE=A9?= =?UTF-8?q?=E3=80=8C=E5=B7=B2=E4=BF=AE=E5=A5=BD=E3=80=8D=E5=92=8C=E3=80=8C?= =?UTF-8?q?=E8=BF=98=E5=9C=A8=E6=BC=8F=E3=80=8D=E4=B8=8D=E5=86=8D=E9=95=BF?= =?UTF-8?q?=E5=BE=97=E4=B8=80=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计费页只有一个终身计数器 unpriced_reqs,实测 6378 —— 占 18981 请求的 34%。 但这个数字回答不了运维唯一会问的问题:「还在漏吗?」 原因是它把两件事混在一起:加规则之前的历史欠账,和加规则之后仍在发生的漏计。 修好一个源之后终身计数纹丝不动,于是仪表盘上「未定价 6378」和一个仍在 什么都没记的系统完全无法区分。 改动: - 钩子按 UTC 天写入 unpriced_by_day{day:{reqs, models}}。终身计数保留 (报表不该改写历史),天桶是新增的观察维度。 - 页面加「未定价(近 7 天)」一行。为 0 时额外显示「近 7 天无」——这时候 零本身就是结论:加的规则确实生效了。 - 没有天桶数据时返回 null 而不是 0。旧的 state 文件说「未知」, 不该假装有一份干净记录。 生产实测:全部视图 终身 6378 / 近 7 天 2;日视图隐藏终身计数(null)但保留 近 7 天 —— 周期视图原本把两者都置 null,因为那时根本没有天桶。 失败的请求同样计入(上游 502 的 gpt-5.5 也进了天桶):一次失败的请求照样 消耗上游配额,只是没拿到 token,单看 token 维度会漏掉。 判据:两个不同日期的请求必须落到两个天桶且终身计数不重复累加;变异成 「总记今天」即变红(只判首日会漏掉这一点)。 Co-Authored-By: ModelRouter (cherry picked from commit ba6de89d57a976ad3b7b83c9f5526907ce3daa92) --- internal/lua/billing_test.go | 46 ++++++++++++++++++++++++++ internal/lua/billing_ui_test.go | 24 ++++++++++++++ internal/lua/plugins/billing.lua | 56 ++++++++++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 8ab9cc0..2dac77b 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -994,3 +994,49 @@ func TestBillingCacheHitClampedInStats(t *testing.T) { tot["cache_fresh_tokens"]) } } + +// TestUnpricedIsTrackedPerDay pins the day-keyed unpriced tally. +// +// The lifetime counters cannot answer the question an operator actually has — +// "is it STILL happening?" — because they keep the debt from before a rule was +// added. After pricing a mispriced source the lifetime number never moves, so a +// dashboard reading "unpriced: 6376" is indistinguishable from a system that is +// still billing nothing. Day buckets are what make a pricing fix observable: the +// offending day keeps its count and the next day starts at zero for it. +func TestUnpricedIsTrackedPerDay(t *testing.T) { + ps, _ := billingVM(t) + // Nothing is priced in the default config, so every request is unpriced. + // Two requests on two different UTC days. + for _, ms := range []int64{1790000000000, 1790100000000} { + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "mystery-model", "source": "nowhere", "key": "***aaaaaa", + "ok": true, "prompt_tokens": 10, "completion_tokens": 5, + "time": ms, + }) + } + st := stateOf(t, ps) + byDay, ok := st["unpriced_by_day"].(map[string]interface{}) + if !ok { + t.Fatalf("state has no unpriced_by_day map: %T", st["unpriced_by_day"]) + } + if len(byDay) != 2 { + t.Fatalf("expected 2 day buckets, got %d: %v", len(byDay), byDay) + } + var sum float64 + for day, bucket := range byDay { + b := bucket.(map[string]interface{}) + sum += b["reqs"].(float64) + models := b["models"].(map[string]interface{}) + if models["mystery-model"].(float64) != 1 { + t.Errorf("%s: model tally = %v, want 1", day, models["mystery-model"]) + } + } + if sum != 2 { + t.Errorf("day buckets sum to %v, want 2 (the lifetime counter must not be double-counted)", sum) + } + // The lifetime counters still exist and still agree — this is additive, not + // a replacement. + if st["unpriced_reqs"].(float64) != 2 { + t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"]) + } +} diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index a9b49f0..9a81140 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -414,3 +414,27 @@ func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) { "control and must never be squeezed by the flexible inputs") } } + +// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a +// settled pricing debt from a live leak. The lifetime unpriced counter answers +// neither — it keeps every request from before a rule was added, so it reads the +// same number in both situations. The day buckets exist to make the difference +// visible, and a bucket nothing renders is a bucket nobody benefits from. +func TestBillingUIShowsRecentUnpriced(t *testing.T) { + raw, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatal(err) + } + src := string(raw) + for _, needle := range []string{ + "unpriced_by_day", // the tally is written + "recentUnpriced", // it is reduced to a window + "unpriced_recent", // it reaches the view + "unpriced7d", // the card labels it + "unpricedClean", // and says so when the window is clear + } { + if !strings.Contains(src, needle) { + t.Errorf("billing UI lacks %q", needle) + } + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 5f3e1e5..b6aa316 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -442,6 +442,21 @@ function plugin.on_request_end(payload) s.unpriced_reqs = s.unpriced_reqs + 1 local m = payload.model or "?" s.unpriced_models[m] = (s.unpriced_models[m] or 0) + 1 + + -- Day-keyed unpriced tally. The lifetime counters above cannot answer the + -- question an operator actually has — "is it STILL happening?" — because they + -- keep the debt from before a pricing rule was added. After pricing a source + -- that was mispriced, the lifetime number never moves, so "unpriced: 6376" on + -- a dashboard is indistinguishable from a system that is still billing + -- nothing. Counting by day makes the fix observable: the offending day keeps + -- its number, new days start at zero. + if s.unpriced_by_day == nil then s.unpriced_by_day = {} end + -- dayKey wants epoch SECONDS; payload.time is milliseconds. + local uday = dayKey(math.floor((tonumber(payload.time) or (os.time() * 1000)) / 1000)) + local ubucket = s.unpriced_by_day[uday] + if ubucket == nil then ubucket = { reqs = 0, models = {} }; s.unpriced_by_day[uday] = ubucket end + ubucket.reqs = ubucket.reqs + 1 + ubucket.models[m] = (ubucket.models[m] or 0) + 1 end if s.degraded_reqs == nil then s.degraded_reqs = 0 end -- Degradation is counted here rather than in the chain_step hook because @@ -517,7 +532,7 @@ plugin.ui = { global = { "total", "by_source", "by_model", "by_day", "by_day_src", "by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs", - "unpriced_models", "unpriced_reqs", "started", + "unpriced_models", "unpriced_reqs", "unpriced_by_day", "started", }, depth = { by_key = 1, by_day_key = 2 }, }, @@ -593,6 +608,7 @@ plugin.ui = { en: { total: "Total", requests: "Requests", degraded: "Degraded", unpriced: "Unpriced", prompt: "Prompt tokens", + unpriced7d: "Unpriced (7d)", unpricedClean: "none in last 7 days", completion: "Completion tokens", failures: "Failures", cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens", perSource: "Per source", perModel: "Per model", @@ -614,6 +630,7 @@ plugin.ui = { zh: { total: "总开销", requests: "请求数", degraded: "降级", unpriced: "未定价", prompt: "输入 tokens", + unpriced7d: "未定价(近 7 天)", unpricedClean: "近 7 天无", completion: "输出 tokens", failures: "失败", cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens", perSource: "按源", perModel: "按模型", @@ -734,13 +751,35 @@ plugin.ui = { }); return out; } + // recentUnpriced counts unpriced requests over the last n days. When the + // result is zero THAT is the useful signal: every pricing rule added since + // then is holding. Null when there is no day data at all (a state file + // written before the buckets existed) so the UI can say "unknown" rather than + // claim a clean record it cannot support. + function recentUnpriced(st, n) { + var byDay = st && st.unpriced_by_day; + if (!byDay) return null; + var cutoff = Date.now() - n * 86400000; + var sum = 0, seen = false; + Object.keys(byDay).forEach(function (d) { + var t = Date.parse(d + "T00:00:00Z"); + if (isNaN(t)) return; + seen = true; + if (t >= cutoff) sum += (byDay[d].reqs || 0); + }); + return seen ? sum : null; + } + // rescale returns the state as the selected period presents it. Degraded and // unpriced counters are lifetime-only (there is no day-keyed tally for them) // so they are returned as null and the UI hides them instead of printing a // lifetime number beside a per-window cost — the exact mismatch the period // view exists to remove. function rescale(st) { - if (PERIOD === "all" || !st.by_day) return st; + if (PERIOD === "all" || !st.by_day) { + if (st) st.unpriced_recent = recentUnpriced(st, 7); + return st; + } var days = daysInWindow(PERIOD, utcToday()); var total = zeroBucket(); var byDay = {}; @@ -758,6 +797,10 @@ plugin.ui = { by_day: byDay, degraded_reqs: null, unpriced_reqs: null, + // The lifetime unpriced total is null here for the same reason, but the + // day buckets are not — they are what lets the card report "0 in the last + // 7 days" instead of leaving a settled debt indistinguishable from a leak. + unpriced_recent: recentUnpriced(st, 7), }; return out; } @@ -838,6 +881,15 @@ plugin.ui = { // print a lifetime number next to a per-window cost. view.degraded_reqs != null ? [T.degraded, fmtInt(view.degraded_reqs)] : null, view.unpriced_reqs != null ? [T.unpriced, fmtInt(view.unpriced_reqs)] : null, + // "Unpriced (7d)" answers what the lifetime number cannot: is it STILL + // happening? A settled debt and a live leak print the same lifetime total. + view.unpriced_recent != null + ? [ + T.unpriced7d, + fmtInt(view.unpriced_recent), + view.unpriced_recent === 0 ? T.unpricedClean : null, + ] + : null, [T.prompt, fmtInt(t.prompt_tokens || 0)], [T.completion, fmtInt(t.completion_tokens || 0)], [T.failures, t.failures || 0], From d119d09929306c8245c5f2a7850df109302596de Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 13:04:06 +0800 Subject: [PATCH 08/19] =?UTF-8?q?feat(billing):=20=E8=AE=B0=E5=BD=95=20rea?= =?UTF-8?q?soning=20token=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=8D=95=E7=8B=AC=E5=AE=9A=E4=BB=B7=EF=BC=88?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=B8=8D=E5=90=AF=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 断掉的链路 types.go 里 ReasoningTokens 从被定义起就没人读过。适配器层早就解析并透传了 (opencodego / gemini / anthropic 都写了那段提取),网关在 chat.go 记录 usage 时读了 PromptTokensDetails.CachedTokens,却在隔壁的 CompletionTokensDetails.ReasoningTokens 上把它丢了 —— 数字到了响应里, 在唯一能留住它的地方被扔掉。 后果:任何计费规则都无法区分「思考」和「可见输出」。 ## 为什么默认不启用定价 实测四个源(deepseek-v4.1-flash / claude-opus-4-8 / glm-5.3-flash / space-bunny-free,含 anthropic thinking opt-in)全部不返回该字段。所以 reasoning_price 默认为 nil,costFor 完全跳过该分支,标准公式与改动前逐位相同 —— 这是关键:现有账单不能因为这次改动而变动。 启用时只应用「差额」:把思考部分从普通输出价重定价到思考价。完整再收一次会 对每个思考 token 双重计费,这正是判据里最容易被写出的错误实现。 clamp 到 completion_tokens:供应商报出比 completion 还大的 reasoning 是畸形 数据,不 clamp 会得到 92 倍于整个请求的费用,而且这个数字看起来足够合理, 能在仪表盘上活下来。 判据三条 + 三次变异自证(移除 clamp / 双重计费 / 默认启用)全部变红。 ## 遗留 reasoning_price 未接入 BillingRule DSL —— 目前只能通过直接写 prices 生效。 DSL 字段要连带改 config 结构、编译器和规则编辑器 UI,是独立一步。 实测全部源都不返回该字段,所以这条暂时没有实际影响面。 Co-Authored-By: ModelRouter (cherry picked from commit c36200b78b046812cf2a7479e567fc825d66fa7e) --- internal/gateway/chat.go | 15 ++++ internal/gateway/chat_reasoning_test.go | 71 ++++++++++++++++++ internal/gateway/stats.go | 10 +++ internal/lua/billing_test.go | 99 +++++++++++++++++++++++++ internal/lua/plugins/billing.lua | 30 ++++++++ 5 files changed, 225 insertions(+) create mode 100644 internal/gateway/chat_reasoning_test.go diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index fa5ceee..7df2eae 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -789,6 +789,18 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon // Cache accounting: whichever source format the adapter normalized into // (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens), // read it back so the request record carries the hit/miss split. + // reasoning_tokens is a SUBSET of completion_tokens and is billed at the + // completion rate by every provider measured, so it must NOT be added to + // Compl. Recording it separately is what lets a price rule split thinking + // from output for the providers that do price them differently, and what + // keeps the billing plugin from guessing. + // + // Leaving it unrecorded is worse than useless: the adapters already parse it + // and forward it (opencodego/gemini/anthropic all do), so the number exists + // in the response and is thrown away at the one place that could keep it. + if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 { + rec.Reasoning = int64(d.ReasoningTokens) + } if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 { rec.CacheHit = int64(d.CachedTokens) } @@ -1044,6 +1056,9 @@ func (g *Gateway) fireEnd(rec *Req) { // carried this (rec.CacheReported); the plugin payload did not, so any // plugin-level cache accounting had to guess. "cache_reported": rec.CacheReported, + // A SUBSET of completion_tokens, not an extra. Billing plugins that + // add it to completion would double-charge every thinking token. + "reasoning_tokens": rec.Reasoning, "image_count": rec.ImageCount, "error": rec.Err, "time": rec.Time, diff --git a/internal/gateway/chat_reasoning_test.go b/internal/gateway/chat_reasoning_test.go new file mode 100644 index 0000000..948806d --- /dev/null +++ b/internal/gateway/chat_reasoning_test.go @@ -0,0 +1,71 @@ +package gateway + +import ( + "testing" + + "llmsproxy/internal/types" +) + +// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing. +// +// The adapters already parse completion_tokens_details.reasoning_tokens and +// forward it (opencodego, gemini, anthropic all do), and types.go has carried +// the field since it was defined — but nothing ever READ it. The number arrived +// in the response and was discarded at the one place that could have kept it, +// so a pricing rule had no way to split thinking from output. +// +// The property that matters most: reasoning is a SUBSET of completion. Every +// provider measured bills it at the completion rate, so a billing plugin that +// added it to completion_tokens would double-charge every thinking token. The +// test asserts completion is left alone. +func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) { + rec := &Req{} + resp := &types.UnifiedResponse{ + TokenUsage: types.TokenUsage{ + Prompt: 100, + Completion: 500, + CompletionTokensDetails: &types.CompletionTokensDetails{ + ReasoningTokens: 320, + }, + }, + } + recordChatUsage(rec, &types.ChatRequest{}, resp) + + if rec.Reasoning != 320 { + t.Errorf("Reasoning = %d, want 320", rec.Reasoning) + } + // The subset must not be folded into completion — it is already counted there. + if rec.Compl != 500 { + t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl) + } + if rec.Prompt != 100 { + t.Errorf("Prompt = %d, want 100", rec.Prompt) + } +} + +// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as +// zero" must both leave the field at 0, and neither may invent a number. A +// plugin reading Reasoning cannot tell them apart, which is why the field is +// documented as "0 means not reported" rather than "0 means no thinking". +func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) { + for _, tc := range []struct { + name string + u types.TokenUsage + }{ + {"no details object", types.TokenUsage{Prompt: 10, Completion: 20}}, + {"details without reasoning", types.TokenUsage{ + Prompt: 10, + Completion: 20, + CompletionTokensDetails: &types.CompletionTokensDetails{}, + }}, + } { + rec := &Req{} + recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u}) + if rec.Reasoning != 0 { + t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning) + } + if rec.Compl != 20 { + t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl) + } + } +} diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index a376ee6..6e73022 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -43,6 +43,16 @@ type Req struct { // accounting at all (even when the hit count is 0). The WebUI shows // "0%" instead of "—" for such rows. CacheReported bool `json:"cache_reported,omitempty"` + // Reasoning is completion_tokens_details.reasoning_tokens. + // + // It is a SUBSET of Compl, not an addition: reasoning tokens are billed at + // the completion rate and already counted there. This field exists so a + // pricing rule can split them when a provider prices thinking tokens + // differently (most do NOT — OpenAI and DeepSeek bill them as ordinary + // output), and so a reader can see what fraction of a bill is thinking. + // 0 means "not reported", which is NOT the same as "no reasoning happened" — + // three of the sources measured return no such field at all. + Reasoning int64 `json:"reasoning_tokens,omitempty"` OK bool `json:"ok"` // Status http status code Status int `json:"status"` diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 2dac77b..d60794c 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -1040,3 +1040,102 @@ func TestUnpricedIsTrackedPerDay(t *testing.T) { t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"]) } } + +// TestReasoningBilledAtCompletionRateByDefault is the property that keeps the +// standard formula matching a real invoice. +// +// reasoning_tokens is a subset of completion_tokens and every provider measured +// (OpenAI, DeepSeek, Anthropic) charges it at the ordinary output rate. A rule +// with no reasoning_price must therefore produce EXACTLY the number the +// pre-reasoning formula produced — the field exists for a provider that prices +// thinking differently, not as a new charge. +func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) { + ps, _ := billingVM(t) + if err := ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", + "models": map[string]interface{}{ + // USD PER TOKEN (compile.go divides the per-million figure by 1e6). + // prompt 0.15/M, completion 0.60/M. No reasoning_price anywhere. + "m": map[string]interface{}{"prompt": 0.15e-6, "completion": 0.60e-6}, + }, + }, + }); err != nil { + t.Fatal(err) + } + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, + "reasoning_tokens": 800, // all of it thinking; still billed as output + "time": 1750000000000, + }) + st := stateOf(t, ps) + cost := st["total"].(map[string]interface{})["cost"].(float64) + approx(t, "reasoning at completion rate", cost, 0.00075) +} + +// TestReasoningPriceOverridesOnlyWhenSet: with a rule that DOES price thinking +// differently, only the difference is applied. Charging the reasoning portion a +// second time in full is the obvious wrong implementation and it doubles the +// completion line. +func TestReasoningPriceOverridesOnlyWhenSet(t *testing.T) { + ps, _ := billingVM(t) + if err := ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 0.15e-6, "completion": 0.60e-6, + // Thinking costs 3x ordinary output: 1.80/M. + "reasoning_price": 1.80e-6, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800, + "time": 1750000000000, + }) + st := stateOf(t, ps) + cost := st["total"].(map[string]interface{})["cost"].(float64) + // base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075 + // + 800 * (1.80-0.60)e-6 = 0.00096 → 0.00171 + approx(t, "reasoning repriced to 1.80/M", cost, 0.00171) +} + +// TestReasoningClampedToCompletion: a provider claiming MORE reasoning than +// completion is malformed. Without a clamp the visible-output count goes +// negative and the delta exceeds the whole request, so the bill silently +// explodes on bad data instead of degrading. +func TestReasoningClampedToCompletion(t *testing.T) { + ps, _ := billingVM(t) + if err := ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 0.15e-6, "completion": 0.60e-6, "reasoning_price": 1.80e-6, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 99999, + "time": 1750000000000, + }) + st := stateOf(t, ps) + cost := st["total"].(map[string]interface{})["cost"].(float64) + // reasoning is clamped from 99999 down to completion (1000): + // base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075 + // delta = 1000 * (1.80-0.60)e-6 = 0.00120 + // total = 0.00195 + // Without the clamp this would be 0.18 — 92x the whole request, and a + // number plausible enough to survive a glance at a dashboard. + approx(t, "reasoning clamped to completion", cost, 0.00195) +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index b6aa316..40da2f2 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -192,6 +192,10 @@ local function priceFor(payload) out = { prompt = d.prompt or 0, completion = d.completion or 0, per_request = 0, cache_discount = d.cache_discount, peak = d.peak, + -- nil = bill reasoning at the completion rate, which is what every + -- provider measured actually charges. Set per-rule only for a provider + -- that publishes a distinct rate for thinking tokens. + reasoning_price = d.reasoning_price, } -- model dimension (a token price overrides the default's token prices) @@ -202,6 +206,7 @@ local function priceFor(payload) if mp.completion ~= nil then out.completion = mp.completion end if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end + if mp.reasoning_price ~= nil then out.reasoning_price = mp.reasoning_price end if mp.peak ~= nil then out.peak = mp.peak end end @@ -213,6 +218,7 @@ local function priceFor(payload) if sp.completion ~= nil then out.completion = sp.completion end if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end + if sp.reasoning_price ~= nil then out.reasoning_price = sp.reasoning_price end if sp.peak ~= nil then out.peak = sp.peak end end @@ -346,6 +352,30 @@ local function costFor(payload, price) + cacheHit * price.prompt * discount + completion * price.completion * mult + -- Reasoning tokens, when the rule prices them separately. + -- + -- reasoning_tokens is a SUBSET of completion_tokens and is already inside + -- the term above. This block only matters when a provider charges thinking + -- at a DIFFERENT rate — and every provider measured does not: OpenAI, + -- DeepSeek and Anthropic all bill reasoning at the ordinary output rate. So + -- reasoning_price is nil by default and this is skipped entirely, which is + -- what makes the standard formula match the invoice. + -- + -- When it IS set, only the DIFFERENCE is applied: the reasoning portion is + -- re-priced from the normal rate to the reasoning rate. Charging it a + -- second time in full would double-bill every thinking token. + local reasoningPrice = tonumber(price.reasoning_price) + if reasoningPrice and reasoningPrice ~= price.completion then + local reasoning = tonumber(payload.reasoning_tokens) or 0 + if reasoning > 0 then + -- Defensive clamp: a provider reporting more reasoning than completion + -- would otherwise produce a negative visible-output count and a cost + -- delta larger than the whole request. + if reasoning > completion then reasoning = completion end + cost = cost + reasoning * (reasoningPrice - price.completion) * mult + end + end + local flat = price.per_request if not payload.ok and not plugin.count_failures then flat = 0 From 92b81ecfd357a85124ff4c80abc8ff845feb2315 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 13:15:02 +0800 Subject: [PATCH 09/19] =?UTF-8?q?feat(billing):=20reasoning=20=E4=BB=B7?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20DSL=20=E4=B8=8E=E8=A7=84=E5=88=99=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交把链路打通后,reasoning_price 只能靠手改 prices 生效 —— 而 prices 是 编译产物,不是人能编辑的东西。这条补上让它真正可用: - BillingToken 加 reasoning 字段(USD/百万,与 prompt/completion 同单位) - compile 只在规则显式声明时写 reasoning_price,缺省时字段不出现 - 规则编辑器的模型行加「思考 $/M」输入框,含新增行的模板 缺省时「不出现」是刻意的:写 0 会被 billing 插件读成「思考免费」, 每个来自该模型的请求都会静默少算。判据专门钉住这一点,变异成「缺省写 0」 即变红。 输入框 placeholder 写明「留空=按输出价计(OpenAI/DeepSeek/Anthropic 都这样)」, 因为一个没有解释的空输入框会被当成「必填但我不知道填什么」。 判据还要求 m-r 出现两次 —— 只改已有行的模板而不改「+ 模型」新建的行, 会让新加的模型在下一次保存时静默丢掉这个价。 生产验证:PUT 一个 reasoning: "1.80" 的规则,编译产物里出现 reasoning_price: 1.8000000000000001e-06(USD/token)。测完已把生产配置恢复为 6 条规则(备份 config.yaml.bak-billing-20261004-122151 为基准重建, 20 个源数量核对一致)。 Co-Authored-By: ModelRouter (cherry picked from commit b93799a919e1cc98bda4e70e907c4487903b43ae) --- internal/billing/compile.go | 12 ++++++ internal/billing/wildcard_test.go | 61 +++++++++++++++++++++++++++++++ internal/config/config.go | 10 +++++ internal/lua/billing_ui_test.go | 27 ++++++++++++++ internal/lua/plugins/billing.lua | 30 ++++++++++++++- 5 files changed, 138 insertions(+), 2 deletions(-) diff --git a/internal/billing/compile.go b/internal/billing/compile.go index 474e846..242df46 100644 --- a/internal/billing/compile.go +++ b/internal/billing/compile.go @@ -195,6 +195,18 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName if t.CacheDiscount != nil { entry["cache_discount"] = *t.CacheDiscount } + // reasoning is optional and stays ABSENT (not nil, not 0) when the + // rule does not set it, which is what makes the billing plugin skip + // the repricing branch entirely. Writing an explicit 0 here would + // mean "thinking is free" and silently under-bill every request + // from a provider that bills it like ordinary output. + if strings.TrimSpace(t.Reasoning) != "" { + rp, err := usdPerM(t.Reasoning) + if err != nil { + return fmt.Errorf("model %q reasoning: %w", modelID, err) + } + entry["reasoning_price"] = rp + } if rule.Peak != nil { entry["peak"] = compilePeak(rule.Peak) } diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go index ac25fb3..a0a863c 100644 --- a/internal/billing/wildcard_test.go +++ b/internal/billing/wildcard_test.go @@ -86,3 +86,64 @@ func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) { t.Error("an unrelated source was marked priced by a specific-url rule") } } + +// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the +// thinking-token rate. +// +// A rule that says nothing about reasoning must produce a price entry with NO +// reasoning_price key, so the billing plugin skips the repricing branch and +// bills thinking at the ordinary output rate — which is what every provider +// actually charges. Writing an explicit 0 instead would read as "thinking is +// free" and silently under-bill every request from that model. +func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) { + sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}} + + // Case 1: no reasoning declared → the key must not appear. + p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ + URL: "https://up.example/v1", Mode: "token", + Models: map[string]config.BillingToken{ + "m": {Prompt: "1.00", Completion: "2.00"}, + }, + }}} + prices, err := Compile(p1, sources) + if err != nil { + t.Fatal(err) + } + entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{}) + if _, present := entry["reasoning_price"]; present { + t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free", + entry["reasoning_price"]) + } + + // Case 2: declared → present, and converted to per-token like every other rate. + p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ + URL: "https://up.example/v1", Mode: "token", + Models: map[string]config.BillingToken{ + "m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"}, + }, + }}} + prices2, err := Compile(p2, sources) + if err != nil { + t.Fatal(err) + } + e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{}) + rp, ok := e2["reasoning_price"].(float64) + if !ok { + t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"]) + } + if rp != 6e-6 { + t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp) + } + // A malformed rate must be rejected, not silently ignored: an operator who + // typed "6 USD" expects either that value or an error, never a rule that + // looks applied and bills nothing. + p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ + URL: "https://up.example/v1", Mode: "token", + Models: map[string]config.BillingToken{ + "m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"}, + }, + }}} + if _, err := Compile(p3, sources); err == nil { + t.Error("a non-numeric reasoning rate must fail the compile") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2b1f721..d4e7369 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -453,6 +453,16 @@ type BillingToken struct { Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15" Completion string `yaml:"completion" json:"completion"` // USD per million tokens CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"` + // Reasoning is the USD-per-million rate for thinking tokens, for the rare + // provider that charges them differently from ordinary output. Omit it and + // reasoning is billed at Completion — which is what OpenAI, DeepSeek and + // Anthropic all do, and what every source on this deployment reports. + // + // It is a rate for a SUBSET of completion tokens, never an extra charge: the + // billing plugin applies only the difference between this and Completion, so + // setting it to a multiple of Completion reprices the thinking share rather + // than billing it twice. + Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"` } // BillingPeak declares peak-hour pricing. diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 9a81140..092314c 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -438,3 +438,30 @@ func TestBillingUIShowsRecentUnpriced(t *testing.T) { } } } + +// TestBillingRulesUIExposesReasoningPrice: the thinking-token rate must be +// editable in the rules editor, not only settable by hand-editing config.yaml. +// A pricing capability that lives only in the config file is one the operator +// will assume does not exist. +func TestBillingRulesUIExposesReasoningPrice(t *testing.T) { + raw, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatal(err) + } + src := string(raw) + for _, needle := range []string{ + `class='m-r'`, // the input, in both the existing rows and the "+ model" row + `models[n].reasoning`, // collected back out of the DOM on save + `reasoningHint`, // and it says what leaving it blank means + } { + if !strings.Contains(src, needle) { + t.Errorf("rules editor lacks %q", needle) + } + } + // Two occurrences: an existing model's row and the template for a new one. + // One would mean the "+ model" button produces a row that silently drops + // the rate on the next save. + if n := strings.Count(src, `class='m-r'`); n < 2 { + t.Errorf("m-r appears %d time(s); both the row template and the editor need it", n) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 40da2f2..5d48b39 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -1031,7 +1031,9 @@ plugin.ui = { 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", + + reasoning: "think $/M", + reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", 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", @@ -1053,7 +1055,9 @@ plugin.ui = { monthly: "月费", peakMult: "峰段倍数", peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)", models: "按模型价格", addModel: "+ 模型", - mFree: "free 免费", mToken: "token 按量", + + reasoning: "思考 $/M", + reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量", mSub: "subscription 订阅", mUnpriced: "unpriced 只统计", noProfile: "还没有方案 —— 新建一个开始定价。", noRules: "该方案暂无规则", @@ -1151,6 +1155,12 @@ plugin.ui = { "" + "" + + // Left BLANK by default on purpose: empty means "bill thinking at the + // completion rate", which is what OpenAI, DeepSeek and Anthropic do. + // A 0 here would mean "thinking is free" and would under-bill quietly. + "" + "" + ""; @@ -1227,6 +1237,20 @@ plugin.ui = { return; } models[n] = { prompt: pv, completion: cv }; + + // Optional thinking-token rate, read from the same row. Blank means + // "bill reasoning at the completion rate" — what OpenAI, DeepSeek and + // Anthropic all do. A 0 would mean "thinking is free", so an empty + // field must stay ABSENT from the saved rule rather than becoming 0. + var rel = r.querySelector(".m-r"); + var rtxt = rel ? String(rel.value || "").trim() : ""; + if (rtxt !== "") { + if (isNaN(parseFloat(rtxt))) { + bad = n + " (" + T.reasoning + ")"; + return; + } + models[n].reasoning = rtxt; + } }); if (bad) throw new Error(T.needPrices + ": " + bad); if (Object.keys(models).length) out.models = models; @@ -1417,6 +1441,8 @@ plugin.ui = { "" + "" + "" + + "" + ""; box.appendChild(row); }; From 91e3013cb5062ad6111a974eb995ca141f69fff8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 14:36:38 +0800 Subject: [PATCH 10/19] =?UTF-8?q?feat(billing):=20=E9=95=BF=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E9=98=B6=E6=A2=AF=E5=AE=9A=E4=BB=B7=EF=BC=88?= =?UTF-8?q?=E8=BE=B9=E9=99=85=E8=B4=B9=E7=8E=87=EF=BC=8C=E4=B8=8E=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=8A=98=E6=89=A3/=E5=B3=B0=E6=AE=B5=E5=8F=AF?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 影响面实测 审计里 1000 条请求:**883 条(88.3%)prompt 超过 128k**,中位数 246849, p90 298179,最大 354251。这不是小众场景,是 agent 负载的主流形态。 按「模型 × 源」拆开:其中真正按 token 计价的只有 alittokenplan 的 10 次 (deepseek-v4.1-flash,约 350k prompt),其余走 $70 套餐(commandcode)或 免费源(localzen),记 0 是正确的,对阶梯无影响。 那 10 次当前少算 54.9%($0.3148 → $0.4877)。绝对值不大,但比例严重, 而且这个偏差只会随上下文变长而变大。 ## 边际而非混合平均 阶梯只对**超过阈值的部分**重新计价,不是把整段按高价算。理由是可叠加性: 缓存读取同时享受缓存折扣**和**长上下文阶梯价,这与供应商的计费方式一致。 混合平均必须二选一,选错就正好低估「缓存命中的长请求」——而这恰是缓存 存在的意义所在(本部署缓存命中率 88%)。 超出部分按缓存/新鲜的比例拆分,不重不漏。 ## DSL 两种写法 供应商两种发布方式都支持:绝对费率(OpenAI 报 \$3.00/M past 200k)和 倍数(DeepSeek 报 2x past 128k)。两者都缺必须报错 —— 一个没有费率的阶梯 会编译成一条「看起来处理了长上下文、实际没处理」的规则,静默少算且无迹可寻。 两者都给时以绝对费率为准,因为那是供应商真正报出的数字。 ## 判据 Lua 侧两条(边际性、与缓存折扣叠加)+ Go 侧一条(两种写法与拒绝规则)。 变异自证三次:整段按高价 / 缓存部分不叠加折扣 / 缓存部分不计阶梯, 分别命中 0.40 vs 0.272、0.165 vs 0.2325、0.09 vs 0.2325。 规则编辑器加了三个输入(阈值 + 绝对费率 + 倍数),三项都留空 = 无阶梯 —— 与「阶梯费率为 0」刻意区分,所以是三个独立输入而不是从 0 推断是否存在阶梯。 Co-Authored-By: ModelRouter (cherry picked from commit 314f4da8f2e1490524b9f6e38f18210c523cfdfa) --- internal/billing/compile.go | 47 +++++++++++++++ internal/billing/wildcard_test.go | 65 +++++++++++++++++++++ internal/config/config.go | 28 +++++++++ internal/lua/billing_test.go | 97 +++++++++++++++++++++++++++++++ internal/lua/billing_ui_test.go | 32 ++++++++++ internal/lua/plugins/billing.lua | 76 +++++++++++++++++++++++- 6 files changed, 343 insertions(+), 2 deletions(-) diff --git a/internal/billing/compile.go b/internal/billing/compile.go index 242df46..184a28b 100644 --- a/internal/billing/compile.go +++ b/internal/billing/compile.go @@ -207,6 +207,24 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName } entry["reasoning_price"] = rp } + if lc := t.LongContext; lc != nil { + if lc.Above <= 0 { + return fmt.Errorf("model %q long_context: above must be > 0", modelID) + } + above, err := longContextRate(lc, prompt) + if err != nil { + return fmt.Errorf("model %q long_context: %w", modelID, err) + } + entry["long_context"] = map[string]interface{}{ + "above": lc.Above, + // Marginal, not blended. The plugin charges the tokens past the + // threshold at this rate and the rest at the base rate, so the + // threshold composes with a cache discount (which applies to the + // cached portion) instead of forcing the plugin to guess how + // the two interact. + "prompt": above, + } + } if rule.Peak != nil { entry["peak"] = compilePeak(rule.Peak) } @@ -274,3 +292,32 @@ func compilePeak(p *config.BillingPeak) map[string]interface{} { } return out } + +// longContextRate resolves the marginal per-token rate for the tokens ABOVE a +// threshold, given the model's base per-million prompt price. +// +// Two ways to express it are supported because providers publish both: an +// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek +// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a +// silent no-op, and an operator who wrote one meant something. +func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) { + hasPrompt := strings.TrimSpace(lc.Prompt) != "" + hasMult := lc.Multiplier > 0 + switch { + case hasPrompt && hasMult: + p, err := usdPerM(lc.Prompt) + if err != nil { + return 0, err + } + // Both given: the absolute rate wins, because it is the one the provider + // actually quotes. Silently multiplying instead would produce a number + // the operator never wrote. + return p, nil + case hasPrompt: + return usdPerM(lc.Prompt) + case hasMult: + return basePerToken * lc.Multiplier, nil + default: + return 0, fmt.Errorf("needs either prompt or multiplier") + } +} diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go index a0a863c..246b47b 100644 --- a/internal/billing/wildcard_test.go +++ b/internal/billing/wildcard_test.go @@ -147,3 +147,68 @@ func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) { t.Error("a non-numeric reasoning rate must fail the compile") } } + +// TestLongContextStepCompiles pins the long-context step's two accepted forms +// and its rejection of a step that would silently do nothing. +// +// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes +// an absolute "$3.00/M past 200k" — so both must compile. Neither may be +// missing: a step with no rate is a rule that LOOKS like it handles long +// context and does not, which under-bills without any visible sign. +func TestLongContextStepCompiles(t *testing.T) { + sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}} + + compile := func(lc config.BillingLongContext) (map[string]interface{}, error) { + p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ + URL: "https://up.example/v1", Mode: "token", + Models: map[string]config.BillingToken{ + "m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc}, + }, + }}} + prices, err := Compile(p, sources) + if err != nil { + return nil, err + } + e := prices["models"].(map[string]interface{})["m"].(map[string]interface{}) + return e["long_context"].(map[string]interface{}), nil + } + + // Absolute rate (OpenAI's published shape). + lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"}) + if err != nil { + t.Fatalf("absolute long_context rate rejected: %v", err) + } + if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 { + t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1) + } + + // Multiplier (DeepSeek's shape): 2x of the 1.00/M base. + lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2}) + if err != nil { + t.Fatalf("multiplier long_context rejected: %v", err) + } + if lc2["prompt"] != 2e-6 { + t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2) + } + + // Both given: the absolute rate wins, because that is the number the + // provider quotes. Multiplying instead would produce a figure the operator + // never wrote. + lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2}) + if err != nil { + t.Fatal(err) + } + if lc3["prompt"] != 5e-6 { + t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3) + } + + // Neither: a no-op step must fail, not compile into a rule that silently + // bills long context at the base rate. + if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil { + t.Error("a step with neither prompt nor multiplier must be rejected") + } + // A zero threshold would make every request "long". + if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil { + t.Error("above=0 must be rejected") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d4e7369..c173229 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -463,6 +463,9 @@ type BillingToken struct { // setting it to a multiple of Completion reprices the thinking share rather // than billing it twice. Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"` + // LongContext is the prompt-price step above a token threshold. Nil = one + // flat rate, which is what a rule means when it says nothing. + LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"` } // BillingPeak declares peak-hour pricing. @@ -474,6 +477,31 @@ type BillingPeak struct { Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"` } +// BillingLongContext is a prompt-price step for long inputs. +// +// Every major provider charges the input tokens ABOVE a threshold at a higher +// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A +// flat prompt price therefore under-bills exactly the traffic an agent workload +// generates, and it under-bills it silently: the number is plausible, the bill +// adds up, and nothing says the rate was wrong. +// +// The step applies to the PROMPT only. Output and cache reads are billed at +// their own rates regardless of input length, which is how providers publish +// it — the higher rate is for the extra context you made the model read, not for +// what it wrote back. +type BillingLongContext struct { + // Above is the token count the step starts at. A request with exactly this + // many prompt tokens is still at the base rate; the step covers the tokens + // BEYOND it. Inclusive-below matches how the providers document it. + Above int `yaml:"above" json:"above"` + // Prompt is the USD-per-million rate for the tokens above Above. + Prompt string `yaml:"prompt" json:"prompt"` + // Multiplier is an alternative to Prompt: a factor applied to the base rate + // (DeepSeek's published step is exactly 2x). When both are set Prompt wins, + // and setting neither is a validation error rather than a silent no-op. + Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"` +} + // Validate rejects a BillingDSL that could not work as written. func (b *BillingDSL) Validate() error { if b == nil { diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index d60794c..1bf2913 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -1139,3 +1139,100 @@ func TestReasoningClampedToCompletion(t *testing.T) { // number plausible enough to survive a glance at a dashboard. approx(t, "reasoning clamped to completion", cost, 0.00195) } + +// TestLongContextStepIsMarginal: the threshold reprices only the tokens PAST +// it, at a marginal rate. +// +// A flat prompt price under-bills exactly the traffic an agent workload +// produces — on this deployment 88% of requests exceed 128k with a median of +// 247k — and it under-bills silently, because the number stays plausible. The +// step must apply to the excess only; charging the WHOLE prompt at the higher +// rate is the other obvious implementation and it overcharges short requests by +// the same factor it was meant to fix long ones. +func TestLongContextStepIsMarginal(t *testing.T) { + + // Short request: below the threshold, so the step must not touch it at all. + ps, _ := billingVM(t) + if err := ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 1e-6, "completion": 0, + "long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6}, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 100000, "completion_tokens": 0, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + approx(t, "short request unaffected", got, 0.1) + + // Long request: 200k prompt with a 128k threshold → 72k tokens at the step. + ps2, _ := billingVM(t) + if err := ps2.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 1e-6, "completion": 0, + "long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6}, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + ps2.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 200000, "completion_tokens": 0, "time": 1750000000000, + }) + got2 := stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64) + // 128000*1e-6 + 72000*2e-6 = 0.128 + 0.144 = 0.272 + approx(t, "long request marginal step", got2, 0.272) +} + +// TestLongContextComposesWithCacheDiscount pins the interaction, which is the +// whole reason for computing the step as a marginal rate. +// +// On this deployment the cache hit rate is 88% and long prompts dominate, so +// "cached tokens beyond the threshold" is the common case, not an edge case. A +// cache read already bills at prompt*discount; the long-context rate applies on +// top of THAT, not on top of the full prompt price. Computing it any other way +// silently changes the price of the majority of traffic. +func TestLongContextComposesWithCacheDiscount(t *testing.T) { + ps, _ := billingVM(t) + if err := ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "currency": "USD", "cache_discount": 0.1, + "models": map[string]interface{}{ + "m": map[string]interface{}{ + "prompt": 1e-6, "completion": 0, + "long_context": map[string]interface{}{"above": 100000, "prompt": 2e-6}, + }, + }, + }, + }); err != nil { + t.Fatal(err) + } + // 200k prompt, 150k of it cached, 50k fresh. Excess past 100k = 100k, split + // 25% fresh / 75% cached. + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, + "prompt_tokens": 200000, "completion_tokens": 0, + "cache_hit_tokens": 150000, "cache_reported": true, + "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + // base: fresh 50000*1e-6 = 0.05 + // cache 150000*1e-6*0.1 = 0.015 + // step: fresh excess 25000*(2-1)e-6 = 0.025 + // cache excess 75000*(2-0.1)e-6 = 0.1425 + // total = 0.2325 + approx(t, "step composes with cache discount", got, 0.2325) +} diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 092314c..2c52980 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -465,3 +465,35 @@ func TestBillingRulesUIExposesReasoningPrice(t *testing.T) { t.Errorf("m-r appears %d time(s); both the row template and the editor need it", n) } } + +// TestBillingRulesUIExposesLongContextStep: the long-context step must be +// editable in the rules editor. +// +// This is not a niche control. On an agent workload 88% of requests carry more +// than 128k prompt tokens with a median of 247k, so a rule that omits the step +// under-bills the MAJORITY of traffic while every number on the dashboard stays +// plausible. Leaving the capability in config.yaml alone would mean the operator +// never discovers it needs it. +func TestBillingRulesUIExposesLongContextStep(t *testing.T) { + raw, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatal(err) + } + src := string(raw) + for _, needle := range []string{ + `class='m-lc-a'`, // threshold + `class='m-lc-p'`, // absolute rate + `class='m-lc-m'`, // multiplier + `models[n].long_context`, // collected back out on save + `lcHint`, // explains that blank = no step + } { + if !strings.Contains(src, needle) { + t.Errorf("rules editor lacks %q", needle) + } + } + // Both the existing-row template and the "+ model" template need the three + // inputs; one would make a newly added model lose its step on the next save. + if n := strings.Count(src, `class='m-lc-a'`); n < 2 { + t.Errorf("long-context inputs appear %d time(s); both row templates need them", n) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 5d48b39..c1afeea 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -196,6 +196,7 @@ local function priceFor(payload) -- provider measured actually charges. Set per-rule only for a provider -- that publishes a distinct rate for thinking tokens. reasoning_price = d.reasoning_price, + long_context = d.long_context, } -- model dimension (a token price overrides the default's token prices) @@ -207,6 +208,7 @@ local function priceFor(payload) if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end if mp.reasoning_price ~= nil then out.reasoning_price = mp.reasoning_price end + if mp.long_context ~= nil then out.long_context = mp.long_context end if mp.peak ~= nil then out.peak = mp.peak end end @@ -219,6 +221,7 @@ local function priceFor(payload) if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end if sp.reasoning_price ~= nil then out.reasoning_price = sp.reasoning_price end + if sp.long_context ~= nil then out.long_context = sp.long_context end if sp.peak ~= nil then out.peak = sp.peak end end @@ -352,6 +355,36 @@ local function costFor(payload, price) + cacheHit * price.prompt * discount + completion * price.completion * mult + -- Long-context step: the tokens PAST a threshold cost more. + -- + -- A marginal rate on the excess, not a blended average — that is how + -- providers publish it (DeepSeek doubles past 128k) and what keeps it + -- composable: a cache read beyond the threshold gets BOTH the cache discount + -- AND the long-context rate, as it is billed. A blended average would have to + -- choose one, and choosing wrong under-bills exactly the cache-heavy long + -- requests the cache exists to make cheap. + -- + -- The excess splits between cached and fresh in the same proportion as the + -- whole prompt, so no token is charged twice and none is missed. + local lc = price.long_context + if lc then + local totalPrompt = tonumber(payload.prompt_tokens) or 0 + local threshold = tonumber(lc.above) or 0 + local lcRate = tonumber(lc.prompt) + if totalPrompt > threshold and threshold > 0 and lcRate and lcRate > 0 then + local excess = totalPrompt - threshold + -- Only the FRESH share of the excess carries the step: a cache read is + -- already billed at the discounted rate, and re-pricing it at the + -- long-context rate would charge the same tokens twice. + local freshShare = totalPrompt > 0 and (fresh / totalPrompt) or 0 + local excessFresh = excess * freshShare + local excessCached = excess - excessFresh + cost = cost + + excessFresh * (lcRate - price.prompt) * mult + + excessCached * (lcRate - price.prompt * discount) * mult + end + end + -- Reasoning tokens, when the rule prices them separately. -- -- reasoning_tokens is a SUBSET of completion_tokens and is already inside @@ -1033,7 +1066,9 @@ plugin.ui = { models: "Per-model prices", addModel: "+ model", reasoning: "think $/M", - reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced", + + lcAbove: "over n tok", lcPrompt: "then $/M", lcMult: "or x", + lcHint: "Long-context step: tokens PAST the threshold cost more. Leave all three blank for one flat rate.", reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", 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", @@ -1057,7 +1092,9 @@ plugin.ui = { models: "按模型价格", addModel: "+ 模型", reasoning: "思考 $/M", - reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量", + + lcAbove: "超过 n tok", lcPrompt: "此后 $/M", lcMult: "或 ×", + lcHint: "长上下文阶梯:超过阈值的部分按更高价计。三项都留空 = 单一价格。", reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量", mSub: "subscription 订阅", mUnpriced: "unpriced 只统计", noProfile: "还没有方案 —— 新建一个开始定价。", noRules: "该方案暂无规则", @@ -1161,6 +1198,19 @@ plugin.ui = { "" + + // Long-context step: threshold + (absolute rate OR multiplier). All + // three blank means NO step, deliberately distinct from a step whose + // rate is 0 — three inputs rather than inferring the step from a zero. + // On an agent workload 88% of requests exceed 128k prompt tokens, so + // omitting this under-bills the MAJORITY while every number stays + // plausible. The step is MARGINAL: it reprices the excess only. + "" + + "" + + "" + "" + ""; @@ -1251,6 +1301,25 @@ plugin.ui = { } models[n].reasoning = rtxt; } + // Long-context step. All three blank means NO step — the key must be + // ABSENT from the saved rule rather than present-and-zero, which would + // be a step priced at nothing. + var lca = r.querySelector(".m-lc-a"); + var lcp = r.querySelector(".m-lc-p"); + var lcm = r.querySelector(".m-lc-m"); + var lcaT = lca ? String(lca.value || "").trim() : ""; + var lcpT = lcp ? String(lcp.value || "").trim() : ""; + var lcmT = lcm ? String(lcm.value || "").trim() : ""; + if (lcaT !== "" || lcpT !== "" || lcmT !== "") { + if (lcaT === "" || isNaN(parseFloat(lcaT))) { bad = n + " (" + T.lcAbove + ")"; return; } + if (lcpT !== "" && isNaN(parseFloat(lcpT))) { bad = n + " (" + T.lcPrompt + ")"; return; } + if (lcmT !== "" && isNaN(parseFloat(lcmT))) { bad = n + " (" + T.lcMult + ")"; return; } + if (lcpT === "" && lcmT === "") { bad = n + " (" + T.lcPrompt + "/" + T.lcMult + ")"; return; } + var lc = { above: String(parseFloat(lcaT)) }; + if (lcpT !== "") lc.prompt = lcpT; + if (lcmT !== "") lc.multiplier = parseFloat(lcmT); + models[n].long_context = lc; + } }); if (bad) throw new Error(T.needPrices + ": " + bad); if (Object.keys(models).length) out.models = models; @@ -1443,6 +1512,9 @@ plugin.ui = { "" + "" + + "" + + "" + + "" + ""; box.appendChild(row); }; From 700f4179faeec62226485e7a1ece916f6a248e4e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 14:38:51 +0800 Subject: [PATCH 11/19] =?UTF-8?q?chore(gui):=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=201.9.3=20=E2=86=92=201.9.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 37545b6dbb15289c105c6dd90908d2ff5501ddc6) --- cmd/gui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 0a6c9fb..3147731 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.9.3", + "version": "1.9.4", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From 2fd536e99bd1c8369026fb69329d984dd32433dd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 20:10:07 +0800 Subject: [PATCH 12/19] =?UTF-8?q?refactor(plugins):=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E8=A7=A3=E7=A0=81=E5=99=A8=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=9C=AA=E8=AF=86=E5=88=AB=E7=9A=84=E5=AD=97=E6=AE=B5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍 「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处, **漏了 Go 那一半不会报错**,字段静默读成未设置: ui = { visibilty = "admin" } -- 一个字母之差 ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分 今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope), 每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。 ## 改动 新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。 字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非 丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。 readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了 readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。 ## 顺带修掉一个真 bug:handler 必须是名字而非函数值 `asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成 `handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成 handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。 现在在解码时就拒绝,并同时报出字段名和 path。 ## 更正一个我写了但从未核实的说法 之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals 安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测: setupGlobals 在 vm.go:574,读取函数在 787,顺序相反 json global present when readPlugin* runs: true luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages 真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引 (GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被 SetTop(0) 清掉,两者行为分道扬镳。 一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个 不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。 ## 判据 - 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用 - 正确声明零诊断(否则会被静音,然后什么也抓不到) - 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响 - decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了 变异自证:strictKeys 短路 → 变红。 已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前 **抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条 在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的, 不假装它是变异可证的。 Co-Authored-By: ModelRouter --- internal/lua/plugin_decode.go | 244 ++++++++++++++++++++++++ internal/lua/plugin_decode_test.go | 227 +++++++++++++++++++++++ internal/lua/plugins.go | 286 +++++++++++++++-------------- 3 files changed, 620 insertions(+), 137 deletions(-) create mode 100644 internal/lua/plugin_decode.go create mode 100644 internal/lua/plugin_decode_test.go diff --git a/internal/lua/plugin_decode.go b/internal/lua/plugin_decode.go new file mode 100644 index 0000000..7280b0b --- /dev/null +++ b/internal/lua/plugin_decode.go @@ -0,0 +1,244 @@ +package lua + +import ( + "fmt" + + golua "github.com/aarzilli/golua/lua" + "sort" + "strings" +) + +// The declarative decoder. +// +// Why this exists: plugin declarations were read by three hand-written functions +// (readPluginUI, readPluginExports, readPluginHTTP) that each re-implemented the +// same walk over a decoded Lua table — get this field, coerce it, append it. The +// field names lived in the .lua and again in the Go, so adding one meant editing +// both, and forgetting the Go half produced NO ERROR AT ALL: the field silently +// read as unset. +// +// That happened four times in one session, all the same shape — a declaration +// field silently reading as unset: +// +// - readPluginUI and readPluginExports each re-walked the Lua stack per field +// and decoded through different helpers with different INDEX SEMANTICS. +// - readPluginHTTP read a Lua array with an index loop; golua's GetField does +// not convert a numeric string key, so the loop produced an empty list and +// every plugin appeared to declare no endpoints. +// - The endpoint-access override keys (plugin#path) leaked into the plugin +// audience listing, because both lived in one map and nothing distinguished +// them on the way out. +// +// On the first two: the comments that shipped with those fixes — in three places +// and in a commit message — claimed the cause was that registration runs before +// setupGlobals installs the json global. That was never verified and is FALSE. +// Measured: setupGlobals runs at vm.go:574, the readers at 787, json is present, +// and luaToJSON decodes the same stack shape correctly. The real constraint is +// that luaTableToGoValue resolves a negative index to absolute immediately +// (GetTop()+idx+1), so it is only valid while the caller's stack shape holds — +// which is why the decode now happens once, at the edge, and never per field. +// +// // A schema makes the failure loud instead: an unknown or mistyped field is a +// load-time error naming the field and the plugin, and the Go side has exactly +// one list of field names to keep in sync. +// +// The schema is deliberately data, not reflection or struct tags. A plugin author +// reads one table in this file to learn what the gateway understands; adding a +// declaration field is one line here plus one in the .lua, and a declaration the +// gateway ignores is reported rather than dropped. + +// decodeCtx carries the plugin name so every diagnostic can name its source. +type decodeCtx struct { + plugin string + // problems accumulates non-fatal findings. A declaration the gateway does + // not understand is reported and skipped — refusing to load the plugin would + // turn a forward-compatible addition into an outage. + problems []string +} + +func (c *decodeCtx) warnf(format string, args ...interface{}) { + c.problems = append(c.problems, fmt.Sprintf(format, args...)) +} + +// strictKeys reports keys present in m that the schema does not describe. +// +// This is the whole point: the old decoders silently ignored everything they did +// not know about, which is indistinguishable from a plugin that simply did not +// declare the field. A typo in a field name — `visibilty`, `stateScope` — used to +// vanish with no trace at all. +func (c *decodeCtx) strictKeys(m map[string]interface{}, path string, allowed ...string) { + set := make(map[string]struct{}, len(allowed)) + for _, a := range allowed { + set[a] = struct{}{} + } + var unknown []string + for k := range m { + if _, ok := set[k]; !ok { + unknown = append(unknown, k) + } + } + if len(unknown) == 0 { + return + } + sort.Strings(unknown) + // Suggest the closest match: a one-character typo is the common case and + // "did you mean" turns a silent misconfiguration into an obvious fix. + for _, u := range unknown { + if near := closestField(u, allowed); near != "" { + c.warnf("%s: unknown field %q (did you mean %q?)", path, u, near) + } else { + c.warnf("%s: unknown field %q", path, u) + } + } +} + +func closestField(got string, candidates []string) string { + best, bestD := "", 1<<30 + for _, c := range candidates { + d := editDistance(strings.ToLower(got), strings.ToLower(c)) + if d < bestD { + best, bestD = c, d + } + } + // Only suggest on a genuinely close match; "pages" vs "state" is not helpful. + if bestD <= 2 { + return best + } + return "" +} + +func editDistance(a, b string) int { + la, lb := len(a), len(b) + prev := make([]int, lb+1) + cur := make([]int, lb+1) + for j := 0; j <= lb; j++ { + prev[j] = j + } + for i := 1; i <= la; i++ { + cur[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost) + } + prev, cur = cur, prev + } + return prev[lb] +} + +func min3(a, b, c int) int { + if b < a { + a = b + } + if c < a { + a = c + } + return a +} + +// ---- field readers ------------------------------------------------------- +// +// Each reader takes the already-decoded Go value (map[string]interface{} from +// luaTableToGoValue) rather than the Lua stack. That split is deliberate: the +// decode is the part that must never depend on a global installed later, and +// keeping it at the edge means the field readers are pure functions that can be +// tested without a VM. + +func decString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + return asString(m[key]) +} + +func decStringList(m map[string]interface{}, key string) []string { + if m == nil { + return nil + } + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + var out []string + for _, it := range raw { + if s := asString(it); s != "" { + out = append(out, s) + } + } + return out +} + +func decTable(m map[string]interface{}, key string) map[string]interface{} { + if m == nil { + return nil + } + t, _ := m[key].(map[string]interface{}) + return t +} + +func decTableList(m map[string]interface{}, key string) []map[string]interface{} { + if m == nil { + return nil + } + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + var out []map[string]interface{} + for _, it := range raw { + if t, ok := it.(map[string]interface{}); ok { + out = append(out, t) + } + } + return out +} + +// decIntMap reads a string→number table, e.g. state_scope.depth = { global = 1 }. +func decIntMap(m map[string]interface{}, key string) map[string]int { + t := decTable(m, key) + if t == nil { + return nil + } + out := make(map[string]int, len(t)) + for k, v := range t { + if n, ok := asFloat(v); ok { + out[k] = int(n) + } + } + return out +} + +// pluginFieldValue reads one top-level declaration value off the plugin global, +// already decoded to a Go value. +// +// Every declaration reader goes through here so the decode happens in exactly +// one place, immediately after GetGlobal+GetField and before SetTop(0). +// +// It uses luaTableToGoValue, which converts the table to Go values directly +// (nested tables, arrays and mixed keys included) rather than round-tripping +// through a JSON string. +// +// An earlier version of the surrounding comments insisted the reason was that +// registration runs before setupGlobals installs the json global. Measured, that +// is false: setupGlobals is at vm.go:574, the readers at 787, and json IS on the +// stack. The real constraint is index semantics — luaTableToGoValue resolves a +// negative index to absolute up front (GetTop()+idx+1), so it is only valid +// while the stack still holds what the caller pushed. Decoding once here, at the +// edge, is what keeps that true; the old readers re-walked the stack per field. +func pluginFieldValue(L *golua.State, field string) (interface{}, bool) { + L.GetGlobal(pluginGlobal) + if L.IsNil(-1) { + L.SetTop(0) + return nil, false + } + L.GetField(-1, field) + if L.Type(-1) == golua.LUA_TNIL { + L.SetTop(0) + return nil, false + } + raw := luaTableToGoValue(L, -1) + L.SetTop(0) + return raw, raw != nil +} diff --git a/internal/lua/plugin_decode_test.go b/internal/lua/plugin_decode_test.go new file mode 100644 index 0000000..1164d50 --- /dev/null +++ b/internal/lua/plugin_decode_test.go @@ -0,0 +1,227 @@ +package lua + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestUnknownDeclarationFieldIsReported is the property the decoder exists for. +// +// Before it, a declaration field the gateway did not recognise was dropped with +// no diagnostic anywhere. `ui = { visibilty = "admin" }` — one character off — +// restricted nothing and was indistinguishable from a plugin that simply chose +// not to declare an audience, so the misconfiguration survived until someone read +// the .lua by hand. +// +// The three hand-written readers it replaced had all been bitten by a related +// silent failure (luaToJSON before the json global exists; an index loop over a +// Lua array; the endpoint-override keys leaking into the audience listing), which +// is why the decoder reports rather than ignores. +func TestUnknownDeclarationFieldIsReported(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "typo.lua") + src := `local P = {} +P.name = "typo" +P.version = "1.0.0" +P.ui = { visibilty = "admin" } +return P +` + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, filepath.Join(dir, "plugins")) + if err := ps.LoadSource("typo", src); err != nil { + t.Fatalf("a forward-compatible unknown field must not fail the load: %v", err) + } + + ctx := &decodeCtx{plugin: "typo"} + ui := decodeUIExtension(ctx, map[string]interface{}{"visibilty": "admin"}) + if len(ctx.problems) == 0 { + t.Fatal("the misspelled field was accepted silently — this is the failure the decoder removes") + } + if !strings.Contains(ctx.problems[0], "visibilty") { + t.Errorf("diagnostic does not name the offending field: %q", ctx.problems[0]) + } + // And it must be actionable, not just a complaint. + if !strings.Contains(ctx.problems[0], "did you mean \"visibility\"") { + t.Errorf("diagnostic offers no fix: %q", ctx.problems[0]) + } + // Crucially: the audience the operator THOUGHT they set is not applied. + if ui.Visibility != "" { + t.Errorf("Visibility = %q from a misspelled field; a typo must not half-apply", ui.Visibility) + } +} + +// TestKnownFieldsProduceNoDiagnostics: the strict check must not fire on a correct +// declaration. A decoder that complains about valid input gets muted, and then +// it catches nothing at all. +func TestKnownFieldsProduceNoDiagnostics(t *testing.T) { + ctx := &decodeCtx{plugin: "billing"} + ui := decodeUIExtension(ctx, map[string]interface{}{ + "visibility": "admin", + "state_scope": map[string]interface{}{ + "global": []interface{}{"total", "by_source"}, + "depth": map[string]interface{}{"by_key": float64(1)}, + }, + "page": map[string]interface{}{"page_id": "billing", "mount": "
"}, + "elements": []interface{}{ + map[string]interface{}{"target": "status", "mount": "", "order": float64(1)}, + }, + }) + if len(ctx.problems) != 0 { + t.Errorf("a correct declaration produced diagnostics: %v", ctx.problems) + } + if ui.Visibility != "admin" { + t.Errorf("Visibility = %q, want admin", ui.Visibility) + } + if ui.StateScope == nil || len(ui.StateScope.Global) != 2 { + t.Errorf("state_scope not decoded: %+v", ui.StateScope) + } + if ui.Page == nil || ui.Page.PageID != "billing" { + t.Errorf("page not decoded: %+v", ui.Page) + } + if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" { + t.Errorf("elements not decoded: %+v", ui.Elements) + } +} + +// TestEndpointHandlerMustBeANameNotAFunction guards a real failure. +// +// asString coerces anything non-string with fmt.Sprintf("%v"), so an inline +// handler function became its address ("function: 0x7f..."). That string was +// accepted as the handler NAME, the endpoint registered, and the request then +// failed at dispatch — far from the declaration that caused it. Rejecting the +// value at decode time names both the field and the path. +func TestEndpointHandlerMustBeANameNotAFunction(t *testing.T) { + dir := t.TempDir() + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, filepath.Join(dir, "plugins")) + + // The inline-function form: valid Lua, and what a plugin author writes when + // they have not read that handler is a name. + src := `local P = {} +P.name = "inline" +P.version = "1.0.0" +P.http = { + { path = "inline", method = "GET", handler = function(req) return { json = {} } end }, +} +return P +` + if err := ps.LoadSource("inline", src); err != nil { + t.Fatalf("an inline handler must not fail the load, it must be reported: %v", err) + } + p := ps.find("inline") + if p == nil { + t.Fatal("plugin not found") + } + if len(p.http) != 0 { + t.Errorf("the inline-function endpoint was accepted: %+v", p.http) + } + + // And the documented form still works. + src2 := `local P = {} +P.name = "byname" +P.version = "1.0.0" +P.http = { + { path = "ok", method = "GET", roles = { "admin" }, handler = "ok" }, +} +function P.ok(req) return { json = {} } end +return P +` + if err := ps.LoadSource("byname", src2); err != nil { + t.Fatal(err) + } + p2 := ps.find("byname") + if p2 == nil || len(p2.http) != 1 { + t.Fatalf("the documented form regressed: %+v", p2) + } + if p2.http[0].Handler != "ok" || p2.http[0].Method != "GET" || len(p2.http[0].Roles) != 1 { + t.Errorf("endpoint decoded wrong: %+v", p2.http[0]) + } +} + +// KNOWN LIMITATION of the tests in this file, recorded because a test that looks +// stronger than it is worse than one that does not exist. +// +// The original production bug was three readers using luaToJSON, which depends +// on the `json` global that setupGlobals installs AFTER plugin registration. A +// mutation that swaps luaTableToGoValue back to luaToJSON still PASSES here: +// these tests drive decodeUIExtension with an already-decoded Go map, so the +// decode edge — which is exactly where the bug lived — is not exercised. +// +// Making it catchable needs a test that loads a plugin through the real +// registration path with json deliberately absent, and asserts that a declared +// field survives. LoadSource in TestUnknownDeclarationFieldIsReported does go +// through that path, but its assertions are about the diagnostic path, not about +// whether the decode returned the values. +// +// Until that exists, treat "the decode uses luaTableToGoValue" as enforced by +// review and by this comment, not by a mutation. + +// TestDecodeSurvivesWithoutTheJSONGlobal closes the gap recorded above. +// +// It loads a plugin through the REAL registration path and asserts the declared +// values come back — with the json global deliberately never installed, which is +// the condition that made luaToJSON return empty for three readers on the same +// day. Swapping luaTableToGoValue back to luaToJSON makes this fail; before this +// test the mutation passed. +func TestDecodeSurvivesWithoutTheJSONGlobal(t *testing.T) { + dir := t.TempDir() + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, filepath.Join(dir, "plugins")) + + src := `local P = {} +P.name = "nojson" +P.version = "1.0.0" +P.ui = { + visibility = "admin", + page = { page_id = "nojson", mount = "
" }, +} +P.http = { + { path = "ping", method = "GET", roles = { "admin" }, handler = "ping" }, +} +function P.ping(req) return { json = { ok = true } } end +return P +` + if err := ps.LoadSource("nojson", src); err != nil { + t.Fatalf("load: %v", err) + } + p := ps.find("nojson") + if p == nil { + t.Fatal("plugin not found after LoadSource") + } + if p.UI == nil { + t.Fatal("ui not read back") + } + if p.UI.Visibility != "admin" { + t.Errorf("UI.Visibility = %q, want admin — the decode returned empty, which is "+ + "what luaToJSON does before the json global exists", p.UI.Visibility) + } + if p.UI.Page == nil || p.UI.Page.PageID != "nojson" { + t.Errorf("UI.Page not decoded: %+v", p.UI.Page) + } + if len(p.http) != 1 { + t.Fatalf("http endpoints = %d, want 1", len(p.http)) + } + if p.http[0].Handler != "ping" || p.http[0].Method != "GET" { + t.Errorf("endpoint decoded wrong: %+v", p.http[0]) + } + if len(p.http[0].Roles) != 1 || p.http[0].Roles[0] != "admin" { + t.Errorf("roles not decoded: %+v", p.http[0].Roles) + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 4f24bef..4548955 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -667,20 +667,13 @@ func (ps *Plugins) LoadSource(name, code string) error { } // ---- UI ---- - if ui, ok := readPluginUI(w.L); ok { + if ui, ok := readPluginUI(w.L, p.Info.Name); ok { p.UI = ui } // ---- http endpoints ---- p.http = readPluginHTTP(w.L) - if ui, ok := readPluginUI(w.L); ok { - p.UI = ui - if ui.StateScope != nil { - ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth) - } - } - // ---- exports ---- // Read here, while the state is locked and before restore() runs: the list is // static for the life of the plugin, so Call never has to re-enter Lua just @@ -778,111 +771,112 @@ func pluginHookName(L *golua.State, stage string) (string, bool) { // non-conforming table yields nil, which Call treats as "nothing is callable": // a plugin that never declared an export simply has no callable surface. func readPluginExports(L *golua.State) []string { - L.GetGlobal(pluginGlobal) - if L.IsNil(-1) { - L.SetTop(0) - return nil - } - L.GetField(-1, "exports") - if L.Type(-1) != golua.LUA_TTABLE { - L.SetTop(0) - return nil - } - // luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering, - // before the json global is installed, and luaToJSON needs it. Going through - // JSON here silently produced an empty list, which then made every exported - // function look unexported. - raw := luaTableToGoValue(L, -1) - L.SetTop(0) - arr, ok := raw.([]interface{}) + // A plugin declares the functions the page may call as `exports = { "a", "b" }`. + // Anything not listed is unreachable from the WebUI, which is the whole point: + // it is a capability list, not a convenience. + raw, ok := pluginFieldValue(L, "exports") if !ok { return nil } - list := make([]string, 0, len(arr)) - for _, v := range arr { - if s := asString(v); s != "" { - list = append(list, s) - } - } - return list + return decStringList(map[string]interface{}{"v": raw}, "v") } -func readPluginUI(L *golua.State) (*UIExtension, bool) { +func readPluginUI(L *golua.State, name string) (*UIExtension, bool) { + // One decode at the edge, then pure field reads. + // + // On WHY luaTableToGoValue and not luaToJSON: the first version of this + // comment claimed registration runs BEFORE setupGlobals installs the json + // global, so luaToJSON returned empty. That explanation was repeated in three + // places and in a commit message, and it is WRONG — measured: setupGlobals + // runs at vm.go:574, these readers at 787, and json IS present on the stack + // when they run. + // + // What actually differs is INDEX SEMANTICS. luaTableToGoValue converts a + // NEGATIVE index to absolute immediately (abs = GetTop() + idx + 1); + // luaToJSON keeps the relative index and pushes it before Call. So after a + // SetTop(0) the two disagree, and the table-valued converter — the one that + // handles nested tables, arrays and mixed keys correctly — is the one that + // needs the stack shape kept intact. + // + // Which is exactly why the decode happens here, ONCE, immediately after + // GetGlobal+GetField, and every field read below is a pure function on the + // decoded Go value. The old readers repeated the stack dance per field. + m, ok := pluginFieldTable(L, "ui") + if !ok { + return nil, false + } + ctx := &decodeCtx{plugin: name} + ui := decodeUIExtension(ctx, m) + + if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 { + return nil, false + } + if len(ctx.problems) > 0 { + log.Printf("[plugins] %s: %s", ctx.plugin, strings.Join(ctx.problems, "; ")) + } + return &ui, true +} + +// pluginFieldTable reads one top-level declaration table off the plugin global. +func pluginFieldTable(L *golua.State, field string) (map[string]interface{}, bool) { L.GetGlobal(pluginGlobal) if L.IsNil(-1) { L.SetTop(0) return nil, false } - L.GetField(-1, "ui") + L.GetField(-1, field) if L.Type(-1) == golua.LUA_TNIL { L.SetTop(0) return nil, false } - // luaTableToGoValue, NOT luaToJSON. This runs while the plugin is - // registering, BEFORE setupGlobals installs the json global that luaToJSON - // depends on — so the conversion fails and every field except the ones on - // Page/Pages (read separately) comes back empty. That is why visibility and - // state_scope silently read as unset even though the .lua declares them: - // the third field in this file to be lost to the same cause, after - // readPluginExports and readPluginHTTP. - var ui UIExtension raw := luaTableToGoValue(L, -1) L.SetTop(0) - if m, ok := raw.(map[string]interface{}); ok { - ui.Visibility = asString(m["visibility"]) - if scope, ok := m["state_scope"].(map[string]interface{}); ok { - sc := &StateScope{} - if gs, ok := scope["global"].([]interface{}); ok { - for _, g := range gs { - if s := asString(g); s != "" { - sc.Global = append(sc.Global, s) - } - } - } - if ds, ok := scope["depth"].(map[string]interface{}); ok { - sc.Depth = map[string]int{} - for k, v := range ds { - if n, ok := asFloat(v); ok { - sc.Depth[k] = int(n) - } - } - sc.Depth = compileDepthMap(sc.Depth) - } - ui.StateScope = sc - } - if pg, ok := m["page"].(map[string]interface{}); ok { - ui.Page = uipageFromValue(pg) - } - if pgs, ok := m["pages"].([]interface{}); ok { - for _, it := range pgs { - if pm, ok := it.(map[string]interface{}); ok { - ui.Pages = append(ui.Pages, uipageFromValue(pm)) - } - } - } - if els, ok := m["elements"].([]interface{}); ok { - for _, it := range els { - em, ok := it.(map[string]interface{}) - if !ok { - continue - } - el := UIElement{ - Target: asString(em["target"]), - Anchor: asString(em["anchor"]), - Mount: asString(em["mount"]), - Audience: asString(em["audience"]), - } - if n, ok := asFloat(em["order"]); ok { - el.Order = int(n) - } - ui.Elements = append(ui.Elements, el) - } + m, ok := raw.(map[string]interface{}) + return m, ok +} + +// decodeUIExtension reads a plugin's `ui` declaration. +// +// The field list below is the single source of truth for what the gateway +// understands. A declaration using anything else is REPORTED rather than +// silently dropped, because the old hand-written reader ignored unknown keys with +// no trace: `visibilty = "admin"` (one character off) restricted nothing and +// looked exactly like a plugin that simply chose not to declare an audience. +func decodeUIExtension(ctx *decodeCtx, m map[string]interface{}) UIExtension { + ctx.strictKeys(m, "ui", "visibility", "state_scope", "page", "pages", "elements") + + var ui UIExtension + ui.Visibility = decString(m, "visibility") + + if scope := decTable(m, "state_scope"); scope != nil { + ctx.strictKeys(scope, "ui.state_scope", "global", "depth") + sc := &StateScope{Global: decStringList(scope, "global")} + if d := decIntMap(scope, "depth"); len(d) > 0 { + sc.Depth = compileDepthMap(d) } + ui.StateScope = sc } - if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 { - return nil, false + + if pg := decTable(m, "page"); pg != nil { + ui.Page = uipageFromValue(pg) } - return &ui, true + for _, pm := range decTableList(m, "pages") { + ui.Pages = append(ui.Pages, uipageFromValue(pm)) + } + for _, em := range decTableList(m, "elements") { + ctx.strictKeys(em, "ui.elements[]", "target", "anchor", "mount", "audience", "order") + el := UIElement{ + Target: decString(em, "target"), + Anchor: decString(em, "anchor"), + Mount: decString(em, "mount"), + Audience: decString(em, "audience"), + } + if n, ok := asFloat(em["order"]); ok { + el.Order = int(n) + } + ui.Elements = append(ui.Elements, el) + } + return ui } func (ps *Plugins) append(p *Plugin) { @@ -2176,64 +2170,82 @@ func containsPage(pages []*UIPage, id string) bool { // not exist. Failing the whole plugin would take a working billing widget down // over a typo in an experimental endpoint. func readPluginHTTP(L *golua.State) []HTTPHandler { - L.GetGlobal(pluginGlobal) - if L.IsNil(-1) { - L.SetTop(0) - return nil - } - L.GetField(-1, "http") - if L.Type(-1) != golua.LUA_TTABLE { - L.SetTop(0) - return nil - } - // luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop. + // `http` is a LIST of declarations, each naming the handler function by + // STRING: // - // luaToJSON needs the json global, which is not installed yet while a plugin - // is registering. And golua's GetField does NOT convert a numeric key: with - // a string index "1" it returns nil, so an index loop silently produced an - // empty list and every plugin appeared to declare no endpoints at all. - raw := luaTableToGoValue(L, -1) - L.SetTop(0) - items, ok := raw.([]interface{}) + // http = { + // { path = "health", method = "GET", roles = { "admin" }, handler = "health" }, + // } + // + // handler is a name, not a value. An inline function silently produced an + // empty handler field and the endpoint was dropped without a word. + raw, ok := pluginFieldValue(L, "http") if !ok { return nil } + // A single declaration may be given as a bare table instead of a one-element + // list; accepting both is friendlier than demanding one spelling. + items, isList := raw.([]interface{}) + if !isList { + if one, isTable := raw.(map[string]interface{}); isTable { + items = []interface{}{one} + } else { + return nil + } + } + ctx := &decodeCtx{plugin: "http"} var list []HTTPHandler for _, it := range items { - m, ok := it.(map[string]interface{}) + h, ok := it.(map[string]interface{}) if !ok { continue } - h := HTTPHandler{ - Path: asString(m["path"]), - Method: asString(m["method"]), - Handler: asString(m["handler"]), - Description: asString(m["description"]), - Public: m["public"] == true, + ctx.strictKeys(h, "http[]", "path", "method", "handler", "description", + "roles", "keys", "public") + dh := HTTPHandler{ + Path: decString(h, "path"), + Method: decString(h, "method"), + Description: decString(h, "description"), + Roles: decStringList(h, "roles"), + Keys: decStringList(h, "keys"), } - if roles, ok := m["roles"].([]interface{}); ok { - for _, r := range roles { - if s := asString(r); s != "" { - h.Roles = append(h.Roles, s) - } + // handler must be a NAME, not a value. asString would happily turn a + // function into its address ("function: 0x..."), which then failed much + // later at dispatch with no connection to the declaration that caused it. + // Rejecting it here names the field and the path. + if hv, present := h["handler"]; present { + name, isStr := hv.(string) + if !isStr { + ctx.warnf("http[%s]: handler must be the NAME of a function on the "+ + "plugin table, not the function itself", decString(h, "path")) + continue } + dh.Handler = name } - if keys, ok := m["keys"].([]interface{}); ok { - for _, k := range keys { - if s := asString(k); s != "" { - h.Keys = append(h.Keys, s) - } - } + if p, ok := h["public"].(bool); ok { + dh.Public = p } // A malformed entry is dropped rather than fatal: one bad route must not - // cost a plugin its hooks and pages. That is also why the mounted count - // can be lower than the declared count — /api/plugins lists what mounted. - if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil { - h.Method = method - if validateHandlerPath(h.Path) == nil && h.Handler != "" { - list = append(list, h) - } + // cost a plugin its hooks and pages. The reason is named so a plugin whose + // endpoint silently vanished can find out why. + method, mErr := normalizeHandlerMethod(dh.Method) + switch { + case mErr != nil: + ctx.warnf("http[%s]: %v", dh.Path, mErr) + continue + case validateHandlerPath(dh.Path) != nil: + ctx.warnf("http[%s]: invalid path", dh.Path) + continue + case dh.Handler == "": + ctx.warnf("http[%s]: handler must name a function on the plugin table "+ + "(an inline function is not supported)", dh.Path) + continue } + dh.Method = method + list = append(list, dh) + } + if len(ctx.problems) > 0 { + log.Printf("[plugins] %s", strings.Join(ctx.problems, "; ")) } return list } From 360a2a8644e5e1f11607f0f353426eb9b6360776 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 20:29:13 +0800 Subject: [PATCH 13/19] =?UTF-8?q?refactor(plugins):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E8=AF=8D=E6=B1=87=20=E2=80=94=E2=80=94=20Acc?= =?UTF-8?q?essRule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里: audienceAllows(want, role): "" / "all" → 全开 页面可见性 HTTPHandler.allows(role,key): "" → 仅 admin 注册端点 两个默认值各自都对:页面没人看得见就等于没渲染,页面不该被未声明的 audience 藏起来;而端点「忘了声明」绝不能等于对公网敞开。 危害不在默认值本身,在于**同一个问题有两套词汇**。运维配「谁能看 billing」和 「谁能调 /balance」得先学会两套规则,才能回答同一个问题。 ## 改动 新增 internal/lua/plugin_access.go,把两套规则收敛成一个 AccessRule, 并把**仍然不同的那一点——默认值——写成数据而不是留成隐含**: - AllowsCapability:空 = 拒绝(端点、函数) - AllowsVisibility:空 = 继承(页面、元素) 语法也统一成一种,`ParseAccessRule` / `Rule` 双向: "admin" / "admin, user" / "@alice, @bob" / "user, @alice" / "public" `@name` 是密钥、其余是角色,一��字符串表达得完规则编辑器要提供的全部选项。 ## 抽取时差点弄丢一个真实语义 `"all"` 是既有插件文件和**已保存的运维 override** 里的写法。统一后的解析器 不认识它,于是一个已发布的「全开」授权静默变成了关闭 —— 已部署的 billing、quota 可见性和所有存量 override 都会受影响。 是既有的 TestAudienceAllows 抓到的,它当场变红。这正是抽取的实质收益: 两个函数并排住着的时候,没人会想到去动那个共用字符串。 `TestAllIsAnAliasForPublic` 把它写成新类型的性质,而不是留给碰巧跑到的那 个套件去守着。 ## 顺手加的东西我又撤了 accessRuleFor 第一版把 roles 里写成 `@alice` 的项重分类成密钥,理由是 「这大概是个自然的笔误」。那是在重构里偷带行为变更:会把写了莫名其妙内容的 端点的可达范围悄悄放宽。已撤,注释里写明为什么这个笔误值得修是另一件事、 需要它自己的理由和判据。 ## 判据 七条性质,每条一个默认/边界:capability 默认关、visibility 默认继承、 keys 替换而非追加、keys 空时未认证调用者不匹配、admin 恒通、 "all" 别名、Rule() 往返稳定、角色与密钥名大小写/空格无关。 变异自证四次,全部变红:keys 变 OR、capability 默认全开、visibility 默认 拒绝、丢掉 "all" 别名。 头两次跑变异时报「没抓到」,是注入脚本自己写坏了(NameError / 替换串不 匹配)—— 变异没生效不等于判据无力,重写后确认四条都能抓到。 ## 一条被记录下来的意外 `TestRoleAndKeyUnionAdmitsBoth` 最初断言「同时列出 role 和 key 时两者都放行」。 debug 跑出来是相反的:keys 非空就整体替换掉 role,carol 被拒。 这个意外正是不把它放在注释里的理由。判据现在断言实际语义(替换),与既有 HTTPHandler.allows 一致,因此没有任何已部署行为改变;但它把「这是我们的选择」 写成了可执行的一句话,而不是默默沿用旧代码碰巧做的事。 Co-Authored-By: ModelRouter --- internal/lua/plugin_access.go | 180 +++++++++++++++++++++++++++++ internal/lua/plugin_access_test.go | 152 ++++++++++++++++++++++++ internal/lua/plugins.go | 50 +------- 3 files changed, 338 insertions(+), 44 deletions(-) create mode 100644 internal/lua/plugin_access.go create mode 100644 internal/lua/plugin_access_test.go diff --git a/internal/lua/plugin_access.go b/internal/lua/plugin_access.go new file mode 100644 index 0000000..6fa6efc --- /dev/null +++ b/internal/lua/plugin_access.go @@ -0,0 +1,180 @@ +package lua + +import "strings" + +// One vocabulary for "who may see / read / call this". +// +// Before this there were two rules with OPPOSITE defaults, living side by side: +// +// - audienceAllows(want, role): "" or "all" → everybody +// Used by UI visibility and per-page/per-element audiences. +// - HTTPHandler.allows(role, key): "" → admin ONLY +// Used by registered endpoints, where "I forgot to declare" must not mean +// "open to the internet". +// +// Both defaults are right for their own surface, and having them in one file +// with one name is how a reader ends up assuming the other one's rule applies. +// An operator configuring "who can see billing" and "who can call /balance" had +// to learn two vocabularies to answer the same question. +// +// The distinction that remains is not the RULE, it is the DEFAULT, so it is +// stated as data rather than left implicit: an empty grant is "deny" for +// capabilities and "inherit" for visibility, and every call site names which it +// wants. + +// AccessRule is a parsed grant: a set of roles, an optional set of named gateway +// keys, and an explicit public flag. +// +// Keys REPLACE roles rather than adding to them. That is not an arbitrary +// choice: "allow these two keys" read as "these two keys AND everyone in the +// user role" silently widens access to every user key, which is the opposite of +// what someone tightening a rule meant. `keys = { "@alice" }` therefore means +// exactly alice. +type AccessRule struct { + // Roles admit a gateway role: "admin", "user", … + Roles []string + // Keys admit a named gateway key, by CONFIG NAME (never the secret itself, so + // a rule file contains no credential and survives key rotation). + Keys []string + // Public admits anyone, including an unauthenticated caller. + Public bool +} + +// AccessAll is the grant that admits everyone. +func AccessAll() AccessRule { return AccessRule{Public: true} } + +// ParseAccessRule reads the one grant syntax the plugin API accepts: +// +// "admin" roles +// "admin, user" roles +// "@alice, @bob" named keys, replacing the role rule +// "user, @alice" the named keys plus the roles (union) +// "public" anyone +// "all" anyone (the older spelling of public) +// "" empty — meaning depends on the caller +// +// An "@name" token is a key, anything else is a role. That is unambiguous to +// type and to parse, and it means one string can express everything the rules +// editor needs to offer. +// +// "all" is kept as an alias for "public" because it is what existing plugin +// files and saved operator overrides already contain. Dropping it would have +// silently turned a published grant into a closed one. +func ParseAccessRule(s string) AccessRule { + var r AccessRule + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if strings.EqualFold(part, "public") || strings.EqualFold(part, "all") { + r.Public = true + continue + } + if strings.HasPrefix(part, "@") { + if k := strings.TrimSpace(strings.TrimPrefix(part, "@")); k != "" { + r.Keys = append(r.Keys, k) + } + continue + } + r.Roles = append(r.Roles, part) + } + return r +} + +// Rule renders the grant back to the syntax ParseAccessRule accepts. The admin +// page uses it to fill its input, so a value that round-trips is a value the +// operator can edit without translating. +func (r AccessRule) Rule() string { + var parts []string + for _, k := range r.Keys { + parts = append(parts, "@"+k) + } + for _, role := range r.Roles { + parts = append(parts, role) + } + if r.Public { + parts = append(parts, "public") + } + return strings.Join(parts, ", ") +} + +// Empty reports whether the grant names nobody. +func (r AccessRule) Empty() bool { + return len(r.Roles) == 0 && len(r.Keys) == 0 && !r.Public +} + +// AllowsCapability reports whether a CAPABILITY (an endpoint, a callable +// function) is reachable. Empty means DENY: a plugin that forgets to declare +// gets a 403 naming the field to set, never an open door. +// +// Admin is always admitted. An operator has to be able to inspect what a plugin +// exposes even while locked out of it, or a typo in a key list would leave +// nobody able to diagnose the endpoint they were trying to tighten. +func (r AccessRule) AllowsCapability(role, keyName string) bool { + if role == "admin" { + return true + } + if len(r.Keys) > 0 { + // A non-empty key list REPLACES the role rule (see the type comment). + // An unauthenticated caller has no key name, so a key-scoped capability + // is unreachable for them — even when the same rule also lists roles, + // because a caller with no key is also not the kind of caller a role + // rule was written for. + if keyName == "" { + return false + } + for _, k := range r.Keys { + if strings.EqualFold(k, keyName) { + return true + } + } + // The key list replaced the role rule, so a miss is a deny even though + // roles may also be listed. + return false + } + if r.Public { + return true + } + for _, want := range r.Roles { + if strings.EqualFold(want, role) { + return true + } + } + return false +} + +// AllowsVisibility reports whether a VIEW (a page, a UI element) is rendered. +// Empty means INHERIT: visibility is additive by nature — a page nobody can see +// is simply not rendered — so an undeclared audience must not hide a page the +// plugin clearly intends to publish. +func (r AccessRule) AllowsVisibility(role string) bool { + if r.Empty() || r.Public { + return true + } + if role == "admin" && len(r.Keys) > 0 { + // A key-scoped view still has to be inspectable by an admin, for the + // same reason capabilities do. + return true + } + for _, want := range r.Roles { + if strings.EqualFold(want, role) { + return true + } + } + return false +} + +// accessRuleFor folds the separate declaration fields of an endpoint into one +// grant, so the rules below live in one place instead of beside each field. +// +// It is a plain fold, deliberately. An earlier version also reclassified a role +// that happened to start with "@" into a key, on the theory that "roles = +// { '@alice' }" is a natural mistake. That is a behaviour change smuggled into a +// refactor: it would have quietly widened who reaches an endpoint whose author +// wrote something nonsensical. If that mistake is worth fixing, it is a change +// with its own justification and its own test, not a side effect of unifying +// two functions into one. +func accessRuleFor(roles, keys []string, public bool) AccessRule { + return AccessRule{Roles: roles, Keys: keys, Public: public} +} diff --git a/internal/lua/plugin_access_test.go b/internal/lua/plugin_access_test.go new file mode 100644 index 0000000..96655f5 --- /dev/null +++ b/internal/lua/plugin_access_test.go @@ -0,0 +1,152 @@ +package lua + +import ( + "strings" + "testing" +) + +// The two functions this file replaces had OPPOSITE defaults: +// +// audienceAllows: "" / "all" → everybody +// HTTPHandler.allows: "" → admin ONLY +// +// Both are correct for their surface. The hazard is that they are two +// vocabularies for the same question, so a reader — or an operator — assumes +// the rule that is more familiar applies. AccessRule keeps one rule and states +// the default as data at each call site. + +// TestCapabilityDefaultIsClosed: an endpoint that declares nobody must not be +// reachable. This is the property that makes "I forgot" fail safe. +func TestCapabilityDefaultIsClosed(t *testing.T) { + for _, tc := range []struct { + name string + rule string + }{ + {"empty", ""}, + {"nil-ish", " "}, + } { + if ParseAccessRule(tc.rule).AllowsCapability("user", "alice") { + t.Errorf("%s: an undeclared capability is reachable by a user", tc.name) + } + if !ParseAccessRule(tc.rule).AllowsCapability("admin", "alice") { + t.Errorf("%s: admin locked out of a capability they must be able to inspect", tc.name) + } + } +} + +// TestVisibilityDefaultIsInherits: the opposite default, on purpose. A page +// nobody can see is not rendered; an undeclared audience must not hide a page +// the author clearly meant to publish. +func TestVisibilityDefaultIsInherits(t *testing.T) { + if !ParseAccessRule("").AllowsVisibility("user") { + t.Error("an undeclared audience hides a page") + } + if ParseAccessRule("admin").AllowsVisibility("user") { + t.Error("an admin-only page was shown to a user") + } +} + +// TestNamedKeysReplaceRolesNotAdd: the single most consequential rule, and the +// one an "OR" implementation gets wrong. "allow these two keys" must not also +// admit every user key. +func TestNamedKeysReplaceRolesNotAdd(t *testing.T) { + r := ParseAccessRule("@alice, @bob") + if !r.AllowsCapability("user", "alice") { + t.Error("a named key was not admitted") + } + if r.AllowsCapability("user", "carol") { + t.Error("a key list leaked to every other user — the rule must REPLACE, not extend") + } + if r.AllowsCapability("user", "") { + t.Error("an unauthenticated caller — no key name at all — matched a named key") + } + if !r.AllowsCapability("admin", "") { + t.Error("admin locked out; a typo in a key list would leave nobody able to diagnose it") + } +} + +// TestRoleAndKeyUnionAdmitsNeitherSibling: what a rule naming BOTH a role and a +// key actually does. +// +// Written first as "both are admitted", which is the intuitive reading of a +// union — and the debug run showed the opposite: a non-empty key list REPLACES +// the role rule outright, so carol is denied even though "user" is listed. +// +// That surprise is exactly why the rule is named, commented and now tested. The +// question is whether the behaviour is the one we want; asserting it here +// documents the choice rather than quietly preserving whatever the old code +// happened to do. The behaviour matches the pre-existing HTTPHandler.allows, so +// nothing deployed changes. +func TestRoleAndKeyUnionAdmitsNeitherSibling(t *testing.T) { + r := ParseAccessRule("user, @alice") + if !r.AllowsCapability("user", "alice") { + t.Error("the named key was not admitted") + } + if r.AllowsCapability("user", "carol") { + t.Error("a listed role was honoured despite a non-empty key list; " + + "the key list must replace it, or a listed role silently widens " + + "access to every user key") + } + if r.AllowsCapability("user", "mallory") { + t.Error("an unrelated user was admitted") + } +} + +// TestAllIsAnAliasForPublic guards a real regression this refactor nearly +// shipped. "all" is what existing plugin files and already-saved operator +// overrides contain; when the unified parser did not know it, a published grant +// silently became a closed one. The pre-existing TestAudienceAllows is what +// caught it — this test states it as a property of the new type so the +// compatibility requirement is not left to whichever suite happens to run. +func TestAllIsAnAliasForPublic(t *testing.T) { + r := ParseAccessRule("all") + if !r.Public { + t.Fatal(`"all" did not parse as public`) + } + if !r.AllowsCapability("user", "carol") { + t.Error(`"all" closed a grant that was published as open`) + } + // It must also render back to the form the editor understands, or the next + // save of an untouched field would rewrite it. + if !strings.Contains(strings.ToLower(r.Rule()), "public") { + t.Errorf("Rule() = %q, want it to render as public", r.Rule()) + } +} + +// TestRuleRoundTrips: the admin page fills its input from Rule(), so a grant +// that does not round-trip is one an operator cannot edit without translating. +func TestRuleRoundTrips(t *testing.T) { + for _, in := range []string{ + "admin", + "admin, user", + "@alice", + "@alice, @bob", + "user, @alice", + "public", + "all", + } { + once := ParseAccessRule(in) + twice := ParseAccessRule(once.Rule()) + if once.Rule() != twice.Rule() { + t.Errorf("%q -> %q -> %q, not stable", in, once.Rule(), twice.Rule()) + } + if len(twice.Roles) != len(once.Roles) || len(twice.Keys) != len(once.Keys) { + t.Errorf("%q lost data across a round trip: %+v -> %+v", in, once, twice) + } + } +} + +// TestRoleMatchingIsCaseAndSpaceInsensitive: grants arrive typed by humans into +// a text input, and "Admin" not matching "admin" would lock an admin out of +// their own plugin. +func TestRoleMatchingIsCaseAndSpaceInsensitive(t *testing.T) { + if !ParseAccessRule(" Admin , User ").AllowsCapability("admin", "x") { + t.Error("casing and spacing defeated a role match") + } + if ParseAccessRule("Admin").AllowsCapability("user", "x") { + t.Error("a user was admitted by a case variant") + } + if !ParseAccessRule(" @Alice ").AllowsCapability("user", "alice") { + t.Error("casing defeated a named-key match") + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 4548955..eee9423 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -1134,18 +1134,9 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} { // "*" is deliberately NOT a synonym for "all": an audience that looks // restrictive in a config file while meaning everything is exactly the kind of // setting that fails silently. +// audienceAllows answers a VISIBILITY question, so an empty grant inherits. func audienceAllows(want, role string) bool { - want = strings.ToLower(strings.TrimSpace(want)) - if want == "" || want == "all" { - return true - } - role = strings.ToLower(strings.TrimSpace(role)) - for _, part := range strings.Split(want, ",") { - if strings.TrimSpace(part) == role { - return true - } - } - return false + return ParseAccessRule(want).AllowsVisibility(role) } // visibilityOverrides holds the operator's per-plugin audience overrides. @@ -2091,40 +2082,11 @@ func (h HTTPHandler) routePath() string { // // keyName is the caller key's configured name ("" when it has none), not the // secret. +// allows answers a CAPABILITY question, so an empty grant denies. See +// AccessRule.AllowsCapability for why the default is closed and why a non-empty +// key list replaces the role rule instead of adding to it. func (h HTTPHandler) allows(role, keyName string) bool { - // Admin is always admitted. An operator must be able to inspect what a plugin - // exposes even while locked out of it, or a typo in a key list would leave - // nobody able to diagnose the endpoint they were trying to tighten. - if role == "admin" { - return true - } - // Named keys REPLACE the role rule rather than adding to it. - if len(h.Keys) > 0 { - if keyName == "" { - return false - } - for _, k := range h.Keys { - if strings.EqualFold(strings.TrimSpace(k), keyName) { - return true - } - } - return false - } - if h.Public { - return true - } - if len(h.Roles) == 0 { - // No declared roles and no public opt-out: treat as admin-only. A plugin - // author who forgets this gets a 403 rather than an open endpoint, and - // the error says which field to set. - return role == "admin" - } - for _, r := range h.Roles { - if strings.EqualFold(strings.TrimSpace(r), role) { - return true - } - } - return false + return accessRuleFor(h.Roles, h.Keys, h.Public).AllowsCapability(role, keyName) } func (ps *Plugins) StateVisibleTo(name, role string) bool { From e5af4ec34c97573338b13597d773dca4e09b4dbf Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 21:56:10 +0800 Subject: [PATCH 14/19] =?UTF-8?q?fix(billing/quota):=20=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E7=94=A8=20os.clock=20=E6=98=AF=20CPU=20?= =?UTF-8?q?=E6=97=B6=E9=97=B4=EF=BC=8C12=20=E7=A7=92=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E4=BB=8E=E6=9C=AA=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 线上 quota 页面点刷新后卡在「探测中…」,13 秒仍未完成,按钮一直 disabled。 ## 实测根因(不是猜测,是量出来的) 单源探测,隔离实例,逐个测: juziai 16029 ms beizhi 16021 ms localzen 2516 ms frank 2028 ms sensenova 258 ms 而插件自己写着 `PROBE_BUDGET_MS = 12000`。**修复前**这两个源各耗 **24013 ms** —— 精确等于 4s 超时 × 2 次重试 × 3 个 adapter。预算形同虚设。 ## 原因 `exhausted()` 用的是 `os.clock()`,它返回 **CPU 时间**。探测几乎全耗在网络等待上, CPU 几乎不动,于是预算永远不触发。 判据 `TestPluginClockDuringNetworkWait` 直接量这件事: 3s blocked on HTTP: os.clock advanced 0.001s, os.time advanced 3s **001 秒。** 一个用 CPU 时间写的预算,在 I/O 等待下永远不会响。 这个陷阱和「用忙等测锁竞争」是同一个,只是下沉了一层 —— 而且我第一版判据 正是这么写错的:用自旋循环当「网络等待」,结果 os.clock 走了 1.624s / 2s, 看起来 os.clock 跟的是墙钟,差点得出「不是这个原因」的错误结论。必须用真实 HTTP 请求重测才能拿到真相。 代码里两处同样的写法(第 434 和 466 行),一起改掉。 ## 改完的效果 串行 20 源(/call/ 逐个) 84391 ms fanout 20 源(并行) 16022 ms ⇒ 5.27x 单个死上游从 24s 降到 16s(12s 预算 + 收尾)。 ## 还没解决的 16 秒仍然偏长,因为它等于**最慢那一个源**的预算。这不是并行失效,是并行 之后墙钟的必然下界。要再往下走只有两条路:把预算压到 4~6s,或者给已知死掉 的上游走缓存而不是每次重探。都是参数与策略选择,不是结构缺陷,留给下一步。 --- internal/lua/plugin_clock_test.go | 95 +++++++++++++++++++++++++++++++ internal/lua/plugins/quota.lua | 36 ++++++++---- 2 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 internal/lua/plugin_clock_test.go diff --git a/internal/lua/plugin_clock_test.go b/internal/lua/plugin_clock_test.go new file mode 100644 index 0000000..7902061 --- /dev/null +++ b/internal/lua/plugin_clock_test.go @@ -0,0 +1,95 @@ +package lua + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// TestPluginClockDuringNetworkWait pins the reason the probe budget never +// enforced itself. +// +// Measured on the live gateway before the fix: a source that skipped after +// timing out took 24013ms — exactly 4s timeout x 2 retries x 3 adapters — while +// the plugin's own PROBE_BUDGET_MS is 12000. A budget that never fires looks +// exactly like a budget nobody remembered to call. +// +// The cause is os.clock(): it measures CPU time, and a plugin blocked on the +// network consumes almost none of it. This is the same trap as measuring lock +// contention with a busy-wait, one layer down — and the first version of this +// test made that mistake, reporting os.clock tracking wall time because the +// "wait" it used was a spin loop burning real CPU. It had to be re-measured +// against an actual HTTP request. +func TestPluginClockDuringNetworkWait(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, pluginDir) + t.Cleanup(ps.Close) + ps.SetOutboundHosts([]string{"127.0.0.1"}) + + code := ` +local P = {} +P.name = "clocktest" +P.version = "1.0.0" +P.exports = { "measure" } +P.measure = function(payload) + local c0, w0 = os.clock(), os.time() + -- The real thing: block on the network. The VM sits inside Go's HTTP client, + -- so Lua executes nothing and consumes no CPU while this takes wall time. + __llmsproxy_http({ url = payload.url, method = "GET", timeout_ms = 5000 }) + return { + clock = os.clock() - c0, + wall = os.time() - w0, + } +end +return P +` + if err := os.WriteFile(filepath.Join(pluginDir, "clocktest.lua"), []byte(code), 0o644); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3 * time.Second) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + if err := ps.LoadDir(); err != nil { + t.Fatal(err) + } + p := ps.find("clocktest") + if p == nil { + t.Fatal("plugin not found") + } + if p.LoadError != "" { + t.Fatalf("plugin failed to compile: %s", p.LoadError) + } + out, err := ps.Call("clocktest", "measure", map[string]interface{}{"url": srv.URL + "/slow"}) + if err != nil { + t.Fatal(err) + } + m, _ := out.(map[string]interface{}) + clock, _ := m["clock"].(float64) + wall, _ := m["wall"].(float64) + t.Logf("3s blocked on HTTP: os.clock advanced %.3fs, os.time advanced %.0fs", clock, wall) + + if wall < 3 { + t.Fatalf("the request did not take 3 wall seconds (got %.0f)", wall) + } + if clock > wall/2 { + t.Fatalf("os.clock tracked wall time (%.3f vs %.0f) — the budget is not the cause; "+ + "look elsewhere", clock, wall) + } + t.Logf("CONFIRMED: blocked on the network for %.0fs, os.clock advanced %.3fs. "+ + "A budget written in os.clock never fires while a plugin waits on I/O.", wall, clock) +} diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 6317b21..50f7f96 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -431,10 +431,15 @@ PLUGIN.collect_all = function(payload) end local srcs = payload.sources if type(srcs) ~= "table" then return nil, "missing sources" end - local t1 = os.clock() + -- os.time, NOT os.clock. os.clock measures CPU time, and a probe spends + -- essentially none: measured on this gateway, 3s blocked on an HTTP request + -- advanced os.clock by 0.001s. A budget written in os.clock therefore never + -- fires, which is why a dead upstream ran the full + -- 4s timeout x 2 retries x 3 adapters = 24s against a "12 second" budget. + local t1 = os.time() local lim1 = PROBE_BUDGET_MS / 1000 local function spent() - return (os.clock() - t1) > lim1 + return (os.time() - t1) > lim1 end local budget = { exhausted = spent } local out = {} @@ -463,10 +468,10 @@ PLUGIN.collect_source = function(payload) -- reads correctly but breaks the moment the call site is `budget.exhausted()` -- from inside a pcall-guarded hook where the upvalue chain differs; the -- explicit capture below cannot go wrong that way. - local t0 = os.clock() + local t0 = os.time() local limit = PROBE_BUDGET_MS / 1000 local function exhausted() - return (os.clock() - t0) > limit + return (os.time() - t0) > limit end local budget = { exhausted = exhausted } for _, s in ipairs(srcs) do @@ -732,14 +737,21 @@ PLUGIN.ui = { var sub=document.getElementById("qa-sub"); if(sub) sub.textContent="探测中…"; var out; try{ - // One call per source, fired together. Probing is network-bound, so a - // sequential collect_all over 21 sources blows past the call's time budget - // and every card comes back as one 502 — the probes themselves were fine. - // Concurrency also makes the page honest about which sources are slow: - // each card appears as its own answer lands. - out = await Promise.all(srcs.map(function(s){ - return pluginAPI.call("quota","collect_source",{sources:[s],name:s.name}) - .catch(function(e){ return {name:s.name,skipped:true,error:String(e.message||e)}; }); + // ONE request, fanned out inside the gateway. + // + // The previous version fired Promise.all at /call/ — twenty concurrent + // browser requests. That looked concurrent and was not: each one queued on + // the plugin mutex for the whole Lua call, network wait included. Measured on + // the live gateway: 29s of wall clock for twenty probes, against 1.4–3.3s for + // a single probe with no contention. The page sat on "探测中…" for half a + // minute and read as broken. + // + // The comment that used to sit here claimed concurrency avoided a call budget + // overrun. It could not have: the requests were serialised, so the budget they + // overran was the very same one. It is a single call now because a + // request-per-item fan-out cannot be parallel from the client at all. + out = await pluginAPI.fanout("quota","collect_source",srcs.map(function(s){ + return {sources:[s],name:s.name}; })); // Mirror the host→vendor map the plugin keeps: a source that answered is // worth probing first next time, and the page has everything it needs to From ff8afaa43de78c30694267cb1dcfcee017192070 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 21:56:45 +0800 Subject: [PATCH 15/19] =?UTF-8?q?feat(plugins):=20=E5=88=86=E7=89=87?= =?UTF-8?q?=E8=B0=83=E7=94=A8=20=E2=80=94=E2=80=94=20=E8=AE=A9=E9=98=BB?= =?UTF-8?q?=E5=A1=9E=E5=9E=8B=E5=AF=BC=E5=87=BA=E5=87=BD=E6=95=B0=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E5=B9=B6=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 quota 页面 20 个源,刷新要 29 秒。页面用 `Promise.all` 并发发 20 个请求, **看起来是并发的,实际不是**。 `Plugins.Call` 持 `p.mu.Lock()`,锁一直持有到 `L.Call` 返回 —— **包含 Lua 函数 内部的全部网络等待**。20 个请求到了网关后排队,串行累加。 生产实测: 单源探测(无竞争) 1.4 – 3.3 s 20 源 Promise.all 29 s 墙钟 串行理论值 ≈ 29 s ← 与实测吻合 代码里其实已经知道这个问题:quota.lua 第 104 行写着「the whole page shares ONE Lua state under a write lock」,并为此把超时从 10s 压到 4s。但那只把 29s 变成 约 12s,没有解决「刷新不可用」。 而当初改成并发的理由(页面注释)是错的 —— 它假设并发能绕开串行。锁让它退化 回串行了。 ## 不能去掉锁 一个 Lua VM 只有一个栈。两个并发调用会破坏栈。锁是必要的,答案只能是**让每个 并发调用有自己的 VM,跑同一份插件源码**。 先量,再写实现(8 分片 × 300ms 本地 HTTP 端点): 共享 VM,8 次串行 2.407 s 8 个独立 VM 并行 0.304 s → 7.9x 顺带一个测量教训:这个 spike 第一版用**忙等**而不是网络 I/O,报出 2.3x。 那是量到了 12 核机器上的 CPU 争抢(负载 8.95),和锁无关。插件做的是网络等待, spike 就必须做网络等待 —— 和上一条提交里 os.clock 那个错误是同一类。 ## 实现 - `callOn(w, fn, args)`:从 `Call` 里抽出的「在某 VM 上调一个导出函数」。`Call` 从 66 行缩到 18 行,仍负责查找、export 白名单和共享 VM 的锁。 - `shardPool`:私有 VM 池,上限 32。开机约 0.1ms,池化省不了什么但省两次刷新 的重复开销。 - `ShardCall(name, fn, argsList)`:结果与输入**按位置对齐**。 - `POST /api/plugins/{name}/fanout/{fn}`:`{"args": [...]}`,上限 64 条。 - `pluginAPI.fanout()`:页面一次请求。 - quota.lua 改用 fanout。 ## 分片的硬约束(写进注释,不是靠约定) 分片 VM **不能碰 PLUGIN.state** —— state 在 VM 里,每个分片一份副本,billing 的累加会各分叉,saver 写出去的是随机某一份。分片只能用于只读、幂等的工作。 所以 `callOn` 既不查 export 白名单(`Call` 那层才是安全边界)也不落盘。 ## 一个分片失败不拖垮整批 一个上游连不上,不该让 20 张卡片变成一个错误。失败的分片在自己的位置留 null,其余照常返回。**但全部分片失败时报错** —— 那不是「某个上游挂了」, 是结构性问题,报成功会把它藏在满屏红卡片后面。 ## 顺手修掉一个既有缺陷:热重载是坏的 写分片源码来源时,判据直接把它顶了出来:`LoadSource` 无条件 append,而 `findLocked` 返回**第一个**匹配。实测: LoadDir 后 1 条 → LoadSource 后 2 条 [0] version=1 [1] version=2 find 返回 version=1 也就是说,**编辑插件并保存看起来什么都没发生** —— 旧版本一直被返回,新版本 躺在旁边不生效。修法是先 Unload 再加载。这不是为分片做的,是分片正好照到了 这条路径,已单独补判据 `TestReloadReplacesRatherThanAppends`。 ## 判据 七条:并行性(有界而非计时断言,8×200ms 串行是 1.6s,超过 1.0s 即红)、 位置对齐、未导出与未知插件被拒、单个失败不拖批、全失败报错、用已安装源码而 非磁盘、以及热重载替换而非追加。 全量 9 包通过。 Co-Authored-By: ModelRouter --- internal/gateway/plugins_api.go | 80 ++++++- internal/gateway/ui/index.html | 32 +++ internal/lua/plugin_shard.go | 210 +++++++++++++++++ internal/lua/plugin_shard_test.go | 366 ++++++++++++++++++++++++++++++ internal/lua/plugins.go | 61 ++++- 5 files changed, 741 insertions(+), 8 deletions(-) create mode 100644 internal/lua/plugin_shard.go create mode 100644 internal/lua/plugin_shard_test.go diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 3a794e0..65f01fb 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -117,6 +117,20 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { return } + // /api/plugins/{name}/fanout/{fn} — one call per argument set, in + // parallel, from one request. See handlePluginFanout for why the + // request-per-item form cannot be made concurrent from the client. + if i := strings.Index(path, "/fanout/"); i >= 0 { + name := path[:i] + fn := path[i+len("/fanout/"):] + if name == "" || fn == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "fanout needs a plugin and a function name") + return + } + g.handlePluginFanout(w, r, name, fn) + return + } + if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") return @@ -133,7 +147,7 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { // Visibility overrides and the endpoint access table, so the // plugin page renders what is IN FORCE rather than making the // operator read .lua files or config.yaml to find out. - "visibility": ps.VisibilityOverrides(), + "visibility": ps.VisibilityOverrides(), "http_access": ps.EndpointAccess(), }) case http.MethodPost: @@ -331,6 +345,68 @@ func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, writeJSON(w, http.StatusOK, map[string]interface{}{"result": result}) } +// handlePluginFanout runs one plugin export per element of a list, in parallel, +// from a SINGLE request. +// +// Why this exists: the quota page has twenty cards, and it used to fire twenty +// browser requests at /call/. They arrived concurrently and then serialised +// inside Plugins.Call, which holds the plugin mutex across the Lua function — +// including its network wait. Measured on the live gateway: 20 probes took 29s of +// wall clock while a single probe with no contention took 1.4–3.3s, so the page +// sat on "probing…" for half a minute. The browser-side concurrency was real and +// made no difference. +// +// A fan-out cannot be assembled from the client either: any request-per-item +// arrangement is exactly the shape that queues. The parallelism has to happen +// where the fan-out is received. +func (g *Gateway) handlePluginFanout(w http.ResponseWriter, r *http.Request, name, fn string) { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + return + } + var req struct { + // Args is a LIST of argument objects, one per call. A bare object is + // rejected rather than coerced: "args" meaning "one call" and "args" + // meaning "many" depending on its shape is how a caller ends up probing + // one source and believing it probed twenty. + Args []map[string]interface{} `json:"args"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + if len(req.Args) == 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "args must be a non-empty list of argument objects") + return + } + // The page legitimately has one card per configured source. The bound is not + // arbitrary: it stops one request from asking for thousands of VMs, and it is + // enforced before any work starts rather than discovered shard by shard. + if len(req.Args) > fanoutMaxArgs { + writeError(w, http.StatusBadRequest, "invalid_request", + fmt.Sprintf("args has %d entries, limit is %d", len(req.Args), fanoutMaxArgs)) + return + } + results, err := g.core.Plugins().ShardCall(name, fn, req.Args) + if err != nil { + writeError(w, http.StatusBadGateway, "plugin_error", err.Error()) + return + } + // A shard that failed contributes a null in its slot. That is deliberate and + // is NOT the same as the whole request failing: one unreachable upstream must + // not turn a page of twenty cards into a single error. + writeJSON(w, http.StatusOK, map[string]interface{}{"results": results}) +} + +// fanoutMaxArgs bounds one fan-out request. Twenty is the measured real source +// count on the gateway this was built for; the headroom is for operators with +// more sources configured. +const fanoutMaxArgs = 64 + func (g *Gateway) pluginDir() string { if c := g.core.Config(); c != nil { return c.PluginDir @@ -369,7 +445,6 @@ func (g *Gateway) removePlugin(name string) error { return g.core.Plugins().Unload(name) } - // handleEndpointAccess serves the operator's view and edit surface for one // plugin endpoint's reachable keys. // @@ -439,7 +514,6 @@ func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, n } } - // splitStateForKey rewrites a plugin state so that a non-admin caller sees only // the buckets that belong to them. // diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 6d9168e..e6e36ac 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -5781,6 +5781,38 @@ toast(t("kAutoChainGlobal")); if (!r.ok) throw new Error(body.error?.message || r.status); return body.result; }, + // fanout runs one of the plugin's exported functions once per argument + // set, IN PARALLEL, from a single request. + // + // `call` in a Promise.all is NOT this. Twenty concurrent /call/ requests + // arrive concurrently and then serialise on the plugin mutex inside the + // kernel — the lock is held across the Lua function, network wait + // included, because one Lua VM has one stack. Measured on a live + // gateway: twenty quota probes took 29s of wall clock through /call/, + // against 1.4–3.3s for a single uncontended probe. The browser-side + // concurrency bought nothing and the page looked simply broken. + // + // The parallelism has to happen where the fan-out is received, so it + // takes one request. Results come back positionally aligned with `argSets`; + // a failed item is null in its slot rather than an error for the batch, + // because one unreachable upstream must not blank a whole page of cards. + // + // Same authorization as call: admin-only, and the function must be in + // the plugin's exports. + async fanout(name, fn, argSets) { + const r = await fetch( + "/api/plugins/" + encodeURIComponent(name) + "/fanout/" + encodeURIComponent(fn), + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ args: argSets || [] }), + }, + ); + const body = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(body.error?.message || r.status); + return body.results || []; + }, onTabShown(fn) { PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || []; PLUGIN_TAB_CBS.__last.push(fn); diff --git a/internal/lua/plugin_shard.go b/internal/lua/plugin_shard.go new file mode 100644 index 0000000..d72ad90 --- /dev/null +++ b/internal/lua/plugin_shard.go @@ -0,0 +1,210 @@ +package lua + +import ( + "fmt" + "sync" +) + +// ShardCall runs one exported function across N private VMs and returns the +// results in the order the calls were given. +// +// Why this exists, measured on a 20-source gateway: +// +// one probe, no contention 1.4 – 3.3 s +// 20 probes via Promise.all 29 s wall clock +// the same 20 through one VM the SUM of all 20 +// +// The page was written to fire its probes concurrently, and the browser really +// did send twenty requests at once. They serialised anyway, in Plugins.Call, +// which holds the plugin mutex for the whole call — including the network wait +// inside the Lua function. A mutex around one Lua VM is not optional: the VM has +// a single stack, so two concurrent calls would corrupt it. The fix cannot be +// "drop the lock". +// +// What it can be: give each concurrent call its OWN VM, running the SAME plugin +// source. Measured with a 300ms local endpoint, 8 shards: +// +// shared VM, 8 in sequence 2.407 s +// 8 independent VMs 0.304 s → 7.9x +// +// A busy-wait version of the same spike reported only 2.3x, which was measuring +// core contention on a loaded box rather than the lock. Network I/O is what the +// plugin actually does, so network I/O is what the spike has to use. +// +// Why a private VM per call rather than a pool: a pool with a bounded size +// still queues, and the queue is exactly the symptom being fixed. Booting a VM +// is ~0.1ms, so a pool would buy nothing a fresh VM does not. +// +// What a shard VM must NOT do: +// +// - Touch PLUGIN.state. State lives in the Lua VM, and every shard would hold +// its own copy of it. The billing plugin's totals would diverge per shard +// and the saver would write whichever copy it happened to see. A shard is +// therefore for READ-ONLY, idempotent work: a probe, a query, a computation. +// - Register hooks. Hooks are the shared VM's job; a shard that registered one +// would simply never be asked, which is a confusing way to lose it. +// +// The plugin decides whether a function is shardable by being listed. There is +// no inference: a function that mutates state and is mistakenly listed would +// write to a copy that is thrown away, silently. +type shardPool struct { + ps *Plugins + + mu sync.Mutex + idle []*worker + live int + peak int + limit int +} + +// defaultShardLimit caps how many VMs a single shardCall may create. +// +// It is a memory bound, not a concurrency policy. Each VM costs roughly a +// megabyte of Lua state for a plugin this size, and a page that fires twenty +// probes should not be able to make the gateway allocate twenty VMs on demand +// with no ceiling. Twenty sources is the measured real case, so 32 leaves +// headroom without being unbounded. +const defaultShardLimit = 32 + +func newShardPool(ps *Plugins) *shardPool { + return &shardPool{ps: ps, limit: defaultShardLimit} +} + +// ShardCall is the Plugins-level entry point. +func (ps *Plugins) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) { + return ps.shards.ShardCall(name, fn, argsList) +} + +// ShardCall runs fn once per element of argsList, each on its own VM, and +// returns results positionally aligned with argsList. +// +// A shard that fails does not fail the batch. Its slot gets an error value and +// the others still return — the alternative is one unreachable upstream +// turning a twenty-card page into a single error, which is the behaviour this +// whole change exists to remove. +func (sp *shardPool) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) { + if len(argsList) == 0 { + return nil, nil + } + p := sp.ps.find(name) + if p == nil { + return nil, fmt.Errorf("plugin %s not found", name) + } + if !pluginExportAllowed(p, fn) { + return nil, fmt.Errorf("plugin %s does not export %q", name, fn) + } + // The source comes from the SHARED plugin's own record, so a shard runs + // exactly the code that is serving requests right now — including a version + // installed through the API, which never touches a file. Reading ps.dir + // instead would shard the previous version of an edited plugin. + code := p.script + if code == "" { + return nil, fmt.Errorf("plugin %s has no source to shard", name) + } + + out := make([]interface{}, len(argsList)) + errs := make([]error, len(argsList)) + var wg sync.WaitGroup + for i := range argsList { + wg.Add(1) + go func(i int) { + defer wg.Done() + w, release, err := sp.acquire(code, name) + if err != nil { + errs[i] = err + return + } + defer release() + res, err := callOn(w, fn, argsList[i]) + if err != nil { + errs[i] = fmt.Errorf("%s: %w", name, err) + return + } + out[i] = res + }(i) + } + wg.Wait() + + var failed int + for _, err := range errs { + if err != nil { + failed++ + } + } + if failed == len(argsList) { + // Every shard failed. That is not "one upstream is down" — something is + // structurally wrong, and reporting success with N error values would + // hide it behind a page full of red cards. + return nil, fmt.Errorf("all %d shards of %s.%s failed: %v", len(argsList), name, fn, errs[0]) + } + if failed > 0 { + if sp.ps.logf != nil { + sp.ps.logf("[plugins] %s.%s: %d/%d shards failed", name, fn, failed, len(argsList)) + } + } + return out, nil +} + +// acquire takes an idle VM or boots one, respecting the limit. +func (sp *shardPool) acquire(code, name string) (*worker, func(), error) { + sp.mu.Lock() + if n := len(sp.idle); n > 0 { + w := sp.idle[n-1] + sp.idle = sp.idle[:n-1] + sp.mu.Unlock() + return w, func() { sp.release(w) }, nil + } + if sp.limit > 0 && sp.live >= sp.limit { + sp.mu.Unlock() + return nil, nil, fmt.Errorf("shard limit %d reached", sp.limit) + } + sp.live++ + sp.mu.Unlock() + + w, err := bootPluginState(code, name, sp.ps.outboundAllowlist()) + if err != nil { + sp.mu.Lock() + sp.live-- + sp.mu.Unlock() + return nil, nil, err + } + return w, func() { sp.release(w) }, nil +} + +func (sp *shardPool) release(w *worker) { + if w == nil || w.L == nil { + return + } + sp.mu.Lock() + // Keep a small idle set: booting is cheap but not free, and a page that + // refreshes twice in a row should not pay for two rounds of VMs. + if len(sp.idle) < sp.limit { + sp.idle = append(sp.idle, w) + sp.mu.Unlock() + return + } + sp.live-- + sp.mu.Unlock() + w.L.Close() +} + +// Shutdown closes every pooled VM. Called from Plugins.Close. +func (sp *shardPool) Shutdown() { + if sp == nil { + return + } + sp.mu.Lock() + idle := sp.idle + sp.idle = nil + sp.live = 0 + sp.mu.Unlock() + for _, w := range idle { + if w != nil && w.L != nil { + w.L.Close() + } + } +} + +// A single-request fan-out does not belong in this file: it needs the HTTP layer +// (routing, auth, JSON encoding) and would be one thin wrapper over ShardCall. +// The gateway route will call ShardCall directly with the sources it parsed. diff --git a/internal/lua/plugin_shard_test.go b/internal/lua/plugin_shard_test.go new file mode 100644 index 0000000..14128db --- /dev/null +++ b/internal/lua/plugin_shard_test.go @@ -0,0 +1,366 @@ +package lua + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The sharding spike (zz_shard_spike_test.go) measured the speedup. These tests +// pin the properties that make it usable rather than just fast. + +// TestShardCallRunsInParallel is the regression this whole mechanism exists to +// prevent, expressed as a bound rather than as a stopwatch. +// +// The production failure was a 20-source quota page that took 29s: twenty +// concurrent browser requests serialised on one plugin mutex. If ShardCall ever +// stops being parallel — a lock reintroduced, the pool collapsed to one worker, +// the source read from the wrong place so every shard fails to boot — this goes +// red. A timing assertion would be flaky on a loaded box, so the bound is loose +// enough to survive load and tight enough that serialisation cannot hide inside +// it: 8 shards of 200ms each are 1.6s serial, and this fails above 1.0s. +func TestShardCallRunsInParallel(t *testing.T) { + dir, ps := newShardTestPlugins(t, ` +local P = {} +P.name = "probe" +P.version = "1.0.0" +P.exports = { "work" } +P.work = function(payload) + local res = __llmsproxy_http({ url = payload.url, method = "GET", timeout_ms = 5000 }) + if res == nil then return { ok = false } end + return { ok = true, status = res.status } +end +return P +`) + _ = dir + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + ps.SetOutboundHosts([]string{"127.0.0.1"}) + + args := make([]map[string]interface{}, 8) + for i := range args { + args[i] = map[string]interface{}{"url": srv.URL + "/probe"} + } + + t0 := time.Now() + out, err := ps.ShardCall("probe", "work", args) + elapsed := time.Since(t0) + if err != nil { + t.Fatalf("ShardCall: %v", err) + } + if len(out) != 8 { + t.Fatalf("got %d results, want 8", len(out)) + } + for i, r := range out { + m, ok := r.(map[string]interface{}) + if !ok || m["ok"] != true { + t.Errorf("shard %d did not succeed: %#v", i, r) + } + } + // 8 x 200ms serialised is 1.6s. Parallel is ~200ms. The bound sits between + // them with room for a loaded machine on either side. + if elapsed > 1000*time.Millisecond { + t.Errorf("8 shards of 200ms took %v — that is serialised, not parallel", elapsed) + } + t.Logf("8 shards took %v", elapsed) +} + +// TestShardCallIsPositionallyAligned: the page renders cards in the order the +// sources came back, so a result landing in the wrong slot shows one upstream's +// balance under another upstream's name. That is worse than an error, because it +// looks correct. +func TestShardCallIsPositionallyAligned(t *testing.T) { + dir, ps := newShardTestPlugins(t, ` +local P = {} +P.name = "echo" +P.version = "1.0.0" +P.exports = { "work" } +P.work = function(payload) + -- Later slots sleep longer, so a result that came back out of order — which is + -- exactly what a correct-but-racy implementation produces — cannot pass. + local n = tonumber(payload.n) + local acc = 0 + for _ = 1, n do acc = acc + 1 end + return { n = n, acc = acc } +end +return P +`) + _ = dir + + // Delays are in Lua-visible work, not wall time: the point is that slot i + // takes longer than slot i-1, so completion order is the REVERSE of input + // order while the correct implementation still returns input order. + var args []map[string]interface{} + for n := 1; n <= 8; n++ { + args = append(args, map[string]interface{}{"n": n}) + } + out, err := ps.ShardCall("echo", "work", args) + if err != nil { + t.Fatalf("ShardCall: %v", err) + } + for i, r := range out { + m, ok := r.(map[string]interface{}) + if !ok { + t.Fatalf("slot %d is not a table: %#v", i, r) + } + got, gotOK := m["n"].(float64) + if !gotOK { + t.Fatalf("slot %d has no n: %#v", i, m) + } + if int(got) != i+1 { + t.Errorf("slot %d carries n=%v; results are not aligned with the input", i, got) + } + } +} + +// TestShardCallRejectsUnknownPluginAndUnexported: a shard boots a VM from +// arbitrary source, so the export allowlist is the ONLY thing standing between a +// page and the plugin's internals. It has to be checked on this path too. +func TestShardCallRejectsUnknownPluginAndUnexported(t *testing.T) { + dir, ps := newShardTestPlugins(t, ` +local P = {} +P.name = "probe" +P.version = "1.0.0" +P.exports = { "public" } +P.public = function() return { ok = true } end +P.secret = function() return { leaked = true } end +return P +`) + _ = dir + + if _, err := ps.ShardCall("nope", "public", []map[string]interface{}{{}}); err == nil { + t.Error("an unknown plugin was sharded") + } + _, err := ps.ShardCall("probe", "secret", []map[string]interface{}{{}}) + if err == nil { + t.Fatal("an unexported function was sharded — the allowlist is the security boundary") + } + if !strings.Contains(err.Error(), "does not export") { + t.Errorf("error does not name the problem: %v", err) + } +} + +// TestShardFailureDoesNotSinkTheBatch: one unreachable upstream must not turn a +// twenty-card page into a single error. That is the behaviour the whole change +// exists to remove, so it is pinned directly. +func TestShardFailureDoesNotSinkTheBatch(t *testing.T) { + dir, ps := newShardTestPlugins(t, ` +local P = {} +P.name = "probe" +P.version = "1.0.0" +P.exports = { "work" } +P.work = function(payload) + if payload.fail == true then error("upstream exploded") end + return { ok = true } +end +return P +`) + _ = dir + + args := []map[string]interface{}{ + {}, + {"fail": true}, + {}, + } + out, err := ps.ShardCall("probe", "work", args) + if err != nil { + t.Fatalf("one failing shard sank the batch: %v", err) + } + if len(out) != 3 { + t.Fatalf("got %d results, want 3", len(out)) + } + if out[1] != nil { + t.Errorf("the failing shard returned %#v, want nil", out[1]) + } + for _, i := range []int{0, 2} { + if out[i] == nil { + t.Errorf("healthy shard %d was lost because another shard failed", i) + } + } +} + +// TestShardAllFailingIsAnError: the opposite case. When EVERY shard fails the +// batch has not succeeded with N red cards — something structural is wrong, and +// reporting success would hide it behind the page's own rendering. +func TestShardAllFailingIsAnError(t *testing.T) { + dir, ps := newShardTestPlugins(t, ` +local P = {} +P.name = "probe" +P.version = "1.0.0" +P.exports = { "work" } +P.work = function(payload) error("always explodes") end +return P +`) + _ = dir + + out, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}, {}, {}}) + if err == nil { + t.Fatalf("a batch where every shard failed reported success: %#v", out) + } + if !strings.Contains(err.Error(), "all 3 shards") { + t.Errorf("error does not say the batch failed wholesale: %v", err) + } +} + +// TestShardUsesInstalledSourceNotDisk: a plugin installed through the API never +// touches a file. If sharding re-read the plugin directory it would boot the +// wrong code — or nothing — while the shared VM served the right one, and the two +// would disagree with no error anywhere. +func TestShardUsesInstalledSourceNotDisk(t *testing.T) { + dir, ps := newShardTestPlugins(t, ` +local P = {} +P.name = "probe" +P.version = "1.0.0" +P.exports = { "work" } +P.work = function() return { ok = true, version = "from-disk" } end +return P +`) + _ = dir + + // Replace the plugin with code that exists only in memory. + const inMemory = ` +local P = {} +P.name = "probe" +P.version = "2.0.0" +P.exports = { "work" } +P.work = function() return { ok = true, version = "from-memory" } end +return P +` + if err := ps.LoadSource("probe", inMemory); err != nil { + t.Fatalf("LoadSource: %v", err) + } + // The file on disk still says from-disk; a disk-reading shard would report it. + if _, err := os.Stat(filepath.Join(dir, "plugins", "probe.lua")); err != nil { + t.Logf("no file on disk at all: %v", err) + } + + out, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}}) + if err != nil { + t.Fatal(err) + } + m, _ := out[0].(map[string]interface{}) + if got, _ := m["version"].(string); got != "from-memory" { + t.Errorf("shard ran version %q, want from-memory — it booted the wrong source", got) + } +} + +// TestShardPoolIsBounded: the pool must not let a page allocate VMs without a +// ceiling. Twenty sources is the real case, so the bound sits above it. +func TestShardPoolIsBounded(t *testing.T) { + dir, ps := newShardTestPlugins(t, ` +local P = {} +P.name = "probe" +P.version = "1.0.0" +P.exports = { "work" } +P.work = function() return { ok = true } end +return P +`) + _ = dir + + if defaultShardLimit < 20 { + t.Fatalf("the limit (%d) is below the measured 20-source case", defaultShardLimit) + } + // Ask for more than the limit allows, sequentially, so the pool is the only + // thing that could refuse. + n := defaultShardLimit + 8 + var refused int + for i := 0; i < n; i++ { + if _, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}}); err != nil { + refused++ + } + } + // Sequential calls reuse one pooled VM, so nothing should be refused here — + // the point is that the batch path is what can exceed the limit. + t.Logf("sequential calls: %d of %d refused", refused, n) + + batch := make([]map[string]interface{}, defaultShardLimit+8) + for i := range batch { + batch[i] = map[string]interface{}{} + } + _, err := ps.ShardCall("probe", "work", batch) + if err != nil && !strings.Contains(err.Error(), "all") && !strings.Contains(err.Error(), "shard limit") { + t.Errorf("over-limit batch produced an unexpected error: %v", err) + } + t.Logf("over-limit batch of %d: err=%v", len(batch), err) +} + +// newShardTestPlugins builds a Plugins over a temp dir holding one plugin. +func newShardTestPlugins(t *testing.T, code string) (string, *Plugins) { + t.Helper() + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, pluginDir) + t.Cleanup(ps.Close) + if err := os.WriteFile(filepath.Join(pluginDir, "probe.lua"), []byte(code), 0o644); err != nil { + t.Fatal(err) + } + if err := ps.LoadDir(); err != nil { + t.Fatal(err) + } + return dir, ps +} + +// TestReloadReplacesRatherThanAppends is the defect the shard source lookup +// exposed, pinned on its own so it does not depend on sharding to be exercised. +// +// LoadSource appended unconditionally, and findLocked returns the FIRST match — +// so LoadDir + LoadSource of an edited plugin left two records and every lookup +// kept answering from the old one. Measured before the fix: /api/plugins listed +// both versions, and Call answered from v1 while v2 sat inert beside it. Saving +// an edit appeared to do nothing at all. +func TestReloadReplacesRatherThanAppends(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, pluginDir) + t.Cleanup(ps.Close) + + if err := os.WriteFile(filepath.Join(pluginDir, "probe.lua"), + []byte("local P={}\nP.name='probe'\nP.version='1'\nreturn P\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := ps.LoadDir(); err != nil { + t.Fatal(err) + } + if got := len(ps.List()); got != 1 { + t.Fatalf("after LoadDir: %d records, want 1", got) + } + + if err := ps.LoadSource("probe", + "local P={}\nP.name='probe'\nP.version='2'\nreturn P\n"); err != nil { + t.Fatal(err) + } + if got := len(ps.List()); got != 1 { + t.Errorf("after reload: %d records, want 1 — the old one was kept", got) + } + p := ps.find("probe") + if p == nil { + t.Fatal("plugin gone after reload") + } + if p.Info.Version != "2" { + t.Errorf("find returns version %q, want 2 — lookups still answer from the old copy", p.Info.Version) + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index eee9423..29f45ba 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -32,9 +32,9 @@ package lua // less featureful, so the split is explicit and documented. import ( - "log" "encoding/json" "fmt" + "log" "os" "path/filepath" "sort" @@ -265,6 +265,10 @@ type Plugins struct { // visibility holds the operator's per-plugin audience overrides, keyed by // plugin name. Consulted by UIForRole and the call endpoint's authorisation. visibility visibilityOverrides + // shards runs exported functions on private VMs so blocking work can be + // genuinely parallel. See plugin_shard.go for why the shared VM's lock makes + // concurrency impossible and what a shard must therefore never touch. + shards *shardPool // stateDirDisabled turns persistence off. Used by tests that assert state // starts empty, and by an embedder that has no writable plugin dir. stateDirDisabled bool @@ -462,6 +466,7 @@ func NewPlugins(vm *VM, dir string) *Plugins { // later, and a saver that only exists when dir != "" would silently never // flush. markDirtyLocked and flush both no-op when the dir is empty. ps.save = newStateSaver(ps) + ps.shards = newShardPool(ps) return ps } @@ -499,6 +504,9 @@ func (ps *Plugins) Close() { if ps == nil { return } + // Shard VMs are separate golua states; leaving them open leaks a VM per + // shard on every reload. + ps.shards.Shutdown() ps.save.Close() } @@ -603,6 +611,23 @@ func (ps *Plugins) LoadSource(name, code string) error { if err := os.MkdirAll(ps.dir, 0755); err != nil && ps.dir != "" { return err } + // Reloading must REPLACE, not append. + // + // LoadSource was reachable both from LoadDir and from the install/reload API, + // and it appended unconditionally. Loading a plugin that was already loaded + // therefore left two records with the same name, and findLocked — which + // returns the FIRST match — kept handing out the OLD one. Measured: after + // LoadDir + LoadSource of an edited plugin, /api/plugins listed both + // versions and Call answered from version 1 while version 2 sat inert + // alongside it. Editing a plugin and saving appeared to do nothing. + // + // Unload is a no-op when nothing is loaded under that name, so this is safe + // on the first load. + if ps.find(name) != nil { + if err := ps.Unload(name); err != nil { + return fmt.Errorf("replace %s: %w", name, err) + } + } p := &Plugin{ Info: PluginInfo{Name: name}, Hooks: map[Stage]string{}, @@ -1935,6 +1960,17 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{}) // quota report is a nested structure with numbers and booleans that must not be // flattened. Hook results still go through invoke, whose map-typed contract the // state merge depends on. +// Call invokes an exported function on a plugin's SHARED VM, serialised by the +// plugin's mutex. +// +// The lock is what makes one Lua VM safe to share, and it is held for the whole +// call — including any network wait inside it. That is correct for a hook and +// wrong for a function that blocks: twenty concurrent quota probes serialise +// into the sum of their timeouts (measured: 29s on a 20-source gateway, against +// 1.4–3.3s for a single probe with no contention). +// +// callOn below is the same work without the shared-VM assumption, so a caller +// that owns a private VM can use it directly. func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface{}, error) { ps.mu.RLock() var p *Plugin @@ -1952,12 +1988,27 @@ func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface return nil, fmt.Errorf("plugin %s does not export %q", name, fn) } p.mu.Lock() + defer p.mu.Unlock() w := p.state if w == nil { - p.mu.Unlock() return nil, fmt.Errorf("no state for plugin %s", name) } - defer p.mu.Unlock() + return callOn(w, fn, args) +} + +// callOn runs one exported function on a specific VM. +// +// It does NOT check the export allowlist and does NOT persist state: both belong +// to the shared-VM path in Call. A shard VM has no plugin identity to check +// against and no state file of its own — the export allowlist in Call is the +// security boundary, and calling this with a function the plugin does not export +// is a programming error, not an attack. +// +// The caller must own w exclusively. No lock is taken here. +func callOn(w *worker, fn string, args map[string]interface{}) (interface{}, error) { + if w == nil || w.L == nil { + return nil, fmt.Errorf("no lua state") + } L := w.L L.SetTop(0) defer L.SetTop(0) @@ -1990,14 +2041,14 @@ func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface L.Pop(1) } if errMsg != "" { - return nil, fmt.Errorf("plugin %s: %s", name, errMsg) + return nil, fmt.Errorf("plugin: %s", errMsg) } if L.GetTop() < 1 || L.IsNil(-1) { return nil, nil } var out interface{} if err := luaToJSON(L, -1, &out); err != nil { - return nil, nil + return nil, err } return out, nil } From 2272b4ecbdc575addb014d642be403a71c2eaf88 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 07:21:43 +0800 Subject: [PATCH 16/19] =?UTF-8?q?feat(quota):=20=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E9=80=80=E9=81=BF=E6=94=B9=E6=8C=87=E6=95=B0=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=AD=BB=E4=B8=8A=E6=B8=B8=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=AF=8F=2030=20=E5=88=86=E9=92=9F=E9=87=8D=E4=BB=98=E4=B8=80?= =?UTF-8?q?=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么 30 分钟 TTL 治不了死上游 线上有两个 host 已经**不可达十个小时**,而 PROBE_MEMO_TTL 是 1800 秒。 于是每次刷新它们的负 memo 都已经过期,每次都重付三个 adapter 的超时: 每个源 24s(预算修好前)、16s(修好后)。固定 TTL 对「已经死了很久」这个 形状完全无效 —— 它只会一直重复付。 指数退避:连续失败 streak 次,等待 1800 × 2^(streak-1),封顶 6 小时。 成功会把 memo 覆盖成 vendor 字符串,streak 自然重置。 封顶不是可选项 —— 没有它,下线一周的 host 就不再被探测,页面会**永久** 把它误报成「没有余额接口」。 ## 过程中判据抓到的三个真 bug **① 记录守卫让 streak 永远是 1** `if rep == nil and not HOST_MEMO[host]` 只在 host **第一次**失败时为真。 之后 memo 已是 table,守卫为假,什么都不写。实测连读四次都是 `{at:... streak:1}` —— 退避退化成固定 30 分钟,正是它要解决的问题。 **② `return 0` 在 Lua 里是真值** `if streak == nil or streak <= 0 then return 0 end` —— 只有 nil 和 false 是假值。调用点写的是 `if backoffSeconds(...) then 跳过`,于是**所有没有 streak 字段的 memo 会被永久跳过**(而那正是本改动之前写的每一个 memo)。 **③ 上限路径 `return BACKOFF_MAX` 也是真值** 21600 会被判成「仍在等待」,封顶反而变成「永远不重试」—— 恰好是封顶要防的 那个故障。现在所有路径只返回布尔。 三个都是静默的:症状只是页面少了几张卡片,没有任何报错。 ## 判据 五条,全部读**实际发布的 quota.lua**(go:embed 那份),不是副本: - 退避确实被调用(变异把调用点换成 `if false` 时会红) - 窗口表:每个 streak 的窗口长度、窗口内跳过、窗口过期后重试、 荒谬 streak 也必须重试 - 只返回布尔(六种输入,含无 streak 字段的老 memo) - 记录守卫会替换 memo 而非合并 - 通过真实插件:首次失败记 streak=1;**跳过不算失败**(否则一个从不被探测的 host 会无限增长 streak,再也不会被重试) 变异自证六次全部变红:`return 0`、上限返回数字、守卫不替换、调用点去掉、 去掉上限、倍数改成 1。头一次跑「守卫不替换」漏放了 —— 因为我把集成判据缩到 只验首次记录,补了一条源码断言才抓住。 ## 这个文件的判据绕了远路,如实记下 `HOST_MEMO` 是进程内缓存,只在首次 collect 时从 state 恢复,之后只往回写。 所以从 Go 侧改 state 对插件不可见 —— 我先是不自知地这么试了两轮,把一个 **正确工作的退避**看成坏的。现在窗口策略直接验函数(经 shim 导出插件自己的 backoffSeconds,不是重新实现),集成判据只留真正能从外部观察的两条。 shim 也踩过:直接把 `backoffSeconds` 当 export,而 Go 侧传的是 table, 于是 at=table、streak=nil,每次都走「无 streak」分支返回 false —— 看起来 和「退避永不触发」一模一样。包一层就对了。 --- internal/lua/plugin_shard.go | 7 + internal/lua/plugins/quota.lua | 69 +++++- internal/lua/quota_backoff_test.go | 338 +++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 internal/lua/quota_backoff_test.go diff --git a/internal/lua/plugin_shard.go b/internal/lua/plugin_shard.go index d72ad90..aa5fa4e 100644 --- a/internal/lua/plugin_shard.go +++ b/internal/lua/plugin_shard.go @@ -2,6 +2,7 @@ package lua import ( "fmt" + "os" "sync" ) @@ -124,6 +125,12 @@ func (sp *shardPool) ShardCall(name, fn string, argsList []map[string]interface{ }(i) } wg.Wait() + if os.Getenv("SHARD_DEBUG") != "" { + sp.mu.Lock() + idle, live := len(sp.idle), sp.live + sp.mu.Unlock() + fmt.Fprintf(os.Stderr, "[shard] %s.%s: %d args, %d VMs live, %d idle\n", name, fn, len(argsList), live, idle) + } var failed int for _, err := range errs { diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 50f7f96..0b5ca6d 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -298,6 +298,52 @@ local HOST_MEMO = {} local HOST_MEMO_AT = {} local PROBE_MEMO_TTL = 1800 -- 30 minutes +-- Exponential backoff for hosts that answer nothing. +-- +-- A flat TTL is the wrong shape for a dead upstream. Measured on this gateway: +-- two hosts had been unreachable for 10 HOURS, and because PROBE_MEMO_TTL is 30 +-- minutes their negative memo was stale on every single page open — so each +-- refresh re-paid the full cost of three adapters (measured: 24s per source +-- before the probe budget was fixed, 16s after). A host that is down should not +-- be re-examined every 30 minutes forever; each consecutive failure doubles the +-- wait, so a long-dead host costs almost nothing while a host that was briefly +-- down is retried within the TTL as before. +-- +-- The cap matters as much as the doubling: without it a host that is down for a +-- week stops being probed at all, and the page would quietly and permanently +-- misreport it as having no balance endpoint. +-- +-- SUCCESS resets the streak. This is only a probe-scheduling decision — a source +-- that stops answering is still shown, still counted, and still retried. +local BACKOFF_BASE = 1800 -- first retry after the usual TTL +local BACKOFF_FACTOR = 2 -- doubles per consecutive miss +local BACKOFF_MAX = 21600 -- 6 hours; beyond this we always re-probe + +-- backoffSeconds returns how long to wait before re-probing a host that has +-- missed `streak` times in a row, and 0 when it is due now. +-- +-- `at` is when the host last failed. streak is stored inside the memo table +-- itself so it survives a restart: a process restart must not reset the backoff +-- and re-pay the cost it was avoiding. +-- Returns a BOOLEAN and nothing else. +-- +-- In Lua only nil and false are falsy, so every path here must return one of +-- those. Two versions of this got it wrong and both were caught by a test that +-- asserts the type: `return 0` for "no streak" (0 is truthy, so a host with no +-- streak — which is every memo written before this change — would be skipped +-- forever, silently), and `return BACKOFF_MAX` on the cap path (21600 is +-- truthy too, so a long-dead host would never be re-probed, which is the exact +-- failure the cap exists to prevent). +local function backoffSeconds(at, streak) + if streak == nil or streak <= 0 then return false end + local wait = BACKOFF_BASE + for _ = 2, streak do + wait = wait * BACKOFF_FACTOR + if wait >= BACKOFF_MAX then wait = BACKOFF_MAX break end + end + return (os.time() - (at or 0)) < wait +end + -- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of -- dead upstreams runs to the sum of every timeout — measured at 149s — and the -- plugin's write lock is held the whole time. @@ -325,6 +371,12 @@ local function collectSource(src, budget) -- exactly the confusion that made an earlier revision drop five sources. if type(known) == "table" then local seen = known.at or 0 + -- A streak is a host that has failed repeatedly. Skip it outright until its + -- backoff expires, instead of re-paying three adapter timeouts every TTL. + if backoffSeconds(seen, known.streak) then + return { name = src.name, host = host, skipped = true, + reason = "backing off after " .. tostring(known.streak) .. " failed probes" } + end if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then known = nil else @@ -394,8 +446,21 @@ local function collectSource(src, budget) -- would make the page quietly stop reporting a source that started working. -- The stamp is what turns a 60s first load into a fast steady state without -- freezing what the page can see. - if rep == nil and not HOST_MEMO[host] then - HOST_MEMO[host] = { at = os.time() } + -- Record the negative result, and GROW THE STREAK. + -- + -- The guard used to be `not HOST_MEMO[host]`, which is true only the FIRST time + -- a host fails: on every later failure the memo already held a table, the + -- guard was false, and nothing was written. The streak therefore stayed pinned + -- at 1 forever and the backoff never grew. Measured while writing this: the + -- memo read back `{at:... streak:1}` after four consecutive failures. + -- + -- A success overwrites the memo with a vendor string, so a host that recovers + -- resets its streak automatically. + if rep == nil then + local prev = HOST_MEMO[host] + local streak = 1 + if type(prev) == "table" then streak = (prev.streak or 1) + 1 end + HOST_MEMO[host] = { at = os.time(), streak = streak } end if rep ~= nil then HOST_MEMO_AT[host] = os.time() diff --git a/internal/lua/quota_backoff_test.go b/internal/lua/quota_backoff_test.go new file mode 100644 index 0000000..447f0fe --- /dev/null +++ b/internal/lua/quota_backoff_test.go @@ -0,0 +1,338 @@ +package lua + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The backoff lives in quota.lua — a shipped plugin, not Go — so these tests read +// the file that actually ships (readShippedPlugin) rather than a copy that can +// drift from it. + +// TestQuotaBackoffIsAppliedInSource guards the wiring. +// +// It exists because of a mutation that removed the call site entirely (`if false +// then`) and every other test in this file still passed. The other tests cover +// the decision FUNCTION and the streak RECORDING; neither exists in order to be +// unused. A backoff nobody consults is indistinguishable from no backoff, and the +// symptom is the original one — a dead host re-probed on every page open. +func TestQuotaBackoffIsAppliedInSource(t *testing.T) { + src := readShippedPlugin(t, "quota.lua") + if !strings.Contains(src, "if backoffSeconds(seen, known.streak) then") { + t.Error("quota.lua never consults backoffSeconds for a host with a streak — " + + "the backoff is computed but not applied") + } + if !strings.Contains(src, `reason = "backing off after "`) { + t.Error("the skip path does not say why it skipped; a silently skipped host " + + "looks exactly like a host that simply has no balance endpoint") + } + // The recording guard has to REPLACE the memo, not merge into it. The bug this + // guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a + // host's first failure — so the streak stayed pinned at 1 forever. Asserted on + // the source because the mutation that reintroduces it leaves the visible + // behaviour of the FIRST collect unchanged, and that is all the integration + // test can reach inside one process. + if !strings.Contains(src, "if rep == nil then") { + t.Error("the negative result is recorded under a guard that skips every " + + "failure after the first — the streak will never grow") + } + if strings.Contains(src, "and not HOST_MEMO[host] then") { + t.Error("the recording guard excludes a host that already has a memo, so " + + "consecutive failures are never counted") + } +} + +// TestQuotaBackoffWindowIsCapped guards the failure mode an exponential backoff +// has when it is not capped: a host that is down for a week stops being probed +// and the page permanently misreports it as having no balance endpoint. +func TestQuotaBackoffWindowIsCapped(t *testing.T) { + src := readShippedPlugin(t, "quota.lua") + for _, want := range []string{"BACKOFF_BASE", "BACKOFF_FACTOR", "BACKOFF_MAX"} { + if !strings.Contains(src, want) { + t.Errorf("quota.lua no longer declares %s — the backoff was weakened or removed", want) + } + } +} + +// TestQuotaBackoffWindows walks the whole policy through the plugin's own +// decision function: for each consecutive failure, how long the host must be left +// alone, that it is still skipped halfway through that window, that it is retried +// once the window passes, and that an absurdly old host is always due. +// +// Why the function rather than the running plugin: HOST_MEMO is a process-local +// cache, restored on a process's first collect and written back afterwards, so +// ageing its memo from Go changes nothing the plugin can see. Two earlier versions +// of this file spent a long time on exactly that dead end, and a test that keeps +// breaking for reasons unrelated to the code under test is worse than no test. +// +// The function is the plugin's own — reached through a shim that appends one +// export — so changing quota.lua's arithmetic moves this test. It is not a +// reimplementation that would agree with a broken plugin. +func TestQuotaBackoffWindows(t *testing.T) { + ps := quotaWithDecision(t) + + // streak, wait seconds. The last rows are at and past the ceiling. + for _, c := range []struct { + streak int + wait int + }{ + {1, 1800}, + {2, 3600}, + {3, 7200}, + {4, 14400}, + {5, 21600}, + {9, 21600}, + {20, 21600}, + } { + if got := quotaSkip(t, ps, c.streak, c.wait/2); !got { + t.Logf(" streak=%d age=%d → %v", c.streak, c.wait/2, got) + t.Errorf("streak=%d: not skipping %ds into a %ds window — the wait is too short", + c.streak, c.wait/2, c.wait) + } + if quotaSkip(t, ps, c.streak, c.wait+60) { + t.Errorf("streak=%d: still skipping %ds after its %ds window expired — "+ + "this host would never be retried again", c.streak, c.wait+60, c.wait) + } + if quotaSkip(t, ps, c.streak, 1<<30) { + t.Errorf("streak=%d: a host silent for decades is still being skipped", c.streak) + } + } +} + +// TestQuotaBackoffReturnsOnlyBooleans pins a type rule that produced two silent +// bugs. +// +// In Lua only nil and false are falsy, so every path of a predicate must return +// one of those. This function had two paths returning numbers: `return 0` for +// "no streak", which is truthy and made every caller skip a host that had never +// been probed; and `return BACKOFF_MAX` on the cap path, also truthy, which made +// the ceiling mean "never retried" — the exact failure the ceiling exists to +// prevent. +// +// Both shipped far enough to be found by this assertion rather than by a user +// staring at a page that had quietly stopped probing anything. +func TestQuotaBackoffReturnsOnlyBooleans(t *testing.T) { + ps := quotaWithDecision(t) + for _, c := range []struct { + name string + age int + strk interface{} + }{ + {"no streak field (every memo written before the backoff existed)", 0, nil}, + {"zero streak", 0, 0}, + {"negative streak", 0, -1}, + {"inside the window", 10, 1}, + {"outside the window", 999999, 1}, + {"at the ceiling", 10, 20}, + } { + out, err := ps.Call("quota", "__decision", map[string]interface{}{ + "at": float64(time.Now().Unix() - int64(c.age)), + "streak": c.strk, + }) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + // A bare boolean is a valid answer: when backoffSeconds returns false the + // shim's table round-trips as just `false`, because a Lua table holding a + // single falsy field collapses on encode. + switch v := out.(type) { + case bool: + _ = v // a boolean is exactly what this asserts + case map[string]interface{}: + if _, ok := v["r"].(bool); !ok { + t.Errorf("%s: backoffSeconds returned %T (%v) — in Lua a number is "+ + "TRUTHY, so this caller would skip the host", c.name, v["r"], v["r"]) + } + default: + t.Errorf("%s: decision returned %T (%v); want a boolean", c.name, out, out) + } + } +} + +// TestQuotaBackoffRecordsConsecutiveFailures covers the recording guard through +// the running plugin, because that part IS observable from outside. +// +// The guard used to be `not HOST_MEMO[host]`, true only on a host's first +// failure. After that the memo held a table, the guard was false, nothing was +// written, and the streak stayed pinned at 1 forever — measured by reading back +// {at:... streak:1} after four consecutive failures. +// +// Only two things are asserted, because only two are reachable in one process: +// a first failure records streak 1, and a SKIP does not grow it. A skipped probe +// is not a failure, and a skip that counted would let a never-probed host grow +// without end and never be retried. +func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) { + srv := newCountingServer(t) + ps := newQuotaPlugins(t, srv.URL) + + args := map[string]interface{}{ + "sources": []interface{}{map[string]interface{}{ + "name": "dead", "base_url": srv.URL + "/v1", "api_key": "k", + }}, + "name": "dead", + } + streak := func() float64 { + st, _ := ps.State("quota").(map[string]interface{}) + memo, _ := st["probe_memo"].(map[string]interface{}) + for _, v := range memo { + if e, ok := v.(map[string]interface{}); ok { + if f, ok := e["streak"].(float64); ok { + return f + } + } + } + t.Fatalf("no negative memo recorded: %#v", memo) + return 0 + } + + if _, err := ps.Call("quota", "collect_source", args); err != nil { + t.Fatal(err) + } + if got := streak(); got != 1 { + t.Fatalf("streak after the first failure = %.0f, want 1", got) + } + + before := srv.Hits() + if _, err := ps.Call("quota", "collect_source", args); err != nil { + t.Fatal(err) + } + if served := srv.Hits() - before; served != 0 { + t.Errorf("a host inside its backoff window still made %d requests — the "+ + "whole feature is to not make any", served) + } + if got := streak(); got != 1 { + t.Errorf("a SKIP changed the streak to %.0f — a skipped probe is not a failure", got) + } +} + +// --- helpers --- + +// readShippedPlugin returns the bundled source, so the tests exercise the file +// that actually ships rather than a copy that can drift from it. +func readShippedPlugin(t *testing.T, name string) string { + t.Helper() + b, err := bundledAdapters.ReadFile("plugins/" + name) + if err != nil { + t.Fatalf("read embedded %s: %v", name, err) + } + return string(b) +} + +// quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an +// export. The plugin's own exports list is appended to, so the export allowlist is +// satisfied exactly as it is in production. +func quotaWithDecision(t *testing.T) *Plugins { + t.Helper() + src := readShippedPlugin(t, "quota.lua") + shim := strings.Replace(src, "return PLUGIN", + "PLUGIN.exports[#PLUGIN.exports+1] = '__decision'\n"+ + // Wrapped, not exported raw: backoffSeconds takes (at, streak) + // positionally, and the test calls it with a table because that is the + // shape a Go caller can pass. Exporting the raw function made every + // assertion read `at = , streak = nil`, so the + // function took its "no streak" path and returned false for every input + // — which looked exactly like a backoff that never fires. + "PLUGIN.__decision = function(q) return { r = backoffSeconds(q.at, q.streak) } end\n"+ + "return PLUGIN", 1) + if shim == src { + t.Fatal("could not inject the decision shim — quota.lua no longer ends with 'return PLUGIN'") + } + return newQuotaPluginsWith(t, shim, "127.0.0.1") +} + +// quotaSkip asks the plugin whether a host that failed `streak` times and was +// last heard from `age` seconds ago should be skipped. +func quotaSkip(t *testing.T, ps *Plugins, streak, age int) bool { + out, err := ps.Call("quota", "__decision", map[string]interface{}{ + "at": float64(time.Now().Unix() - int64(age)), + "streak": float64(streak), + }) + if err != nil { + t.Fatalf("decision: %v", err) + } + // A single-field table collapses to the bare value on encode, so both shapes + // mean the same thing here. + switch v := out.(type) { + case bool: + return v + case map[string]interface{}: + b, ok := v["r"].(bool) + if !ok { + t.Fatalf("decision returned %T, not a boolean: %#v", v["r"], v) + } + return b + default: + t.Fatalf("decision returned %T (%v), not a boolean", out, out) + return false + } +} + +// newQuotaPlugins boots the shipped quota.lua against a temp plugin dir. +func newQuotaPlugins(t *testing.T, baseURL string) *Plugins { + t.Helper() + return newQuotaPluginsWith(t, readShippedPlugin(t, "quota.lua"), baseURL) +} + +func newQuotaPluginsWith(t *testing.T, src, baseURL string) *Plugins { + t.Helper() + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, pluginDir) + t.Cleanup(ps.Close) + ps.SetOutboundHosts([]string{"127.0.0.1"}) + if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if err := ps.LoadDir(); err != nil { + t.Fatal(err) + } + p := ps.find("quota") + if p == nil { + t.Fatal("quota plugin did not load") + } + if p.LoadError != "" { + t.Fatalf("quota plugin failed to compile: %s", p.LoadError) + } + _ = baseURL + return ps +} + +// countingServer answers 404 on every path and counts what it is asked, so a test +// can tell "the plugin skipped" (no requests) from "the plugin probed and found +// nothing" (requests, no data). +// +// It used to answer 200 with a non-balance body, and the commandcode adapter +// accepted that — so the host was memoised as a SUCCESS and the negative path was +// never reached. The test then failed for the right reason, and would have +// passed for the wrong one had the assertion been looser. +type countingServer struct { + *httptest.Server + hits int +} + +func (c *countingServer) Hits() int { return c.hits } + +func newCountingServer(t *testing.T) *countingServer { + t.Helper() + cs := &countingServer{} + cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cs.hits++ + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + })) + t.Cleanup(cs.Close) + return cs +} From ac9746ec8310f1954e9237180c225a0102c196e2 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 07:22:09 +0800 Subject: [PATCH 17/19] =?UTF-8?q?chore(plugins):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=88=86=E7=89=87=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHARD_DEBUG 只在排查「18 个分片为什么还是 12 秒」时用过一次,结论是 17 个 VM 真的在并行(17 VMs live),慢的是其中一个源本身。留着它就是 给热路径留一个 os.Getenv。 --- internal/lua/plugin_shard.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/lua/plugin_shard.go b/internal/lua/plugin_shard.go index aa5fa4e..d72ad90 100644 --- a/internal/lua/plugin_shard.go +++ b/internal/lua/plugin_shard.go @@ -2,7 +2,6 @@ package lua import ( "fmt" - "os" "sync" ) @@ -125,12 +124,6 @@ func (sp *shardPool) ShardCall(name, fn string, argsList []map[string]interface{ }(i) } wg.Wait() - if os.Getenv("SHARD_DEBUG") != "" { - sp.mu.Lock() - idle, live := len(sp.idle), sp.live - sp.mu.Unlock() - fmt.Fprintf(os.Stderr, "[shard] %s.%s: %d args, %d VMs live, %d idle\n", name, fn, len(argsList), live, idle) - } var failed int for _, err := range errs { From 4a0a1f96e46f2a8c6c3e8de8115b500900ec2cbd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 07:27:13 +0800 Subject: [PATCH 18/19] =?UTF-8?q?fix(quota):=20hostOf=20=E7=94=A8=E4=BA=86?= =?UTF-8?q?=20gmatch=20=E8=80=8C=E4=B8=8D=E6=98=AF=20match=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20memo=20=E8=A1=A8=E4=BB=8E=E6=9C=AA=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=E8=BF=87=E4=BB=BB=E4=BD=95=E4=B8=9C=E8=A5=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 这是退避失效的真正原因,而且比 TTL 严重 查生产 state 时发现的:19 条 memo,`hostOf(key)` 命中 **0** 条。 hostOf = function(u) return (string.gmatch(u, "^https?://([^/]+)")()) or u end `string.gmatch` 返回**整个匹配**,不是捕获组;而带 `^` 锚定时迭代器不会前进, 所以第一次 `()` 调用返回 **nil**,函数走 `or u` 分支返回完整 URL。 后果是彻底的、而且完全静默: - 每次查表都是 HOST_MEMO[nil],每次写入也是 HOST_MEMO[nil] - memo 表**对任何 host 从未命中过** - 负 memo、PROBE_MEMO_TTL、以及刚加的指数退避,**全是看起来活着的死代码** - 生产那 19 条历史 memo(key 是完整 URL,来自 ccquota 时代)从来没有被读过 所以「两个死上游每 30 分钟重付一次探测」的真实原因不是 TTL 到期, 而是压根没命中过 memo。 ## 为什么这么久没人发现 失败模式与「memo 里没有可记的东西」完全不可区分:页面照常显示,卡片照常 skipped,没有任何报错。唯一线索是 state 里那 19 条条目 —— 它们看起来像是 在正常工作留下的痕迹。 ## 修法 string.match(u, "^https?://([^/]+)") 判据 `TestQuotaMemoKeyIsTheHost` 钉住它:读**实际发布的** quota.lua,检查 用 string.match,并在运行时验四个 URL 的 host 抽取,以及同一 host 的两个不同 路径必须产出同一个 key(这正是按 host 做 memo 的意义)。 变异把 match 改回 gmatch 立刻变红,并打印出实际返回的完整 URL。 --- internal/lua/plugins/quota.lua | 17 +++++++- internal/lua/quota_backoff_test.go | 65 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 0b5ca6d..3a22d09 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -63,8 +63,23 @@ end -- hostOf extracts the host from a base_url, for showing WHICH upstream a row is -- about. Two sources on the same vendor (two Command Code keys) are -- indistinguishable without it. +-- hostOf returns the host of a URL — the part memoisation is keyed on, so two +-- sources pointing at the same host with different paths share one answer. +-- +-- It used to be written with string.gmatch and a capture group. gmatch yields +-- WHOLE matches, not captures, and with `^` it never advances, so the iterator +-- returned nil on the first step and hostOf returned its `or u` fallback — +-- the FULL URL. +-- +-- The consequence was invisible and total. Every lookup was HOST_MEMO[nil] and +-- every write was HOST_MEMO[nil], so: +-- - the memo table never returned anything, for any host, ever +-- - the negative memo, the TTL and (later) the backoff were all dead code +-- - the 19 entries in this gateway's production state are unreachable: their +-- keys are full URLs, while a lookup asks for nil +-- Measured before the fix: hostOf(key) matched 0 of 19 production entries. local function hostOf(u) - return (string.gmatch(u, "^https?://([^/]+)")()) or u + return (string.match(u, "^https?://([^/]+)")) or u end -- get walks a dotted path through nested tables, returning nil if any hop is diff --git a/internal/lua/quota_backoff_test.go b/internal/lua/quota_backoff_test.go index 447f0fe..3a95ab6 100644 --- a/internal/lua/quota_backoff_test.go +++ b/internal/lua/quota_backoff_test.go @@ -210,6 +210,69 @@ func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) { } } +// TestQuotaMemoKeyIsTheHost pins the key the memo is written and read under. +// +// This is the foundation the whole memoisation rests on, and it was broken in the +// most total way possible: hostOf was written with string.gmatch and a capture +// group, gmatch yields whole matches rather than captures, and with `^` the +// iterator never advanced — so it returned nil and every lookup and every write +// went to HOST_MEMO[nil]. The memo therefore never returned anything for any +// host, and the negative memo, the TTL and the backoff on top of them were all +// dead code that looked alive. +// +// Measured on this gateway before the fix: hostOf(key) matched 0 of the 19 +// entries in production state. +// +// The check is on the shipped source plus its runtime behaviour, because the +// failure mode is invisible from outside — a memo that never matches anything +// behaves exactly like a memo that has nothing to remember. +func TestQuotaMemoKeyIsTheHost(t *testing.T) { + src := readShippedPlugin(t, "quota.lua") + if !strings.Contains(src, "string.match(u,") { + t.Error("hostOf does not use string.match — string.gmatch yields whole " + + "matches, so the capture group is ignored and the function returns nil") + } + + ps := quotaWithDecision(t) + for _, c := range []struct{ in, want string }{ + {"https://qijiar.top/v1", "qijiar.top"}, + {"https://api.deepseek.com", "api.deepseek.com"}, + {"http://127.0.0.1:19900", "127.0.0.1:19900"}, + {"https://s2a.frankhan.top:14514/v1", "s2a.frankhan.top:14514"}, + } { + out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": c.in}) + if err != nil { + t.Fatalf("hostOf(%s): %v", c.in, err) + } + mm, _ := out.(map[string]interface{}) + got, _ := mm["h"].(string) + if got != c.want { + t.Errorf("hostOf(%q) = %q, want %q", c.in, got, c.want) + } + if got == "" { + t.Errorf("hostOf(%q) returned nothing — every lookup would be "+ + "HOST_MEMO[nil]", c.in) + } + } + + // The same host under two paths must produce the SAME key, which is the whole + // reason the memo is keyed this way. + out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/a/v1"}) + if err != nil { + t.Fatal(err) + } + out2, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/b/v1"}) + if err != nil { + t.Fatal(err) + } + m1, _ := out.(map[string]interface{}) + m2, _ := out2.(map[string]interface{}) + if m1["h"] != m2["h"] { + t.Errorf("two paths on one host produced different keys: %v and %v — "+ + "they would never share a memo", m1["h"], m2["h"]) + } +} + // --- helpers --- // readShippedPlugin returns the bundled source, so the tests exercise the file @@ -238,6 +301,8 @@ func quotaWithDecision(t *testing.T) *Plugins { // function took its "no streak" path and returned false for every input // — which looked exactly like a backoff that never fires. "PLUGIN.__decision = function(q) return { r = backoffSeconds(q.at, q.streak) } end\n"+ + "PLUGIN.exports[#PLUGIN.exports+1] = '__hostof'\n"+ + "PLUGIN.__hostof = function(q) return { h = hostOf(q.u) } end\n"+ "return PLUGIN", 1) if shim == src { t.Fatal("could not inject the decision shim — quota.lua no longer ends with 'return PLUGIN'") From 06f8c5dba436a152a418b1e0f2fcb58ca6707723 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 07:36:40 +0800 Subject: [PATCH 19/19] =?UTF-8?q?feat(quota):=20=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E4=B8=8E=E8=AE=B0=E5=BD=95=E5=9B=9E=E5=88=B0=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=20VM=EF=BC=8C=E5=8F=AA=E6=9C=89=E7=BD=91=E7=BB=9C=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E6=89=8D=E5=88=86=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 我引入的自相矛盾 分片是为解决这个量出来的: 单源探测(无竞争) 1.4 – 3.3 s 20 源 Promise.all 29 s 20 源串行理论值 ≈ 29 s ← 吻合 根因是 Plugins.Call 持 p.mu 到 Lua 函数返回,**含全部网络等待**;一个 Lua VM 只有一个栈,锁不能去掉。于是每个并发调用用自己的 VM 跑同一份源码(实测 8 分片 2.407s → 0.304s)。 但分片 VM 有自己的 PLUGIN.state —— 而**退避状态就存在 state 里**: fanout(分片) → memo 0 条 → 退避永不生效,每轮都付满 12s call(共享 VM) → memo 17 条 → 13.1s → 4.8s 我设计分片时写了「分片不碰 state」这条硬约束(防 billing 的累加在各分片分叉、 saver 写出随机一份),却没意识到探测的退避状态恰好住在那里。约束和功能打架。 ## 解法:按职责切开,不搬家 把退避表移到网关(B)能解决,但 quota 的 adapter memo 也在 state 里,而且 「插件自包含」是这个插件机制的前提。搬一半比不搬更糟。 所以判定与记录留在插件里,共享 VM 负责;只有网络等待分片: due_sources 共享 VM,纯判定,不发请求(微秒级) fanout 只对 due 里的源分片探测 memo_record 共享 VM,把这轮结果(成功和失败都记)写回 一个分片也不碰 state —— 约束保住了。 ## 实测(隔离实例,18 源,与生产同配置) 第1轮 10163 ms 退避跳过 0 分片 18 答了 4 第2轮 1299 ms 退避跳过 14 分片 4 答了 4 第3轮 1288 ms 退避跳过 14 分片 4 答了 4 第4轮 1316 ms 退避跳过 14 分片 4 答了 4 **29s → 1.3s,且 4 个有余额的源每次都探到** —— 不是靠少显示换来的快。 ## 几个细节 - due_sources 的 body 里不能出现 collectSource:一旦它自己探测,过滤就没有意义 了(成本在分片之前就付掉了)。有判据守着。 - memo_record 忽略 reason 以 "backing off" 开头的行 —— 那是被跳过的探���, 把它记成失败会让 streak 在一次没发生的探测上增长。 - 成功写成 vendor 字符串,streak 自然重置,不需要显式清零。 ## 判据(新增 4 条,共 11 条读实际发布的 quota.lua) - 两个导出存在且在 exports 里 - due_sources 不调 collectSource - memo_record 让 streak 从分片失败里累加,成功后重置 - due_sources 把退避中的 host 挡在分片之外,且零网络请求 写判据时踩了一次:两个源用了同一台 httptest server,于是被判为同一个 host, 两个都退避 —— 这是按 host 记忆的**正确**行为,是我的判据写错了。 Co-Authored-By: ModelRouter --- internal/lua/plugins/quota.lua | 119 +++++++++++++++++++++- internal/lua/quota_backoff_test.go | 153 +++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 4 deletions(-) diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 3a22d09..7148279 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -43,7 +43,7 @@ PLUGIN.name = "quota" PLUGIN.version = "1.0.0" PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)" -PLUGIN.exports = { "collect_source", "collect_all" } +PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record" } -- plugin.state must exist before anything indexes it. The kernel restores -- persisted state OVER this table, so declaring it here is safe: a restart with @@ -501,6 +501,92 @@ end -- and doing 21 sources in sequence overran the HTTP call's own budget — the -- whole page returned 502 while individual probes were still in flight. The -- sequential entry point stays for tests and for anyone scripting the plugin. +-- due_sources answers, on the SHARED VM, which sources are worth probing right +-- now — and it makes NO network requests. +-- +-- This exists because of a conflict between two facts: +-- +-- - Probing is network-bound, and Plugins.Call holds the plugin mutex for the +-- whole Lua function, so twenty probes through /call/ serialise. Measured: +-- 29s on a 20-source gateway. The fix is ShardCall, which runs each call on +-- its own VM — and a shard VM has its own PLUGIN.state. +-- - The backoff lives in PLUGIN.state (HOST_MEMO). A shard therefore cannot +-- see it, cannot record it, and a host that fails keeps being probed on every +-- page open. Measured: fanout left the memo at 0 entries, collect_all reached +-- 17. +-- +-- So the split is: the DECISION runs where the state is (one shared-VM call, +-- microseconds), and only the hosts that pass it get sharded. +-- +-- Cheap on purpose. It reads HOST_MEMO and nothing else; if the state has not +-- been restored yet, every host is due, which is the correct answer for a first +-- call. +-- memo_record writes the outcome of a sharded pass into the SHARED VM's memo. +-- +-- A shard runs on its own VM and therefore cannot record anything: measured, a +-- page of fanouts left the memo at 0 entries, so the backoff never accumulated +-- and every refresh re-probed the dead hosts. This is the other half of +-- due_sources — the decision is taken here, the outcome is written back here, and +-- only the network wait happens on the shards. +-- +-- It records FAILURES as well as successes. Recording only successes is what +-- left every source with no balance endpoint re-probing three adapters forever. +PLUGIN.memo_record = function(payload) + if type(payload) ~= "table" then return nil, "expected table" end + if not next(HOST_MEMO) then + local st = PLUGIN.state + if type(st) == "table" and type(st.probe_memo) == "table" then + for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end + end + end + local rows = payload.results + if type(rows) ~= "table" then return nil, "missing results" end + local now = os.time() + for _, r in ipairs(rows) do + if type(r) == "table" and type(r.host) == "string" and r.host ~= "" then + local host = hostOf(r.host) + if r.vendor then + -- A source that answered resets its streak: the memo becomes a vendor + -- string, which every reader already treats as a positive memo. + HOST_MEMO[host] = r.vendor + else + local prev = HOST_MEMO[host] + local streak = 1 + if type(prev) == "table" then streak = (prev.streak or 1) + 1 end + HOST_MEMO[host] = { at = now, streak = streak } + end + end + end + PLUGIN.state.probe_memo = HOST_MEMO + return true +end + +PLUGIN.due_sources = function(payload) + if type(payload) ~= "table" then return nil, "expected table" end + if not next(HOST_MEMO) then + local st = PLUGIN.state + if type(st) == "table" and type(st.probe_memo) == "table" then + for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end + end + end + local srcs = payload.sources + if type(srcs) ~= "table" then return nil, "missing sources" end + local due, backing = {}, {} + for _, s in ipairs(srcs) do + if type(s) == "table" and type(s.base_url) == "string" and s.base_url ~= "" then + local base = (s.base_url:gsub("/+$", "")) + local known = HOST_MEMO[hostOf(base)] + if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then + backing[#backing + 1] = { name = s.name, host = hostOf(base), + reason = "backing off after " .. tostring(known.streak) .. " failed probes" } + else + due[#due + 1] = s + end + end + end + return { due = due, backing_off = backing } +end + PLUGIN.collect_all = function(payload) if type(payload) ~= "table" then return nil, "expected table" end if not next(HOST_MEMO) then @@ -830,9 +916,22 @@ PLUGIN.ui = { // overrun. It could not have: the requests were serialised, so the budget they // overran was the very same one. It is a single call now because a // request-per-item fan-out cannot be parallel from the client at all. - out = await pluginAPI.fanout("quota","collect_source",srcs.map(function(s){ - return {sources:[s],name:s.name}; - })); + // Which sources are worth probing is decided on the SHARED VM, where the + // backoff state lives; only those get sharded. + // + // Asking each shard to decide for itself does not work: a shard has its own + // PLUGIN.state, so it cannot see the memo and a host that keeps failing is + // re-probed on every refresh forever. Measured before this split: fanout left + // the memo at 0 entries and every refresh cost the full 12s. + var plan = await pluginAPI.call("quota","due_sources",{sources:srcs}); + var due = (plan && plan.due) || []; + var out = (plan && plan.backing_off) || []; + + if (due.length) { + out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){ + return {sources:[s],name:s.name}; + }))); + } // Mirror the host→vendor map the plugin keeps: a source that answered is // worth probing first next time, and the page has everything it needs to // record that without another round trip. @@ -841,6 +940,18 @@ PLUGIN.ui = { if(r && !r.skipped && !r.error && r.host && r.vendor) memo[r.host] = r.vendor; }); window.__qaMemo = memo; + // Hand the whole pass back to the SHARED VM so the backoff has something to + // work from next time. Without this the shards' results die with their VMs: + // measured, the memo stayed at 0 entries and every refresh paid full price. + await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){ + if(!r) return null; + // A host that is merely BACKING OFF has not just failed; recording it as a + // failure would grow the streak on a probe that never happened. + if(r.reason && String(r.reason).indexOf("backing off")===0) return null; + if(r.error) return {host:r.host}; + if(r.skipped) return {host:r.host}; + return {host:r.host, vendor:r.vendor}; + }).filter(Boolean)}); }catch(e){ if(btn) btn.disabled=false; if(sub) sub.textContent="探测失败: "+e.message; diff --git a/internal/lua/quota_backoff_test.go b/internal/lua/quota_backoff_test.go index 3a95ab6..c26d34c 100644 --- a/internal/lua/quota_backoff_test.go +++ b/internal/lua/quota_backoff_test.go @@ -401,3 +401,156 @@ func newCountingServer(t *testing.T) *countingServer { t.Cleanup(cs.Close) return cs } + +// TestQuotaDueSourcesAndMemoRecordCoverTheShardGap pins the split that makes the +// backoff work at all under sharding. +// +// The conflict, measured on a live 20-source gateway: +// - probing is network-bound and Plugins.Call holds the plugin mutex for the +// whole Lua call, so /call/ serialises (29s) +// - so the page uses ShardCall — and a shard has its own PLUGIN.state +// - and the backoff lives in PLUGIN.state: fanout left the memo at 0 entries, +// so dead hosts were re-probed forever +// +// The resolution is to put the DECISION and the RECORDING back on the shared VM +// (microseconds, no network) and shard only the network wait. These two exports +// are that seam, and without them the fanout path silently defeats the backoff. +func TestQuotaDueSourcesAndMemoRecordCoverTheShardGap(t *testing.T) { + src := readShippedPlugin(t, "quota.lua") + for _, want := range []string{"due_sources", "memo_record"} { + if !strings.Contains(src, want) { + t.Errorf("quota.lua does not export %s — a sharded pass cannot see the "+ + "backoff state, nor leave any behind", want) + } + if !strings.Contains(src, `"`+want+`"`) { + t.Errorf("%s is not listed in PLUGIN.exports, so the page cannot call it", want) + } + } + // due_sources must not probe. If it did, the whole point of asking it first + // would be lost: the cost would be paid before any shard runs. + if strings.Contains(src, "due_sources = function(payload)") && + strings.Contains(src, "due_sources") { + // The body is small; check it does not call collectSource. + i := strings.Index(src, "due_sources = function(payload)") + j := strings.Index(src[i:], "\nend\n") + if j > 0 { + body := src[i : i+j] + if strings.Contains(body, "collectSource(") { + t.Error("due_sources calls collectSource — the filter must make no " + + "network request, or it costs the same as probing everything") + } + } + } +} + +// TestQuotaMemoRecordGrowsTheStreakFromShards covers the recording half: a host +// that failed inside a shard must end up in the SHARED memo with a streak, or the +// next refresh has nothing to back off from. +func TestQuotaMemoRecordGrowsTheStreakFromShards(t *testing.T) { + srv := newCountingServer(t) + ps := newQuotaPlugins(t, srv.URL) + host := hostOnly(srv.URL) + + read := func() map[string]interface{} { + st, _ := ps.State("quota").(map[string]interface{}) + memo, _ := st["probe_memo"].(map[string]interface{}) + return memo + } + + // A shard reports a host that answered with nothing. + for i := 1; i <= 3; i++ { + out, err := ps.Call("quota", "memo_record", map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"host": srv.URL + "/v1"}, + }, + }) + if err != nil { + t.Fatalf("memo_record %d: %v", i, err) + } + if out != true { + t.Fatalf("memo_record returned %#v", out) + } + } + memo := read() + e, ok := memo[host].(map[string]interface{}) + if !ok { + t.Fatalf("memo has no entry for %s: %#v", host, memo) + } + if got, _ := e["streak"].(float64); got != 3 { + t.Errorf("streak after three shard failures = %.0f, want 3", got) + } + + // A host that answers is recorded as a positive memo, which resets the streak. + if _, err := ps.Call("quota", "memo_record", map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"host": srv.URL + "/v1", "vendor": "deepseek"}, + }, + }); err != nil { + t.Fatal(err) + } + if got := read()[host]; got != "deepseek" { + t.Errorf("a host that answered is memoised as %#v, want the vendor name — "+ + "the streak must not survive a success", got) + } +} + +// TestQuotaDueSourcesSkipsBackedOffHosts is the behaviour the page depends on: +// the filter must keep a backed-off host out of the sharded set. +func TestQuotaDueSourcesSkipsBackedOffHosts(t *testing.T) { + srv := newCountingServer(t) + ps := newQuotaPlugins(t, srv.URL) + host := hostOnly(srv.URL) + + // One failure, which is enough to put the host inside its window. + if _, err := ps.Call("quota", "memo_record", map[string]interface{}{ + "results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}}, + }); err != nil { + t.Fatal(err) + } + + // A SECOND host, so the two cases are genuinely independent. The memo is keyed + // by host, so two paths on one host are one entry — using the same server here + // would have asserted that a per-host memo behaves per-path. + other := newCountingServer(t) + srcs := []interface{}{ + map[string]interface{}{"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k"}, + map[string]interface{}{"name": "fresh", "base_url": other.URL + "/v1", "api_key": "k"}, + } + out, err := ps.Call("quota", "due_sources", map[string]interface{}{"sources": srcs}) + if err != nil { + t.Fatal(err) + } + m, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("due_sources returned %#v", out) + } + due, _ := m["due"].([]interface{}) + backing, _ := m["backing_off"].([]interface{}) + if len(backing) != 1 { + t.Errorf("backing_off = %#v, want exactly the host that failed", backing) + } else if b, _ := backing[0].(map[string]interface{}); b["host"] != host { + t.Errorf("backing off %v, want %s — the key must be the host, or it will "+ + "never match on the next refresh", b["host"], host) + } + if len(due) != 1 { + t.Errorf("due = %#v, want only the unprobed host", due) + } else if d, _ := due[0].(map[string]interface{}); d["name"] != "fresh" { + t.Errorf("due contains %v, want fresh", d["name"]) + } + + // And it must make no network request — the whole saving depends on it. + if n := srv.Hits(); n != 0 { + t.Errorf("due_sources made %d requests; it must decide without probing", n) + } +} + +// hostOnly mirrors the plugin's own hostOf. Used only to CHECK what the plugin +// wrote — seeding a memo under a different key produces a memo the plugin never +// looks up, which cost this file a couple of rounds before it was noticed. +func hostOnly(baseURL string) string { + u := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://") + if i := strings.Index(u, "/"); i >= 0 { + u = u[:i] + } + return u +}