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:
@ -2,12 +2,25 @@ package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pluginLoaded reports whether a plugin with this name is present, ignoring any
|
||||
// LoadError: a plugin that is present but broken still counts as "loaded here",
|
||||
// and the caller decides whether the error matters.
|
||||
func pluginLoaded(ps *Plugins, name string) bool {
|
||||
for _, p := range ps.List() {
|
||||
if fmt.Sprint(p["name"]) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The billing plugin ships with the gateway, so its arithmetic is a contract:
|
||||
// a wrong price silently produces wrong money. These tests drive it through the
|
||||
// real hook path and check the NUMBERS, not merely that it loads.
|
||||
@ -447,8 +460,16 @@ return plugin`)
|
||||
}
|
||||
|
||||
// Order must be honoured so the sidebar is predictable.
|
||||
if len(ui.Pages) != 4 {
|
||||
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
|
||||
//
|
||||
// The page COUNT is not asserted: it used to be 4, and it grew every time a
|
||||
// bundled plugin added a page. A hard count here reads as "the merge is
|
||||
// broken" whenever the product gains a feature, which trains the reader to
|
||||
// bump a number instead of reading the failure. What matters is that all
|
||||
// three pages this test contributed merged, and that they came out sorted.
|
||||
for _, want := range []string{"third-a", "third-b", "other-page"} {
|
||||
if !got[want] {
|
||||
t.Errorf("page %s did not merge: %v", want, got)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(ui.Pages); i++ {
|
||||
if ui.Pages[i].Order < ui.Pages[i-1].Order {
|
||||
@ -462,8 +483,15 @@ return plugin`)
|
||||
// configuration, since seeding only happens on a fresh plugin dir.
|
||||
func TestBillingPluginLoadedByDefault(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
if ps.Count() != 1 {
|
||||
t.Fatalf("expected 1 bundled plugin, got %d", ps.Count())
|
||||
// billing must be among the bundled plugins, not the only one. The count
|
||||
// grows with every shipped plugin and asserting it makes this test a
|
||||
// changelog detector.
|
||||
if !pluginLoaded(ps, "billing") {
|
||||
var names []string
|
||||
for _, p := range ps.List() {
|
||||
names = append(names, fmt.Sprint(p["name"]))
|
||||
}
|
||||
t.Fatalf("billing is not loaded; bundled plugins are %v", names)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil {
|
||||
t.Errorf("billing.lua was not written to the plugin dir: %v", err)
|
||||
|
||||
@ -8,6 +8,11 @@ import (
|
||||
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({
|
||||
@ -18,18 +23,24 @@ import (
|
||||
// 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() < 1 {
|
||||
if L.GetTop() < httpArgIdx {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: expected a table argument")
|
||||
}
|
||||
if L.Type(1) != golua.LUA_TTABLE {
|
||||
if L.Type(httpArgIdx) != golua.LUA_TTABLE {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s",
|
||||
normalizeString(L, 1))
|
||||
normalizeString(L, httpArgIdx))
|
||||
}
|
||||
arg, ok := luaTableToGoValue(L, 1).(map[string]interface{})
|
||||
arg, ok := luaTableToGoValue(L, httpArgIdx).(map[string]interface{})
|
||||
if !ok {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
368
internal/lua/plugins/ccquota.lua
Normal file
368
internal/lua/plugins/ccquota.lua
Normal file
@ -0,0 +1,368 @@
|
||||
-- Command Code quota — multi-account plan balance for the gateway.
|
||||
--
|
||||
-- The billing plugin only ever reports what the gateway already measured, so it
|
||||
-- never has to reach out. A plan balance is different: the number only exists
|
||||
-- on the vendor's side, and the only way to learn it is to ask. Command Code
|
||||
-- exposes /alpha/billing/credits (plus whoami / subscriptions / usage) on the
|
||||
-- provider host, authenticated with the same API key the source already uses.
|
||||
--
|
||||
-- Accounts: one per configured source whose base_url is the Command Code API
|
||||
-- host. Two keys on the same host are two separate accounts, and both are
|
||||
-- shown — an operator with a spare seat needs to see both draining.
|
||||
--
|
||||
-- Every endpoint degrades independently. A vendor that retires one route must
|
||||
-- not blank the others, and a key that has run out still returns HTTP 401 on
|
||||
-- the inference path while credits reads fine, so the report has to survive
|
||||
-- partial failure and say WHICH part failed rather than rendering a zero that
|
||||
-- looks like "no quota left".
|
||||
|
||||
local PLUGIN = {}
|
||||
|
||||
PLUGIN.name = "ccquota"
|
||||
PLUGIN.version = "1.0.0"
|
||||
PLUGIN.description = "Command Code 多账户额度余量(5 小时 / 周 / 月窗口 + 余额)"
|
||||
|
||||
-- Command Code's API host. __llmsproxy_http only permits hosts that a
|
||||
-- configured source already talks to, so this works exactly when such a source
|
||||
-- exists — no separate allowlist to keep in sync, and no way to widen reach.
|
||||
local API_HOST = "api.commandcode.ai"
|
||||
|
||||
-- These headers are required alongside the bearer token. Without them the
|
||||
-- endpoints answer 401 even with a perfectly valid key, which looks identical
|
||||
-- to a revoked key and costs a lot of time to tell apart.
|
||||
--
|
||||
-- The bearer token is NOT here on purpose: it is per-account, so fetchJSON
|
||||
-- copies this table and injects it per request. An earlier version passed this
|
||||
-- table straight through and every endpoint answered 401 — which reads exactly
|
||||
-- like a revoked key, and sent me looking at the account instead of the plugin.
|
||||
local CLI_VERSION = "1.54.2"
|
||||
local REQUIRED_HEADERS = {
|
||||
["x-command-code-version"] = CLI_VERSION,
|
||||
["x-cli-environment"] = "production",
|
||||
["User-Agent"] = "commandcode-quota/" .. CLI_VERSION,
|
||||
}
|
||||
|
||||
-- Accounts are handed in from the page (which reads /api/sources and its own
|
||||
-- key store). The kernel deliberately does NOT let a plugin read the config, so
|
||||
-- this is the only channel — which also means the key never sits in Lua state
|
||||
-- or in the persisted plugin file.
|
||||
local ACCOUNTS = {}
|
||||
|
||||
-- ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
local function tonum(v)
|
||||
return tonumber(v) or 0
|
||||
end
|
||||
|
||||
-- usd formats a dollar amount the way the vendor's panel does: two decimals,
|
||||
-- but never a bare "0" when there is a real fractional balance left. A $0.0811
|
||||
-- balance rounds to $0.08, and showing "$0" there would read as "nothing left".
|
||||
|
||||
|
||||
-- fetchJSON performs one request and returns (decodedTable, nil) or
|
||||
-- (nil, errorMessage). It NEVER raises: an escaping error inside a plugin hook
|
||||
-- can take the whole gateway down (see the hook-guard note in the kernel), so
|
||||
-- every failure has to arrive as an ordinary value.
|
||||
local function fetchJSON(path, key)
|
||||
-- Copy, then add the token: mutating the shared table would leak one
|
||||
-- account's key into every later request.
|
||||
local headers = {}
|
||||
for k, v in pairs(REQUIRED_HEADERS) do headers[k] = v end
|
||||
headers["Authorization"] = "Bearer " .. tostring(key or "")
|
||||
local res, err = __llmsproxy_http({
|
||||
url = "https://" .. API_HOST .. path,
|
||||
method = "GET",
|
||||
headers = headers,
|
||||
timeout_ms = 12000,
|
||||
})
|
||||
if res == nil then
|
||||
return nil, tostring(err or "request failed")
|
||||
end
|
||||
-- Body arrives as a JSON string; the kernel's json.decode is the only parser
|
||||
-- available and it raises on malformed input, so it is pcall'd here too.
|
||||
local ok, decoded = pcall(json.decode, res.body)
|
||||
if not ok then
|
||||
return nil, "bad JSON from " .. path
|
||||
end
|
||||
return decoded, nil
|
||||
end
|
||||
|
||||
-- authFailure recognises the one error that means "this key is not usable"
|
||||
-- rather than "this endpoint is unhappy". Quota exhaustion and a revoked key
|
||||
-- both surface as 401 here, and they need opposite operator responses.
|
||||
|
||||
-- ---------------------------------------------------------------- account report
|
||||
|
||||
-- collectAccount gathers one account's numbers. Each endpoint is independent:
|
||||
-- credits is the one that matters, and the rest enrich it. A failure is
|
||||
-- recorded per-endpoint instead of aborting, because "I could not read the
|
||||
-- subscription" and "the account has no quota" must not look the same.
|
||||
local function collectAccount(acct)
|
||||
local report = {
|
||||
name = acct.name,
|
||||
label = acct.label,
|
||||
failures = {},
|
||||
checked_at = os.time(),
|
||||
}
|
||||
|
||||
local who, werr = fetchJSON("/alpha/whoami", acct.key)
|
||||
if who == nil then
|
||||
report.failures[#report.failures + 1] = "whoami: " .. tostring(werr)
|
||||
elseif type(who) == "table" and type(who.user) == "table" then
|
||||
report.email = who.user.email
|
||||
report.user = who.user.name or who.user.userName
|
||||
end
|
||||
|
||||
local creds, cerr = fetchJSON("/alpha/billing/credits", acct.key)
|
||||
local rawStatus = 0
|
||||
if creds == nil then
|
||||
report.failures[#report.failures + 1] = "credits: " .. tostring(cerr)
|
||||
else
|
||||
local c = creds.credits or {}
|
||||
local wl = creds.windowLimits or {}
|
||||
report.monthly = tonum(c.monthlyCredits)
|
||||
report.purchased = tonum(c.purchasedCredits)
|
||||
report.free = tonum(c.freeCredits)
|
||||
report.total = report.monthly + report.purchased + report.free
|
||||
report.limited = wl.limited == true
|
||||
report.exceeded = wl.exceeded
|
||||
report.fiveHour = wl.fiveHour
|
||||
report.weekly = wl.weekly
|
||||
rawStatus = 200
|
||||
end
|
||||
|
||||
local subs, serr = fetchJSON("/alpha/billing/subscriptions", acct.key)
|
||||
if subs == nil then
|
||||
report.failures[#report.failures + 1] = "subscriptions: " .. tostring(serr)
|
||||
elseif type(subs) == "table" and type(subs.data) == "table" then
|
||||
report.plan_status = subs.data.status
|
||||
report.plan_price = subs.data.metadata and nil
|
||||
local md = subs.data.metadata
|
||||
if type(md) == "table" then
|
||||
report.plan_name = md.plan or md.name or md.tier
|
||||
end
|
||||
end
|
||||
|
||||
local usage, uerr = fetchJSON("/alpha/usage/summary", acct.key)
|
||||
if usage == nil then
|
||||
report.failures[#report.failures + 1] = "usage: " .. tostring(uerr)
|
||||
elseif type(usage) == "table" then
|
||||
report.requests = tonum(usage.totalCount)
|
||||
report.spent = tonum(usage.totalCost)
|
||||
report.tokens = tonum(usage.totalTokens)
|
||||
report.success_rate = tonum(usage.successRate)
|
||||
end
|
||||
|
||||
-- An account whose every endpoint failed is an auth problem, not a data
|
||||
-- problem: state it plainly so nobody reads "—" as "zero quota".
|
||||
if #report.failures >= 3 and report.total == nil then
|
||||
report.auth_failed = true
|
||||
end
|
||||
return report
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- page
|
||||
|
||||
PLUGIN.ui = {
|
||||
page = {
|
||||
page_id = "ccquota",
|
||||
title = "CC Quota",
|
||||
order = 45,
|
||||
mount = [==[
|
||||
<div id="ccq-root" class="ccq-root">
|
||||
<div class="ccq-head">
|
||||
<div>
|
||||
<div class="ccq-title">Command Code 额度</div>
|
||||
<div class="ccq-sub" id="ccq-sub">点击刷新以拉取各账户余量</div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<button class="small" id="ccq-refresh">刷新</button>
|
||||
<span class="ccq-auto"></span>
|
||||
</div>
|
||||
<div id="ccq-list"><div class="ccq-empty">尚未拉取</div></div>
|
||||
</div>
|
||||
<style>
|
||||
#ccq-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
|
||||
.ccq-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
|
||||
.ccq-title{font-size:15px;font-weight:600}
|
||||
.ccq-sub{font-size:12px;opacity:.66;margin-top:2px}
|
||||
.ccq-auto{font-size:12px;opacity:.6}
|
||||
.ccq-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 12px;min-width:0}
|
||||
.ccq-acc.err{border-color:rgba(220,80,80,.5)}
|
||||
.ccq-acc-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
|
||||
.ccq-acc-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}
|
||||
.ccq-mail{font-size:12px;opacity:.66;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ccq-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
|
||||
.ccq-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
|
||||
.ccq-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
|
||||
.ccq-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
|
||||
.ccq-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 10px;flex-wrap:wrap}
|
||||
.ccq-bal-n{font-size:20px;font-weight:600}
|
||||
.ccq-bal-l{font-size:12px;opacity:.66}
|
||||
.ccq-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 18px}
|
||||
.ccq-win{min-width:0}
|
||||
.ccq-win-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
|
||||
.ccq-win-n{font-variant-numeric:tabular-nums;font-weight:600}
|
||||
.ccq-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
|
||||
.ccq-bar-fill{height:100%;background:#3b82f6;border-radius:3px}
|
||||
.ccq-bar-fill.warn{background:#d97706}
|
||||
.ccq-bar-fill.over{background:#dc2626}
|
||||
.ccq-win-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
|
||||
.ccq-reset{opacity:.6;margin-left:4px}
|
||||
.ccq-meta{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:10px}
|
||||
.ccq-fail{font-size:11.5px;color:#b91c1c;margin-top:8px;word-break:break-word}
|
||||
.ccq-empty{opacity:.6;font-size:13px;padding:8px 0}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
var HOST = "api.commandcode.ai";
|
||||
var lastReports = [];
|
||||
|
||||
function escH(s){
|
||||
return String(s==null?"":s).replace(/[&<>"]/g,function(c){
|
||||
return c==="&"?"&":c==="<"?"<":c===">"?">":""";
|
||||
});
|
||||
}
|
||||
function usd(v){
|
||||
v = Number(v)||0;
|
||||
if (v<=0) return "$0.00";
|
||||
return "$"+(v<0.01?v.toFixed(4):v.toFixed(2));
|
||||
}
|
||||
function stateOf(r){
|
||||
if (r.auth_failed) return {cls:"err", txt:"认证失败"};
|
||||
if (r.exceeded) return {cls:"out", txt:"已超额"};
|
||||
var t = Number(r.total)||0;
|
||||
if (t<=0) return {cls:"out", txt:"无余额"};
|
||||
if (t<5) return {cls:"low", txt:"余额偏低"};
|
||||
return {cls:"ok", txt:r.plan_status||"正常"};
|
||||
}
|
||||
function win(label,w){
|
||||
if(!w) return "";
|
||||
var used=Number(w.used)||0, cap=Number(w.cap)||0;
|
||||
var p = cap>0 ? Math.floor(used/cap*100+0.5) : null;
|
||||
if(p===null) return "";
|
||||
var over = w.exceeded===true;
|
||||
if(over) p=100;
|
||||
var cls = "ccq-bar-fill"+(over||p>=100?" over":(p>=80?" warn":""));
|
||||
var reset="";
|
||||
if(Number(w.resetAt)>0){
|
||||
var s=Math.floor(w.resetAt/1000-Date.now()/1000);
|
||||
if(s>0){
|
||||
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
|
||||
reset=(d>0?d+"d "+h+"h":(h>0?h+"h "+m+"m":m+"m"));
|
||||
}
|
||||
}
|
||||
return '<div class="ccq-win"><div class="ccq-win-h"><span>'+escH(label)+
|
||||
(reset?' <span class="ccq-reset">↻'+escH(reset)+'</span>':'')+
|
||||
'</span><span class="ccq-win-n">'+p+'%</span></div>'+
|
||||
'<div class="ccq-bar"><div class="'+cls+'" style="width:'+p+'%"></div></div>'+
|
||||
'<div class="ccq-win-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
|
||||
}
|
||||
function card(r){
|
||||
var st = stateOf(r);
|
||||
var h = '<div class="ccq-acc'+((r.auth_failed||(r.failures&&r.failures.length===4))?" err":"")+'">'+
|
||||
'<div class="ccq-acc-h"><span class="ccq-acc-n">'+escH(r.name||"")+'</span>'+
|
||||
(r.email?'<span class="ccq-mail">'+escH(r.email)+'</span>':'')+
|
||||
'<span class="grow"></span>'+
|
||||
'<span class="ccq-pill '+st.cls+'">'+escH(st.txt)+'</span></div>'+
|
||||
'<div class="ccq-bal"><span class="ccq-bal-n">'+usd(r.total)+
|
||||
'</span><span class="ccq-bal-l">月度余额'+
|
||||
(Number(r.purchased)>0?'(含充值 $'+Number(r.purchased).toFixed(2)+')':'')+
|
||||
'</span></div>'+
|
||||
'<div class="ccq-grid">'+
|
||||
win("5 小时", r.fiveHour)+win("每周", r.weekly)+
|
||||
'</div>';
|
||||
var meta=[];
|
||||
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
|
||||
if(r.spent) meta.push("累计 "+usd(r.spent));
|
||||
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
|
||||
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
|
||||
if(meta.length) h+='<div class="ccq-meta">'+meta.map(escH).join(" · ")+'</div>';
|
||||
if(r.failures&&r.failures.length){
|
||||
h+='<div class="ccq-fail">部分接口失败:'+r.failures.map(escH).join(";")+'</div>';
|
||||
}
|
||||
return h+"</div>";
|
||||
}
|
||||
function render(reports){
|
||||
lastReports = reports||[];
|
||||
var host=document.getElementById("ccq-list");
|
||||
if(!host) return;
|
||||
if(!lastReports.length){host.innerHTML='<div class="ccq-empty">没有可用的 Command Code 源</div>';return;}
|
||||
host.innerHTML=lastReports.map(card).join("");
|
||||
var sub=document.getElementById("ccq-sub");
|
||||
if(sub) sub.textContent = lastReports.length+" 个账户 · "+
|
||||
lastReports.filter(function(r){return r.auth_failed;}).length+" 个认证失败";
|
||||
}
|
||||
async function refresh(){
|
||||
var srcs;
|
||||
try{
|
||||
var r=await fetch("/api/sources",{credentials:"same-origin"});
|
||||
srcs=(await r.json()).sources||[];
|
||||
}catch(e){ render([]); return; }
|
||||
// Every source on the Command Code host is one account, keyed by its own
|
||||
// API key — two keys on that host are two seats, and both must be listed.
|
||||
var accts=srcs.filter(function(s){
|
||||
try{ return new URL(s.base_url).hostname===HOST; }catch(e){ return false; }
|
||||
});
|
||||
if(!accts.length){ render([]); return; }
|
||||
var btn=document.getElementById("ccq-refresh");
|
||||
if(btn) btn.disabled=true;
|
||||
var auto=document.getElementById("ccq-auto")||document.querySelector(".ccq-auto");
|
||||
if(auto) auto.textContent="拉取中…";
|
||||
var out=[];
|
||||
var errs=0;
|
||||
// One account at a time: the endpoints are independent, and a sequential
|
||||
// loop means a hung vendor endpoint delays only its own row instead of
|
||||
// blocking the whole page behind one promise.
|
||||
for (var i=0;i<accts.length;i++){
|
||||
var a=accts[i];
|
||||
try{
|
||||
var rep = await pluginAPI.call("ccquota","collect_account",
|
||||
{name:a.name,label:a.name,key:a.api_key});
|
||||
out.push(rep||{name:a.name,failures:["no result"]});
|
||||
}catch(e){
|
||||
errs++;
|
||||
out.push({name:a.name,failures:[String(e.message||e)],auth_failed:true});
|
||||
}
|
||||
}
|
||||
render(out);
|
||||
if(btn) btn.disabled=false;
|
||||
if(auto) auto.textContent="更新于 "+new Date().toLocaleTimeString();
|
||||
try{ await pluginAPI.postState("ccquota",{reports:out,updated_at:Date.now()}); }catch(e){}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded",function(){
|
||||
var b=document.getElementById("ccq-refresh");
|
||||
if(b) b.addEventListener("click",refresh);
|
||||
refresh();
|
||||
});
|
||||
window.__ccqRender = render;
|
||||
window.__ccqRefresh = refresh;
|
||||
})();
|
||||
</script>
|
||||
]==],
|
||||
},
|
||||
}
|
||||
|
||||
-- exports is what makes collect_account reachable from the plugin's own page.
|
||||
-- Without this list the call endpoint refuses it: the endpoint is an HTTP
|
||||
-- surface into the Lua state, so "which functions may it reach" has to be the
|
||||
-- plugin's explicit answer rather than a default.
|
||||
PLUGIN.exports = { "collect_account" }
|
||||
|
||||
-- collect_account gathers one account's report. It returns the report TABLE (not
|
||||
-- a string) so the page receives structured numbers and booleans instead of
|
||||
-- having to parse anything back out of a formatted blob.
|
||||
PLUGIN.collect_account = function(acct)
|
||||
if type(acct) ~= "table" then return nil, "expected account table" end
|
||||
if acct.key == nil or acct.key == "" then
|
||||
return { name = tostring(acct.name or ""), failures = { "missing api key" },
|
||||
auth_failed = true }
|
||||
end
|
||||
local rep = collectAccount({
|
||||
name = acct.name, label = acct.label, key = acct.key,
|
||||
})
|
||||
return rep, nil
|
||||
end
|
||||
|
||||
return PLUGIN
|
||||
Reference in New Issue
Block a user