mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17: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)
This commit is contained in:
150
internal/gateway/plugin_call_api_test.go
Normal file
150
internal/gateway/plugin_call_api_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user