mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user