mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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>
150 lines
4.5 KiB
Go
150 lines
4.5 KiB
Go
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())
|
|
}
|
|
} |