feat(plugins): ccquota 多账户额度插件 + 页面→Lua 调用通道

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 <noreply@modelrouter.dev>
(cherry picked from commit 111dc6d53f)
(cherry picked from commit 22c9215b02)
This commit is contained in:
JianFeeeee
2026-10-04 08:10:11 +08:00
parent a2bb6de9fb
commit 85c81c63cc
8 changed files with 846 additions and 10 deletions

View File

@ -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