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>
122 lines
3.2 KiB
Go
122 lines
3.2 KiB
Go
package lua
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
golua "github.com/aarzilli/golua/lua"
|
|
)
|
|
|
|
// httpArgIdx is the stack slot holding the caller's table. Verified against
|
|
// golua: a PushGoClosure function is invoked with its arguments already on top
|
|
// and nothing of itself below them.
|
|
const httpArgIdx = 1
|
|
|
|
// readHTTPRequestSpec decodes the Lua argument table:
|
|
//
|
|
// __llmsproxy_http({
|
|
// url = "https://host/path",
|
|
// method = "GET", -- optional, default GET
|
|
// headers = { ["X-A"] = "1" }, -- optional
|
|
// body = "...", -- optional
|
|
// timeout_ms = 8000, -- optional, clamped to 60s
|
|
// })
|
|
//
|
|
// The table is at index 1: measured, not assumed. A Go closure registered with
|
|
// PushGoClosure is NOT on the stack when it runs — GetTop() is 1 and slot 1 is
|
|
// the caller's table (type LUA_TTABLE). An earlier version assumed the closure
|
|
// occupied slot 1 and read from slot 2, which then reported "expected a table
|
|
// argument" for a perfectly good call.
|
|
//
|
|
// luaTableToGoValue already turns the table into a map/slice, so the whole
|
|
// argument is decoded in one pass instead of walking the table field by field.
|
|
func readHTTPRequestSpec(L *golua.State) (httpRequestSpec, error) {
|
|
var spec httpRequestSpec
|
|
if L.GetTop() < httpArgIdx {
|
|
return spec, fmt.Errorf("__llmsproxy_http: expected a table argument")
|
|
}
|
|
if L.Type(httpArgIdx) != golua.LUA_TTABLE {
|
|
return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s",
|
|
normalizeString(L, httpArgIdx))
|
|
}
|
|
arg, ok := luaTableToGoValue(L, httpArgIdx).(map[string]interface{})
|
|
if !ok {
|
|
return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table")
|
|
}
|
|
for k, v := range arg {
|
|
switch strings.ToLower(k) {
|
|
case "url", "uri", "endpoint":
|
|
spec.URL = asString(v)
|
|
case "method", "verb":
|
|
spec.Method = asString(v)
|
|
case "body", "data", "payload":
|
|
spec.Body = asString(v)
|
|
case "headers", "header":
|
|
if m, ok := v.(map[string]interface{}); ok {
|
|
spec.Headers = make(map[string]string, len(m))
|
|
for hk, hv := range m {
|
|
spec.Headers[hk] = asString(hv)
|
|
}
|
|
}
|
|
case "timeout_ms", "timeoutms":
|
|
if n, ok := asFloat(v); ok {
|
|
spec.Timeout = time.Duration(n) * time.Millisecond
|
|
}
|
|
}
|
|
}
|
|
if spec.URL == "" {
|
|
return spec, fmt.Errorf("__llmsproxy_http: missing url")
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
// pushHTTPResponse converts the result into two Lua values: a table
|
|
// {status, headers, body} and nil. The caller pushes the error slot itself.
|
|
func pushHTTPResponse(L *golua.State, r *httpResult) {
|
|
L.NewTable()
|
|
|
|
L.PushString("status")
|
|
L.PushNumber(float64(r.Status))
|
|
L.SetTable(-3)
|
|
|
|
L.PushString("headers")
|
|
pushStringMap(L, r.Headers)
|
|
L.SetTable(-3)
|
|
|
|
L.PushString("body")
|
|
L.PushString(r.Body)
|
|
L.SetTable(-3)
|
|
}
|
|
|
|
func pushStringMap(L *golua.State, m map[string]string) {
|
|
L.NewTable()
|
|
for k, v := range m {
|
|
L.PushString(v)
|
|
L.SetField(-2, k)
|
|
}
|
|
}
|
|
|
|
func asString(v interface{}) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case nil:
|
|
return ""
|
|
default:
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
}
|
|
|
|
func asFloat(v interface{}) (float64, bool) {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return t, true
|
|
case int:
|
|
return float64(t), true
|
|
case int64:
|
|
return float64(t), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
} |