From 85c81c63cc12e4f2f9ffaf89f5391dcd8c88723d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 08:10:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20ccquota=20=E5=A4=9A=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E9=A2=9D=E5=BA=A6=E6=8F=92=E4=BB=B6=20+=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E2=86=92Lua=20=E8=B0=83=E7=94=A8=E9=80=9A=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