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:
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user