Compare commits

...

11 Commits

Author SHA1 Message Date
4e83ce0fc9 chore(gui): 版本号 1.9.8 → 1.9.9 2026-10-07 16:33:52 +08:00
ae2b121959 feat(plugins): usage 插件进仓,随包播种
之前只通过 POST /api/plugins 装在生产上,源码仓里没有 —— 于是
core-dist.sh 的 deb/rpm/tar.gz 与 GUI 的 electron-builder 产物都不带它,
/v1/usage 在任何拿到安装包的人那里都是 404,页面显示「后台未开放消费接口」,
看起来像坏了而不像少了个可选功能。billing.lua / quota.lua 一直都是 embed 的,
usage 与它们同级。

四个测试(internal/lua/usage_plugin_test.go)盯的是三件容易在改 Lua 时被
无声破坏的事:

1. 遮罩算法必须与 Go 的 keyID() 一致。/api/stats 的 map 以掩码(***f9a9ed)
   为键,handler 收到的是完整 key;传完整 key 不报错,只是静默返回 0。实测:
     ?key=***f9a9ed        → reqs=33847
     ?key=sk-gw-7ffe…9ed   → reqs=0
   端点于是对一个有几十万次请求的 key 报「0 次」。
2. 不得读 PLUGIN.state / prices / 任何凭据:它报的是**调用者**的用量,
   读配置里的 key 会把一个租户的花费报给所有租户。
3. 取不到数据必须报「usage unavailable」,不能返回 0:0 会被渲染成
   「已用 0,0 次」,那是「这个 key 从没被用过」这个不同的、且错误的断言。
4. period 默认 all:日维度看起来像每天清零。

反向验证过 roles 声明:撤掉后 TestUsagePluginShipsAndGuardsItsOwnKey 变红。
2026-10-07 16:32:58 +08:00
af977efbf7 fix(gateway): ?key= 传完整 key 会让 /api/stats panic
给 usage 插件探路时撞出来的,与插件无关。

Snapshot 的统计 map 以**掩码** key id 为键(keyID(req.Key))——by_key 返回什么、
面板就传什么。而 exportKey 对 admin 允许 ?key= 任意值,于是:

    byKey = map[string]*Stat{key: s.byKey[key]}   // 传完整 key → 零值 nil *Stat
    ...
    rows() 里 Stat: *v                          // 解引用 nil → panic

实测(改动前,线上):

    /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
    → curl: (52) Empty reply from server
    → 日志: http: panic serving …: invalid memory address or nil pointer dereference
        llmsproxy/internal/gateway.rows  stats.go:771

net/http 会 recover 单个连接所以进程不死,但客户端拿到空响应,日志里每次一条
panic 栈。任何「这个 key 用了多少」的问法(key 已配但当天无流量、period 把它
的历史滤掉了)都会命中 —— 而零正是那个问题的答案,不是错误。

三处防御,因为同一个错误形状有四个入口:
- rows() 跳过 nil 而不是解引用(调用方不止 Snapshot)
- byStatus 的两处遍历同理
- PeriodSnapshot 的 buckets[k] 同理

by_key 仍保留该 key 的一行零值:面板按行存在与否渲染,缺行会让 key 整个消失。

判据:internal/gateway/stats_unknown_key_test.go(5 个测试)。反向验证过——
把 Snapshot 与 rows 的修复撤掉,TestSnapshotUnknownKeyDoesNotPanic 立刻 panic。
2026-10-07 11:36:42 +08:00
0d9eb916df chore(gui): 版本号 1.9.7 → 1.9.8 2026-10-07 06:38:18 +08:00
d01712a697 test(harness): 变异测试拒绝在脏工作区上运行
两个变异 harness(Go 版 discover、Python 版 quota)都是「把变异写进源码
文件、跑测试、再还原」。这个形状有两个陷阱,上一轮都真踩到了:

一、脏工作区上的 baseline 是错的

变异被覆盖写入实现文件。如果文件本来就有未提交改动(上一轮被 background
task 超时杀掉、Ctrl-C 中断、编辑器里改了一半),那么:

  - 捕获的「原始内容」不是已提交的源码,baseline 跑的是别的东西
  - 留下的那个变异会被下一轮当成 baseline

两种情况下所有裁决都毫无意义,而 harness 仍然报出干净的分数 —— 正是
「判据失效」最坏的一种:它不报错。

改为直接拒绝运行(Go 版 t.Fatalf,Python 版退出码 2),并且不自动 checkout:
脚本分不清「残留变异」和「人正在改」,静默回退真实改动比停下来更糟。

二、注入必须原子,还原必须覆盖所有退出路径

Python 版原来用 open(path,"w"),就地截断。与它并发的构建(background
go test、编辑器 gofmt、编译器自己读文件)可能看到写了一半的文件,报
syntax error,然后被当成 COMPILE 静默豁免 —— 一个真实的变异就这样被放过。
改为 tempfile + os.replace。还原登记到 atexit 与 SIGINT/SIGTERM。

三、路径必须相对当前目录

Go 版最初用 cwd=包目录 + 仓根相对路径,git 匹配不到任何东西,守卫永远不
触发。两个 harness 现在都用 ./name,各自在自己目录里跑 git。

两个方向都验证过:干净时全杀(discover 11/11、quota 12/12),脏时拒绝运行。
2026-10-07 03:58:51 +08:00
3444d2d903 fix(lua): 导出函数调用不标脏,插件状态从不落盘
quota 声明 hooks=[],全部工作都在导出函数里。markDirty 只从 hook 与
SetState 触发,Call 两边都不占 —— 于是探测改的内存没人写盘,状态文件要等
下一次无关的配置写入才顺带刷新。

现象是「插件看起来没写」:负 memo 要等到下一次改配置才出现。用隔离实例
逐步观察时,一度把这误读成 memo 状态机的三个缺陷,逐个查清后只有这一条
是真的:

  - 「未就绪轮次累计 streak」 —— 不成立。写侧本来就对临时条目用 streak=1,
    65ed1b 版本已是这个行为。
  - 「读侧用旧快照,streak 永远停在 1」 —— 是退避的设计行为。单进程内第二
    次探测被退避挡在写侧之前,streak 自然不再增长;真实的多轮计数在跨进程
    (trace_streak.py)下是 1/2/3/4。
  - 「Call 不标脏」 —— 真缺陷,已验证:去掉 markDirtyLocked,
    新增的 TestOnDemandCallIsPersisted 立刻变红。

顺带保留两处无行为差异的可读性收紧:provisionalEntry 统一按「集合非空」
判断临时条目(与 copySet 空集返回 nil 的写法对齐)。

判据:TestOnDemandCallIsPersisted 用 hookless 插件走导出函数累加,
反向证明(去掉 markDirtyLocked)会红。
2026-10-07 02:31:11 +08:00
83f1ec0807 fix(quota): 未就绪适配器污染负 memo;被拒凭据被后续 404 掩盖
端到端验证时撞出两个真缺陷,都是单元测试全绿、隔离实例打真上游才暴露。

一、负 memo 语义混用

负 memo 说的是「这台 HOST 没有余额端点」,且无条件在每次探测前生效。
openDesign 缺 control_key 说的是「这台部署还没配」—— 两件事共用一个条目:

  1. 未配 control_key → 探测 → 负 memo
  2. 操作员写入 key   → 探测被退避拦下,adapter 根本没跑
  3. 结果:修复看起来无效,源持续沉默(退避窗口可达数小时)

不能靠「skip 就不记 memo」解决:真实 vela 主机上另外三个 adapter 都返回
404(关于主机的持久结论,正是 memo 存在的理由),verdict 一定是普通 skip。
做法是 memo 条目带上「哪些 vendor 因自身未配置而无法判断」:
  - 未就绪的 adapter 直接不跑(跑了只能得到 401 或伪造的 skip)
  - 条目带 unready 集合;该 vendor 配置好后条目作废重算
  - 仍缺配置时条目依然写入(否则无看板的主机回到每次开页 60 秒超时),
    但不退避 —— 退避会拒绝那个本来能成功的探测

二、401 被下一个 adapter 的 404 掩盖

adapter 循环不停在 error,而「最后一个胜」不精确:openDesign 走厂商的整份
主机列表,是最可能看到「key rejected」的那个,但它也可能排在最前(memo 记
得它应答过,下次就优先它)—— 于是它的 401 被下一个 adapter 的
404「no dashboard here」分类成「这台主机没有余额端点」。两种情况的修复动作
完全相反,输出却一样。规则改为:被拒凭据优先,且不被覆盖。

判据:12/12 变异全杀。三条曾经存活或误杀,逐条查清:
  - rejected-as-skip 存活是因为测试没有复现生产调用顺序(memo 空 → openDesign
    排最后 → 旧规则也能过),测试已改为预置 memo 并让另三个 adapter 走同一个
    stub(它们读 base 而非 api_base,否则会全部传输失败)
  - no-cred-guard 并非存活,只是杀它的不是我以为的那个测试(门禁后面还有
    一道检查),预期已改正
  - unready-memo-not-dropped 是真等价变异(known=false 与 nil 只差一次廉价
    探活),已从清单移除
2026-10-07 01:42:24 +08:00
65075b6d44 fix(plugins): 分片 VM 拿不到插件配置
页面探测走的是 fanout(分片)路径,结果写回走共享 VM。分片 VM 从同一份
源码启动,但没有 state 文件、没有插件身份,所以 PLUGIN.prices(配置,不是
累计状态)在分片里是空的。

对只读累计状态的插件无所谓;对按配置分支的插件就是致命的。OpenDesign
适配器读 prices.control_key,未播种时它对每个主机都得出「未配置」并报
skip,而同一个调用走共享 VM 是好的。也就是说:生产上死、所有调共享 VM 的
测试全绿。

pool 在每次取用时推送配置(不只是首次启动时),并把配置变化也带给池中
复用的 VM;推送是拷贝,分片拿不到共享表的引用。

这条不是在写代码时想到的,是端到端验证时撞出来的:单元测试全过,隔离实例
打真上游时 vela 报「未配置」,而 control_key 明明写进去了。
2026-10-07 01:19:15 +08:00
4a63b9dc0b feat(quota): OpenDesign 出站主机加入白名单
插件出站白名单 = 已配置源的 host。这条规则对每个 adapter 都成立,直到
OpenDesign —— 它把推理和计费拆在两个域上:推理 amr-link,计费 amr-api。
adapter 从推理主机推导计费主机,于是它请求的 host 从来不是操作员配过的
那个,请求在离开进程前就被拒,报"不是允许的出站目标",读起来像插件 bug。

core.sourceHosts 现在同时加入派生主机,且仅当源本身就在 amr-link 上
(没有 OpenDesign 源的部署不会凭空获得这条授权)。

派生规则前后端各存一份,因为 Go 建白名单、Lua 建 URL,两边不一致的表现是
「请求被拒」而不是「命名不匹配的错误」。规则改成了完整主机名匹配:
`amr-link.` 前缀写法会把 amr-link.example.com 也改写成 OpenDesign 地址。

顺带修掉一个真缺陷:Lua 侧先 `gsub("/+$")` 再匹配主机名,而 base_url 是
`https://amr-link.open-design.ai/v1` —— 带路径时整串拿去匹配 `^...$`
永远不命中,改写静默失效,探测打到推理主机。改成先切路径。

这个缺陷是「行为断言」抓到的:上一版判据 grep 源码里的 gsub 模式,而
gsub 模式在改前改后都"看起来对"。8/8 变异全杀。
2026-10-06 23:55:15 +08:00
75a0d247a4 feat(quota): 接入 OpenDesign/Vela 余额探测(controlKey)
vela 一直显示 skipped,因为计费端点需要第二个凭据:推理走源自己的
runtimeKey,但 amr-api.open-design.ai 的 wallet/balance 和
billing/summary 只认桌面端登录时下发的 controlKey(runtimeKey 返回
401)。全量后拿到了 controlKey,本机 /root/.amr/config.json。

测了实测行为(两个端点各 200,约 1.3s):
  wallet/balance   → balanceUsd / expiresAt / workspaceId
  billing/summary  → creditsPerUsd / membershipTier /
                     subscriptionCreditsUsd / totalConsumedUsd 等

实现要点:
- controlKey 走插件自己的 prices 表(插件系统本就按插件持久化一张
  prices,与 state 分离),用 PUT /api/plugins/quota/state 设置;
  base_url 保持推理主机,adapter 内部改写 amr-link→amr-api。
- 本账户 billingState=free,没有 codingPlan.windows,所以不设
  rep.windows:页面显示"无周期数据",而不是编一个 100%。
- creditsPerUsd 缺失时置 credits_rate_missing 并跳过积分换算,不默认
  10000;*Usd 字段是权威值,直接采用。
- 401 归为 error(凭据要重登),不是 skip(无计费端点),两者修复动作
  不同。
- skip 时把原因带上:区分「未配置凭据」和「上游无该端点」。

新增 7 条判据 + 7 个变异全部杀死(含一条"skip 丢原因")。变异 harness
第一轮 6/6 时 no-cred-guard 存活,原因是该测试没设 api_base,探测被
出站白名单拦掉、与缺少凭据无法区分 —— 判据失效而非代码问题,已修。
2026-10-06 23:43:30 +08:00
2fdb82b119 fix(ui): 生图与自动探测按钮把对象塞给了 fetch
`api()` 是 `(p, o) => fetch(p, o)`,第二个参数是原生 fetch options,
所以 `body` 必须是字符串。两处都传了对象字面量:

- 生图页 `body: { model, prompt, n, size }`
- 源编辑框自动探测 `body: { base_url, api_key }`

fetch 把对象转成 `"[object Object]"`,网关 json.Unmarshal 失败返回
400,请求根本没到上游。生图页的现象是 lat_ms: 0。

两处都改成 JSON.stringify + 显式 Content-Type。

判据在源码层扫描全文件的每个 `method: "POST"`,而不是断言 DOM ——
wire format 在 DOM 里不可见,这也正是为什么之前测过 curl 端点却漏掉
了按钮。这条全文件扫描当场抓到了第二个同源缺陷(自动探测按钮)。
2026-10-06 23:43:04 +08:00
18 changed files with 2159 additions and 33 deletions

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.9.7",
"version": "1.9.9",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -650,11 +650,29 @@ func (c *Core) rebuildRegistry() error {
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
// failing startup: a malformed base_url is already rejected when the provider is
// built, and it must not take plugins down with it.
//
// A source may also imply a SECOND host. OpenDesign splits inference
// (amr-link.open-design.ai) from billing (amr-api.open-design.ai), and the
// quota plugin derives the billing host from the inference one — so the host it
// asks for is not the host the operator configured, and the request would be
// refused with a message that reads like a plugin defect. Adding the derived
// host here is what makes the probe reachable.
//
// The grant stays scoped: only a source that IS on amr-link pulls in amr-api, so
// a deployment with no OpenDesign source does not silently gain the entry.
func sourceHosts(cfg *config.Config) []string {
if cfg == nil {
return nil
}
out := make([]string, 0, len(cfg.Sources))
seen := make(map[string]bool, len(cfg.Sources))
add := func(h string) {
if h == "" || seen[h] {
return
}
seen[h] = true
out = append(out, h)
}
for _, s := range cfg.Sources {
if s.BaseURL == "" {
continue
@ -663,11 +681,27 @@ func sourceHosts(cfg *config.Config) []string {
if err != nil || u.Hostname() == "" {
continue
}
out = append(out, u.Hostname())
add(u.Hostname())
add(deriveBillingHost(u.Hostname()))
}
return out
}
// deriveBillingHost returns the billing host a configured source implies, or
// the host unchanged when it implies none.
//
// Kept in Go as well as in the Lua adapter because the two must agree: Go builds
// the allowlist and Lua builds the request URL, and a disagreement shows up as a
// refused request rather than as an error naming the mismatch. The rule is the
// full hostname, not a prefix — `amr-link.` as a prefix would also rewrite
// amr-link.example.com into an OpenDesign address.
func deriveBillingHost(host string) string {
if host == "amr-link.open-design.ai" {
return "amr-api.open-design.ai"
}
return host
}
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
// against the current providers. Slot model ids are normalized to the exact
// configured spelling (case-insensitive match), otherwise ModelFor would fall

View File

@ -0,0 +1,108 @@
package core
import (
"net/url"
"testing"
"llmsproxy/internal/config"
)
// The plugin outbound allowlist is built from the hosts of configured sources.
// That rule is correct for every adapter whose credential and endpoint live on
// the same host — which was all of them until OpenDesign.
//
// OpenDesign splits them: inference is amr-link.open-design.ai, billing is
// amr-api.open-design.ai. The quota plugin derives the billing host from the
// inference one, so the host it asks for is NOT the host the operator
// configured. The request is then refused by the allowlist with a message that
// reads like a plugin defect ("host ... is not an allowed outbound destination")
// rather than a missing entry.
//
// This file pins the bridge: when a source looks like OpenDesign, the derived
// billing host joins the allowlist.
func TestSourceHostsIncludesConfiguredHosts(t *testing.T) {
cfg := &config.Config{Sources: []config.Source{
{Name: "a", BaseURL: "https://api.deepseek.com"},
{Name: "b", BaseURL: "https://amr-link.open-design.ai/v1"},
{Name: "empty", BaseURL: ""},
{Name: "bad", BaseURL: "://nonsense"},
}}
got := map[string]bool{}
for _, h := range sourceHosts(cfg) {
got[h] = true
}
for _, want := range []string{"api.deepseek.com", "amr-link.open-design.ai"} {
if !got[want] {
t.Errorf("sourceHosts missing configured host %q (allowlist would refuse "+
"every plugin request to it): %v", want, got)
}
}
// An empty/unparseable base_url must not abort the whole list.
if !got["api.deepseek.com"] {
t.Fatal("an unparseable sibling source dropped a valid host")
}
}
// TestSourceHostsAddsDerivedBillingHost covers the OpenDesign case at the level
// that actually matters: the host the PLUGIN asks for must be in the allowlist.
func TestSourceHostsAddsDerivedBillingHost(t *testing.T) {
cfg := &config.Config{Sources: []config.Source{
{Name: "vela", BaseURL: "https://amr-link.open-design.ai/v1"},
}}
got := map[string]bool{}
for _, h := range sourceHosts(cfg) {
got[h] = true
}
if !got["amr-link.open-design.ai"] {
t.Fatalf("inference host missing: %v", got)
}
if !got["amr-api.open-design.ai"] {
t.Fatalf("the billing host the quota plugin derives is not in the allowlist, "+
"so every OpenDesign balance probe is refused before it leaves the "+
"process: %v", got)
}
}
// A deployment with no OpenDesign source must not silently gain its billing
// host: an allowlist entry is a grant, and widening it for everyone would let an
// unrelated plugin reach a host the operator never configured.
func TestSourceHostsDoesNotGrantUnconfiguredBillingHost(t *testing.T) {
cfg := &config.Config{Sources: []config.Source{
{Name: "ds", BaseURL: "https://api.deepseek.com"},
}}
for _, h := range sourceHosts(cfg) {
if h == "amr-api.open-design.ai" {
t.Fatal("the OpenDesign billing host was granted to a deployment with " +
"no OpenDesign source")
}
}
}
// The derivation has to agree with the Lua side. Keep the two spellings of the
// rule in one place so a change to one is visible when reading the other.
func TestSourceHostsDerivationMatchesPluginRule(t *testing.T) {
// what the Lua adapter does: origin:gsub("^amr%-link%.", "amr-api.")
const in = "amr-link.open-design.ai"
want := "amr-api.open-design.ai"
got := deriveBillingHost(in)
if got != want {
t.Fatalf("deriveBillingHost(%q) = %q, want %q — the Go allowlist and the "+
"Lua probe must agree on the host or the request is refused", in, got, want)
}
// A host that is not amr-link stays untouched, so this cannot accidentally
// rewrite an unrelated provider into an OpenDesign address.
for _, other := range []string{"api.deepseek.com", "amr-link.example.com", "xamr-link.a"} {
if deriveBillingHost(other) != other {
t.Errorf("deriveBillingHost(%q) = %q, want it unchanged", other, deriveBillingHost(other))
}
}
// url.Parse round-trip keeps the helper honest about what it receives.
u, err := url.Parse("https://amr-link.open-design.ai/v1")
if err != nil {
t.Fatal(err)
}
if deriveBillingHost(u.Hostname()) != "amr-api.open-design.ai" {
t.Fatal("derivation failed on a real base_url hostname")
}
}

View File

@ -15,6 +15,11 @@ package gateway
// the filter selects a non-trivial number of tests (see baseline below), and it
// treats "[no tests to run]" as a hard error rather than a green run.
//
// It also must not be run against a DIRTY tree: the mutant is written over
// discover.go, and a leftover mutant from a killed run would make the baseline
// green against broken code while every verdict came from the wrong source.
// See the guard at the top of TestRealMutants.
//
// Run: go test -tags luajit ./internal/gateway/ -run TestRealMutants -v
import (
@ -49,6 +54,23 @@ func TestRealMutants(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Refuse to run against a dirty tree.
//
// The mutant is written OVER discover.go, so on a dirty file the "original"
// captured above is not the committed source — the baseline then runs against
// whatever was there, and a killed run (a background-task timeout, a Ctrl-C)
// leaves a mutant behind that the NEXT run treats as the baseline. Both make
// every verdict meaningless while the harness still reports a clean score.
//
// Refusing rather than restoring: this cannot tell "leftover mutant" apart from
// "the developer is mid-edit", and silently reverting real work is worse than
// stopping.
if dirty, derr := mutationTargetIsDirty(wd, "discover.go"); derr == nil && dirty {
t.Fatalf("%s has uncommitted changes — commit or stash them first. A mutant "+
"left behind by a killed run would otherwise become the baseline, and "+
"every verdict below would be about code nobody wrote.", target)
}
defer func() {
if werr := os.WriteFile(target, orig, 0o644); werr != nil {
t.Fatalf("restore discover.go: %v", werr)
@ -234,6 +256,23 @@ func runSuite(wd, filter string) string {
return string(out)
}
// mutationTargetIsDirty reports whether a file under internal/gateway has
// uncommitted changes, so the harness can refuse to run instead of treating a
// leftover mutant as the baseline.
//
// git runs from the package directory with a PATH RELATIVE to it. An earlier
// version of the sibling script passed a repo-relative pathspec while running in
// the package directory; git matched nothing and the guard never fired.
func mutationTargetIsDirty(wd, name string) (bool, error) {
cmd := exec.Command("git", "diff", "--name-only", "--", "./"+name)
cmd.Dir = wd
out, err := cmd.Output()
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) != "", nil
}
func countTests(out string) int {
n := 0
for _, line := range strings.Split(out, "\n") {

View File

@ -765,9 +765,20 @@ func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
return total
}
// rows flattens a map[string]*Stat into a reportable, descending-by-requests
// list.
//
// A nil value is skipped rather than dereferenced. The maps this is called with
// are built by several callers (Snapshot, PeriodSnapshot, the audit replay) and a
// nil entry is a legitimate representation of "this dimension has no data" —
// `map[string]*Stat{key: s.byKey[key]}` produced exactly that whenever the
// queried key had no recorded traffic, and `*v` then panicked the handler.
func rows(m map[string]*Stat) []StatsRow {
out := make([]StatsRow, 0, len(m))
for k, v := range m {
if v == nil {
continue
}
out = append(out, StatsRow{Name: k, Stat: *v})
}
for i := 1; i < len(out); i++ {
@ -1185,7 +1196,26 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
if key == "" {
byKey, byModel, bySrc = s.byKey, s.byModel, s.bySrc
} else {
byKey = map[string]*Stat{key: s.byKey[key]}
// The map is keyed by the MASKED key id (keyID(req.Key)), which is what
// by_key reports and what the dashboard sends as ?key=. An admin may
// pass anything in that query parameter, including the full key, and
// `s.byKey[key]` then returns the zero value — a nil *Stat.
//
// rows() below does `*v` on every value, so a nil entry panicked the
// handler: any ?key=<full key> request killed the response with an empty
// reply, and logged a full panic stack. Reproduced against the live
// gateway: /api/stats?period=day&limit=1&key=sk-gw-... → curl exit 52.
//
// A key with no recorded traffic is a legitimate question ("what did this
// key use?"), and its answer is zero — so an absent entry is an empty
// result, not an error, and not a crash.
st := s.byKey[key]
byKey = make(map[string]*Stat, 1)
if st != nil {
byKey[key] = st
} else {
byKey[key] = &Stat{}
}
byModel = s.byKeyModel[key]
bySrc = s.byKeySrc[key]
}
@ -1202,8 +1232,11 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
}
}
bs := make([]agrRow, 0, len(s.byStatus))
for code := range s.byStatus {
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *s.byStatus[code]})
for code, st := range s.byStatus {
if st == nil {
continue
}
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *st})
}
sort.Slice(bs, func(i, j int) bool {
ci, _ := strconv.Atoi(bs[i].Name)

View File

@ -174,6 +174,9 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
out.Srcs = rows(bySrc)
bs := make([]agrRow, 0, len(byStatus))
for code, st := range byStatus {
if st == nil {
continue
}
bs = append(bs, agrRow{Name: code, Stat: *st})
}
sort.Slice(bs, func(i, j int) bool {
@ -192,7 +195,11 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
sort.Strings(ks)
out.Bucket = make([]PeriodBucket, 0, len(ks))
for _, k := range ks {
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]})
// Defensive for the same reason rows() is: ks comes from the bucket map's
// keys, and a nil value would panic on the deref below.
if st := buckets[k]; st != nil {
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *st})
}
}
// The window is only "complete" if the audit walk actually reached back

View File

@ -0,0 +1,140 @@
package gateway
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestSnapshotUnknownKeyDoesNotPanic covers a nil-dereference that killed the
// response for any /api/stats request carrying a ?key= the statistics had no
// entry for.
//
// The maps in Snapshot are keyed by the MASKED key id (keyID(req.Key)) — that
// is what by_key reports and what the dashboard sends. An admin may pass anything
// in the query parameter, including the full key, and `s.byKey[key]` then yields
// the zero value: a nil *Stat. rows() dereferenced every value, so the handler
// panicked and the client got an empty reply plus a panic stack in the log.
//
// Reproduced against the live gateway before the fix:
//
// /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
// → curl: (52) Empty reply from server
// → log: http: panic serving …: invalid memory address or nil pointer dereference
// llmsproxy/internal/gateway.rows stats.go:771
//
// A key with no recorded traffic is a legitimate question, and zero is its
// answer — not a crash.
func TestSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
s := NewStats(16)
// One recorded key, masked the way Record masks it.
s.Record(Req{Key: "***aaaaaa", Type: "chat", Model: "m", Prompt: 5, Compl: 2})
cases := []struct {
name string
key string
}{
{"masked key that exists", "***aaaaaa"},
{"masked key that does not exist", "***zzzzzz"},
{"full key rather than the mask", "sk-gw-full-key-value"},
{"arbitrary text", "not-a-key-at-all"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// A panic here fails the subtest by unwinding through the test
// goroutine; rows() used to take the whole process with it.
out := s.Snapshot(10, c.key)
byKey, ok := out["by_key"].([]StatsRow)
if !ok {
t.Fatalf("by_key is %T, want []StatsRow", out["by_key"])
}
total, _ := out["total"].(Stat)
t.Logf("key=%q → by_key %d 条, total.reqs=%d", c.key, len(byKey), total.Reqs)
})
}
}
// TestSnapshotUnknownKeyReportsZeroNotAbsence pins the semantics: a key with no
// traffic answers zero, and the by_key row still exists so a dashboard that keys
// off its presence renders a row of zeros rather than dropping the key.
func TestSnapshotUnknownKeyReportsZeroNotAbsence(t *testing.T) {
s := NewStats(16)
out := s.Snapshot(10, "***zzzzzz")
byKey, _ := out["by_key"].([]StatsRow)
if len(byKey) != 1 {
t.Fatalf("by_key has %d rows, want 1: the queried key must appear with zeros", len(byKey))
}
if byKey[0].Name != "***zzzzzz" {
t.Errorf("row name = %q, want ***zzzzzz", byKey[0].Name)
}
if byKey[0].Reqs != 0 {
t.Errorf("reqs = %d, want 0", byKey[0].Reqs)
}
}
// TestPeriodSnapshotUnknownKeyDoesNotPanic is the same guard on the period view,
// which builds its own buckets and calls the same flattening helpers.
func TestPeriodSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
dir := t.TempDir()
audit := dir + "/audit.jsonl"
writeAuditFile(t, audit, 3, "***aaaaaa", time.Now().Add(-time.Hour).UnixMilli())
s := periodStats(t, audit)
for _, key := range []string{"***aaaaaa", "***zzzzzz", "sk-gw-full-key-value"} {
out := s.PeriodSnapshot(PeriodAll, key, time.Now())
t.Logf("key=%q → total.reqs=%d byKey=%d buckets=%d",
key, out.Total.Reqs, len(out.ByKey), len(out.Bucket))
}
}
// TestRowsSkipsNilValues pins the defensive behaviour directly, so a future
// caller that reintroduces a nil entry fails here rather than in production.
func TestRowsSkipsNilValues(t *testing.T) {
in := map[string]*Stat{
"has-traffic": {Reqs: 5},
"no-entry": nil,
}
got := rows(in)
if len(got) != 1 {
t.Fatalf("rows() returned %d rows, want 1 (the nil entry must be skipped): %+v", len(got), got)
}
if got[0].Name != "has-traffic" {
t.Errorf("kept %q, want has-traffic", got[0].Name)
}
}
// TestRowsEmptyIsEmptyNotNil keeps the JSON shape honest: an all-nil map yields
// an empty list, which encodes as [] rather than null — the frontend does
// Array.isArray checks on these fields.
func TestRowsEmptyIsEmptyNotNil(t *testing.T) {
got := rows(map[string]*Stat{"a": nil})
if got == nil {
t.Fatal("rows() returned nil; the report field must stay [] instead of null")
}
if len(got) != 0 {
t.Fatalf("got %d rows, want 0", len(got))
}
}
// TestStatsAPIUnknownKeyReturnsJSON exercises the HTTP path so the assertion
// covers the reported symptom (an empty reply), not just the helper.
func TestStatsAPIUnknownKeyReturnsJSON(t *testing.T) {
g := newTestGateway(t)
srv := httptest.NewServer(g.Handler())
defer srv.Close()
req, err := http.NewRequest("GET", srv.URL+"/api/stats?period=day&limit=1&key=sk-gw-full-key", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer sk-test")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("request failed (an empty reply is what the live gateway produced): %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}

View File

@ -3000,9 +3000,25 @@ async function renderChat() {
btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000)));
}, 1000);
try {
// api() is a thin wrapper over fetch(p, o) — o is the NATIVE
// fetch options object, so body must be a STRING.
//
// Passing the object directly made fetch coerce it to the literal
// text "[object Object]", the gateway's json.Unmarshal rejected it,
// and the page showed a bare "400 Bad Request" with no image and no
// usable message. It cost a round of debugging because the DOM, the
// model list and the button state were all correct — only the wire
// format was wrong, which is invisible until you capture the request.
//
// JSON.stringify + an explicit Content-Type is what every other
// POST in this file does; the missing header plus the object body
// meant the server saw Content-Type: text/plain and a string body.
const payload = { model: model, prompt: prompt, n: n };
if (size) payload.size = size;
const r = await api("/v1/images/generations", {
method: "POST",
body: size ? { model: model, prompt: prompt, n: n, size: size } : { model: model, prompt: prompt, n: n },
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const empty = $("#it-empty");
if (empty) empty.remove();
@ -3483,7 +3499,14 @@ async function renderChat() {
try {
const r = await api("/api/sources/discover", {
method: "POST",
body: { base_url: baseUrl, api_key: apiKey },
// api() hands its second argument straight to fetch, so `body`
// must be a STRING. Passing the object made fetch send the literal
// text "[object Object]" and the gateway answered
// 400 invalid character 'o' — the Auto-discover button looked
// dead while curl against the same endpoint worked, because the
// curl test exercised the ENDPOINT and not this BUTTON.
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ base_url: baseUrl, api_key: apiKey }),
});
// The server normalized the URL form. Showing the operator exactly
// what will be saved is the whole point of probing server-side

View File

@ -0,0 +1,109 @@
package gateway
import (
"strings"
"testing"
)
// TestUI_ImageRequestSendsJSONBody pins the bug that made the image page
// unusable in production while every DOM assertion still passed.
//
// The page called api(path, {method, body: {...}}). api() is
// `(p, o) => fetch(p, o)` — o is the NATIVE fetch options object, so an object
// in `body` is coerced by fetch into the literal text "[object Object]". The
// gateway's json.Unmarshal rejected it, the response was a bare 400 with
// lat_ms 0, and the page showed an error toast with no image and no usable
// message.
//
// What made this expensive: the pane rendered, the model list had all six
// models, the button enabled itself, the console was clean. Only the WIRE
// format was wrong, and nothing in the DOM shows a wire format.
func TestUI_ImageRequestSendsJSONBody(t *testing.T) {
body := sendImageSource(t)
if !strings.Contains(body, "JSON.stringify") {
t.Error("sendImage() does not JSON.stringify its body — fetch coerces " +
"the object to the text \"[object Object]\" and the gateway answers " +
"400 with lat_ms 0")
}
if !strings.Contains(body, `"Content-Type": "application/json"`) {
t.Error("sendImage() does not set Content-Type: application/json; the " +
"server sees text/plain and may refuse to parse the body")
}
// The literal defect, asserted so a later refactor reintroducing it fails
// here rather than in a browser.
if strings.Contains(body, "body: size ? {") || strings.Contains(body, "body: { model") {
t.Error("sendImage() still passes an object literal as the fetch body")
}
}
// TestUI_PostCallsSendJSONString guards every POST in the file, not just the
// image one. The image page happened to be the only place that used api() with
// an object body, so a check scoped to it would pass while another page had the
// same defect.
func TestUI_PostCallsSendJSONString(t *testing.T) {
ui := uiSource(t)
lines := strings.Split(ui, "\n")
var bad []string
for i, l := range lines {
if !strings.Contains(l, `method: "POST"`) {
continue
}
end := i + 8
if end > len(lines) {
end = len(lines)
}
window := strings.Join(lines[i:end], "\n")
if !strings.Contains(window, "body:") {
continue // no body at all: legitimate (a bare trigger)
}
if strings.Contains(window, "JSON.stringify") || strings.Contains(window, "FormData") {
continue
}
bad = append(bad, strings.TrimSpace(l))
}
if len(bad) > 0 {
t.Errorf("%d POST call(s) send a non-string body:", len(bad))
for _, b := range bad {
if len(b) > 100 {
b = b[:100]
}
t.Errorf(" %s", b)
}
}
}
// TestUI_ImagePageReportsServerErrors pins that a failed generation surfaces
// something actionable. The production bug produced a bare "400 Bad Request"
// toast; the requirement is that the gateway's own message reaches the page.
func TestUI_ImagePageReportsServerErrors(t *testing.T) {
body := sendImageSource(t)
// api() throws Error(j.error.message) on !r.ok, so appending e.message is
// what puts the explanation on screen.
if !strings.Contains(body, "e && e.message") {
t.Error("sendImage() does not surface the server's error message; a " +
"failure would show a bare status with no cause")
}
// And a success carrying no image must not look like a success.
if !strings.Contains(body, "imgNoData") {
t.Error("sendImage() does not warn when the upstream returns no image")
}
}
// sendImageSource returns the text of sendImage() only, so an assertion cannot
// be satisfied by a string that happens to appear elsewhere in the file.
func sendImageSource(t *testing.T) string {
t.Helper()
ui := uiSource(t)
i := strings.Index(ui, "async function sendImage(")
if i < 0 {
t.Fatal("sendImage() not found")
}
body := ui[i:]
if end := strings.Index(body[1:], "\n function "); end > 0 {
body = body[:end+1]
}
return body
}

View File

@ -281,3 +281,60 @@ func TestCloseFlushesTail(t *testing.T) {
t.Errorf("flushed n = %v, want 1", saved.State["n"])
}
}
// TestOnDemandCallIsPersisted guards the persistence gap that the quota memo fell
// into, and it needs a plugin with NO hooks to see it.
//
// markDirty fired from two places: hooks and SetState. `Call` — the on-demand path
// a plugin page uses — fired from neither, so a plugin that does all its work in
// exported functions (quota: it declares no hooks at all) mutated state that
// nothing ever wrote. The visible symptom was a health probe that looked inert
// from outside: the memo changed in memory and the file kept the previous
// generation until some unrelated configuration write happened to flush it.
//
// A hookless plugin is the whole point of the fixture: the counter above
// accumulates through a hook, which is the path that already persisted, so it
// cannot see this defect.
func TestOnDemandCallIsPersisted(t *testing.T) {
ps, _ := newPersistVM(t)
if err := ps.LoadSource("ondemand", `
local plugin = { name = "ondemand", version = "1.0" }
plugin.state = { n = 0 }
plugin.exports = { "bump" }
function plugin.bump(payload)
plugin.state.n = plugin.state.n + 1
return { n = plugin.state.n }
end
return plugin
`); err != nil {
t.Fatal(err)
}
for i := 1; i <= 3; i++ {
out, err := ps.Call("ondemand", "bump", map[string]interface{}{})
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
if m, _ := out.(map[string]interface{}); m["n"] != float64(i) {
t.Fatalf("call %d returned %v, want n=%d", i, out, i)
}
}
// Close() waits for the final flush, so no timing is involved: anything the
// calls did NOT mark dirty cannot appear here.
ps.Close()
b, err := os.ReadFile(ps.stateFile("ondemand"))
if err != nil {
t.Fatalf("★ an exported call did not mark the plugin dirty: %v — a plugin "+
"with no hooks can never persist its work", err)
}
var saved struct {
State map[string]interface{} `json:"state"`
}
if err := json.Unmarshal(b, &saved); err != nil {
t.Fatal(err)
}
if saved.State["n"] != float64(3) {
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
}
}

View File

@ -152,6 +152,9 @@ func (sp *shardPool) acquire(code, name string) (*worker, func(), error) {
w := sp.idle[n-1]
sp.idle = sp.idle[:n-1]
sp.mu.Unlock()
// Re-seed on every checkout, not only on boot: the operator may have
// changed the configuration since this VM was pooled.
sp.ps.seedShardConfig(name, w)
return w, func() { sp.release(w) }, nil
}
if sp.limit > 0 && sp.live >= sp.limit {
@ -168,6 +171,9 @@ func (sp *shardPool) acquire(code, name string) (*worker, func(), error) {
sp.mu.Unlock()
return nil, nil, err
}
// A fresh shard has no state file and no plugin identity, so the shared
// plugin's configuration has to be pushed onto it (see seedShardConfig).
sp.ps.seedShardConfig(name, w)
return w, func() { sp.release(w) }, nil
}

View File

@ -0,0 +1,176 @@
package lua
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A shard VM is booted from the same plugin SOURCE but has no state file and no
// plugin identity, so anything a plugin reads from its `prices` table
// (configuration, as opposed to accumulated state) is empty unless the pool puts
// it there.
//
// That asymmetry is invisible until a plugin's behaviour depends on its
// configuration. The OpenDesign adapter is the first: it reads
// prices.control_key, and without seeding it concluded "not configured" for every
// host and reported a skip — while the identical call through the shared VM
// worked.
//
// It mattered because the two paths do different jobs: the page probes through
// the FANOUT (shard) path and records results through the shared one. A feature
// broken on the shard path is dead in production and green in every test that
// calls the shared VM.
const shardConfigProbe = `
local P = {}
P.name = "cfgprobe"
P.version = "1.0.0"
P.exports = { "read_config" }
P.read_config = function(payload)
local c = P.prices
return { key = (type(c) == "table") and c.control_key or nil }, nil
end
return P
`
func shardConfigPlugins(t *testing.T) *Plugins {
t.Helper()
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
if err := os.WriteFile(filepath.Join(pluginDir, "cfgprobe.lua"),
[]byte(shardConfigProbe), 0o644); err != nil {
t.Fatal(err)
}
ps := NewPlugins(vm, pluginDir)
t.Cleanup(ps.Close)
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
return ps
}
func configKeyOf(t *testing.T, out interface{}) string {
t.Helper()
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("result is %T (%v), want a table", out, out)
}
s, _ := m["key"].(string)
return s
}
func TestShardSeesPluginConfiguration(t *testing.T) {
ps := shardConfigPlugins(t)
if err := ps.SetState("cfgprobe", map[string]interface{}{
"prices": map[string]interface{}{"control_key": "secret-value"},
}); err != nil {
t.Fatal(err)
}
// The shared path is the control: every pre-existing test exercises it, and
// it passes whether or not the pool seeds anything.
out, err := ps.Call("cfgprobe", "read_config", map[string]interface{}{})
if err != nil {
t.Fatalf("shared call: %v", err)
}
if got := configKeyOf(t, out); got != "secret-value" {
t.Fatalf("shared VM cannot read prices.control_key (%q) — the test itself "+
"is wrong, not the shard path", got)
}
// The shard path is the one the page uses.
outs, err := ps.ShardCall("cfgprobe", "read_config",
[]map[string]interface{}{{}, {}, {}})
if err != nil {
t.Fatalf("ShardCall: %v", err)
}
for i, o := range outs {
if got := configKeyOf(t, o); got != "secret-value" {
t.Fatalf("shard %d read prices.control_key as %q, want the configured "+
"value — a plugin that branches on its configuration behaves "+
"differently on the path the page actually uses", i, got)
}
}
}
// A pooled VM is reused, so the seeding has to happen on every checkout rather
// than only on boot, or a configuration change would be invisible to the pool
// until the process restarted.
func TestShardConfigFollowsAConfigurationChange(t *testing.T) {
ps := shardConfigPlugins(t)
if err := ps.SetState("cfgprobe", map[string]interface{}{
"prices": map[string]interface{}{"control_key": "first"},
}); err != nil {
t.Fatal(err)
}
if _, err := ps.ShardCall("cfgprobe", "read_config", []map[string]interface{}{{}}); err != nil {
t.Fatal(err)
}
if err := ps.SetState("cfgprobe", map[string]interface{}{
"prices": map[string]interface{}{"control_key": "second"},
}); err != nil {
t.Fatal(err)
}
outs, err := ps.ShardCall("cfgprobe", "read_config",
[]map[string]interface{}{{}, {}, {}, {}})
if err != nil {
t.Fatal(err)
}
for i, o := range outs {
if got := configKeyOf(t, o); got != "second" {
t.Fatalf("shard %d still reads %q after the configuration changed to "+
"%q — pooled VMs keep a stale configuration", i, got, "second")
}
}
}
// Reading must not disturb the shared value. That is what makes it acceptable to
// seed configuration into a VM whose state is otherwise throwaway, and it is why
// the seeding is a one-way copy rather than a reference to the shared table.
func TestShardConfigSeedingDoesNotAliasSharedState(t *testing.T) {
ps := shardConfigPlugins(t)
if err := ps.SetState("cfgprobe", map[string]interface{}{
"prices": map[string]interface{}{"control_key": "keep-me"},
}); err != nil {
t.Fatal(err)
}
outs, err := ps.ShardCall("cfgprobe", "read_config", []map[string]interface{}{{}})
if err != nil {
t.Fatal(err)
}
if got := configKeyOf(t, outs[0]); got != "keep-me" {
t.Fatalf("shard read %q", got)
}
out, err := ps.Call("cfgprobe", "read_config", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if got := configKeyOf(t, out); got != "keep-me" {
t.Fatalf("the shared configuration changed to %q after a shard read", got)
}
}
// TestQuotaProbesThroughTheFanoutPath is the reason the above matters. If the
// quota page stopped probing through fanout, seeding shards would be dead code
// and the asymmetry could return unnoticed.
func TestQuotaProbesThroughTheFanoutPath(t *testing.T) {
src := readShippedPlugin(t, "quota.lua")
if !strings.Contains(src, "pluginAPI.fanout(") {
t.Fatal("quota.lua no longer probes through the fanout (shard) path — the " +
"page and the tests would then exercise different code, which is how " +
"the missing shard configuration stayed hidden")
}
if !strings.Contains(src, "memo_record") {
t.Fatal("quota.lua no longer records through memo_record")
}
}

View File

@ -1172,6 +1172,54 @@ func audienceAllows(want, role string) bool {
// upgrade would overwrite anyway.
type visibilityOverrides map[string]string
// seedShardConfig copies the shared plugin's `prices` table onto a shard VM.
//
// `prices` is CONFIGURATION, as opposed to `state` (accumulated history). A shard
// must not write state — its copy is thrown away — but it must be able to READ
// the operator's configuration, or a plugin that branches on its configuration
// behaves one way through Plugins.Call and another through ShardCall.
//
// Found by the OpenDesign adapter, which reads prices.control_key: without this, a
// fanout probe saw an empty table, concluded "not configured", and reported a
// skip for every host — while the identical call through the shared VM worked.
// The page uses the fanout path, so the feature would have been dead in
// production and alive in every test that called the shared VM.
//
// Called on every checkout (boot and reuse), so a configuration change reaches
// pooled VMs too. The push is a copy: shards get the same values, never a live
// reference to the shared table.
func (ps *Plugins) seedShardConfig(name string, w *worker) {
if ps == nil || w == nil || w.L == nil {
return
}
ps.mu.RLock()
p := ps.find(name)
ps.mu.RUnlock()
if p == nil {
return
}
p.mu.Lock()
if p.state == nil || p.state.L == nil || p.state.L == w.L {
p.mu.Unlock()
return
}
prices := snapshotField(p.state.L, "prices")
p.mu.Unlock()
if prices == nil {
return
}
L := w.L
L.SetTop(0)
defer L.SetTop(0)
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
return
}
plug := L.GetTop()
pushGoValue(L, prices)
L.SetField(plug, "prices")
}
// SetPluginVisibility records an operator override for a plugin's audience. An
// empty value clears the override and restores the plugin's own default.
func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
@ -1993,7 +2041,19 @@ func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface
if w == nil {
return nil, fmt.Errorf("no state for plugin %s", name)
}
return callOn(w, fn, args)
out, err := callOn(w, fn, args)
// An on-demand call mutates state just as a hook does — quota writes its
// host→adapter memo here, and that is the whole point of the function — but
// markDirty used to fire only from hooks and SetState. quota declares no
// hooks, so every probe it ran through this path lived in memory and the memo
// file was only rewritten when something ELSE dirtied the plugin. The
// staleness is what made the memo look inert from the outside: a negative
// entry appeared only after the next configuration change happened to flush.
//
// Marking here is the same call the hook path already makes, so the cost is the
// same (the saver snapshots on its own schedule, not per call).
ps.markDirtyLocked(p)
return out, err
}
// callOn runs one exported function on a specific VM.

View File

@ -43,7 +43,8 @@ PLUGIN.name = "quota"
PLUGIN.version = "1.0.0"
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record" }
PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record",
"resolve_api_base" }
-- plugin.state must exist before anything indexes it. The kernel restores
-- persisted state OVER this table, so declaring it here is safe: a restart with
@ -105,6 +106,17 @@ local function getnum(root, path)
return n
end
-- strang reads a string field, returning nil rather than "". An empty string
-- and an absent field must stay distinguishable: the UI prints the first as
-- blank and the second as "未探测", and collapsing them loses that.
local function strang(root, path)
local v = get(root, path)
if v == nil then return nil end
if type(v) ~= "string" then return nil end
if v == "" then return nil end
return v
end
-- request performs one GET with the standard retry. Retrying is not
-- politeness here: several of these hosts sit behind DNS that intermittently
-- refuses, and a single 000 turned into "provider unknown" on every poll.
@ -220,6 +232,247 @@ local function openaiDashboard(base, key)
return rep, nil
end
-- copySet returns a shallow copy of a set-table, or nil when it is empty.
--
-- The copy matters: a memo entry outlives the call that wrote it, and storing
-- the same table would let a later write mutate a record already handed out.
-- nil for an empty set keeps "nothing was unready" out of the persisted state,
-- so the read side can use plain truthiness on the field.
local function copySet(t)
local out = nil
for k, v in pairs(t) do
out = out or {}
out[k] = v
end
return out
end
-- provisionalEntry reports whether a negative memo was written while some adapter
-- could not judge the host. Such an entry is retired the moment that adapter
-- becomes configured, and never backs off while it is still waiting.
--
-- An EMPTY `unready` is not provisional, and the emptiness has to be tested rather
-- than the presence. Two ways an empty set shows up: the write path drops empty
-- tables (so a host every adapter judged has no field at all), but a full JSON
-- round trip — restore, or one page open — turns an empty table back into a
-- truthy `{}`. Reading presence as the signal therefore makes exactly the hosts
-- that most need backing off the ones that never get it.
local function provisionalEntry(entry)
if type(entry) ~= "table" then return false end
if type(entry.unready) ~= "table" then return false end
return next(entry.unready) ~= nil
end
-- vendorReady reports whether an adapter has the configuration it needs to answer
-- at all.
--
-- OpenDesign is the first adapter that needs a credential the SOURCE does not
-- carry (prices.control_key). When it is missing the adapter should not run: with
-- no credential the only possible outcomes are a 401 (filed as an ERROR, "key
-- rejected") or a fabricated skip, and neither is a fact about the host. What it
-- would actually be reporting is that the deployment is not configured yet.
--
-- Per-VENDOR rather than one global flag, so a future adapter that needs its own
-- configuration gets the same treatment by adding a branch here.
local function vendorReady(vendor)
if vendor == "opendesign" then
local c = PLUGIN.prices
if type(c) ~= "table" or type(c.control_key) ~= "string" or c.control_key == "" then
-- The reason travels with the refusal: "adapter not configured" alone says
-- what is wrong but not what to type.
return false, "control_key not configured (set prices.control_key)"
end
end
-- Every other adapter carries its credential on the source itself.
return true, nil
end
-- resolveApiBase turns a configured source base_url into the OpenDesign billing
-- origin.
--
-- Split out as its own function because it is the one piece of this adapter that
-- is pure logic, and it is load-bearing while being silent when it breaks: the
-- probe simply goes to the inference host, which serves no billing route, and the
-- source quietly stays unlisted.
--
-- The host is taken as a HOST, not as the tail of a URL. base_url is normally
-- `https://amr-link.open-design.ai/v1`, and matching a `^...$` pattern against
-- that whole string never succeeds — the rewrite then silently does not happen.
-- That was a real defect here, found by asserting behaviour rather than source
-- text.
local function resolveApiBase(base, override)
if type(override) == "string" and override ~= "" then
return (override:gsub("/+$", ""))
end
local origin = base:gsub("^https?://", ""):gsub("/.*$", "")
origin = origin:gsub("^amr%-link%.open%-design%.ai$", "amr-api.open-design.ai")
return "https://" .. origin .. "/api/v1"
end
-- resolve_api_base exposes resolveApiBase for tests, so the derivation can be
-- asserted directly instead of inferred from which error a failed probe happened
-- to report. Declared AFTER the local function it calls — a Lua local is nil until
-- its assignment runs, and this closure would otherwise capture nil. Pure: no
-- network, no state.
PLUGIN.resolve_api_base = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
local base = tostring(payload.base_url or "")
if base == "" then return nil, "missing base_url" end
local override = payload.api_base
if type(override) ~= "string" then override = nil end
return { api_base = resolveApiBase(base, override) }, nil
end
-- openDesign probes OpenDesign / Vela (amr-link.open-design.ai).
--
-- This vendor needs a credential the source does not carry. Inference runs on
-- `runtimeKey`, but the billing endpoints on amr-api.open-design.ai only accept
-- `controlKey` — a second, separate secret handed out when the desktop app signs
-- in and stored in ~/.amr/config.json under profiles.prod.
--
-- Verified against the live service (200 on both, ~1.3s each):
-- GET https://amr-api.open-design.ai/api/v1/wallet/balance (controlKey)
-- {balanceUsd, expiresAt, workspaceId, ...}
-- GET https://amr-api.open-design.ai/api/v1/billing/summary (controlKey)
-- {creditsPerUsd, membershipTier, subscriptionStatus,
-- subscriptionCreditsUsd, rechargeCreditsUsd,
-- todayConsumedUsd, totalConsumedUsd, subscriptionCurrentPeriodEnd, ...}
--
-- The two keys are NOT interchangeable: sending runtimeKey to either endpoint
-- answers 401 {"error":"unauthenticated"}. That is why this adapter reads
-- PLUGIN.prices instead of reusing the source's api_key.
--
-- config location: `prices.control_key` in the plugin's persisted config (the
-- same file billing uses for its price table — the plugin system persists one
-- `prices` table per plugin, separate from `state`). Set it through the admin
-- API:
-- PUT /api/plugins/quota/state {"prices": {"control_key": "vela_ctrl_..."}}
-- `prices.api_base` is optional and overrides the amr-link -> amr-api host
-- derivation.
--
-- Three things this adapter deliberately does NOT do:
--
-- * It does not invent an exchange rate. creditsPerUsd is the only thing that
-- turns credits into dollars, and a missing or non-positive value is an
-- error, not a default. The vendor serves pre-computed *Usd fields too, so
-- the rate is only needed for the credit breakdown.
-- * It does not report a period window when the account has no Coding Plan.
-- `vela billing workspace-snapshot` returns billingState:"free" and NO
-- codingPlan.windows for such workspaces, and rendering "100% remaining"
-- from an absent field is the exact lie this plugin was written to avoid.
-- * It does not treat a zero balance as an exhausted plan. balanceUsd and the
-- period windows are different things; conflating them was a previous bug.
local function openDesign(base, key)
-- The source's own api_key is the RUNTIME key, which these endpoints reject.
-- The control key comes from the plugin's persisted config.
local cfg = PLUGIN.prices
local ck = nil
if type(cfg) == "table" then ck = cfg.control_key end
if type(ck) ~= "string" or ck == "" then
return nil, "skip", "control_key not configured (set prices.control_key)"
end
-- amr-link serves inference; amr-api serves billing. Rewriting the host here
-- rather than asking the operator to configure a second source keeps one
-- logical provider in the UI.
--
-- `api_base` overrides the derivation: the vendor has already moved these
-- hosts once, and an operator who can see the new one in a browser should not
-- be blocked on a code change to use it.
local api = resolveApiBase(base, type(cfg) == "table" and cfg.api_base or nil)
-- The gateway's outbound allowlist is the hosts of configured sources. It
-- therefore contains amr-link (the inference host) but NOT amr-api (the
-- billing host), and every request below would be refused with "host ... is
-- not an allowed outbound destination" — a message that reads like a plugin
-- bug rather than a missing allowlist entry. Add the derived host alongside
-- the source hosts in core.sourceHosts when this adapter is enabled.
local wallet, wErr = request(api .. "/wallet/balance", ck, nil, 1)
local summary, sErr = request(api .. "/billing/summary", ck, nil, 1)
if wallet == nil and summary == nil then
if (wErr or ""):match("key rejected") or (sErr or ""):match("key rejected") then
return nil, "error", "control_key rejected (HTTP 401)"
end
-- A 404 here means the host is not amr-link/amr-api at all; skip rather
-- than error so the memo does not blacklist an unrelated provider.
if (wErr or ""):match("HTTP 404") and (sErr or ""):match("HTTP 404") then
return nil, "skip", "no OpenDesign billing endpoints"
end
return nil, "skip", (wErr or sErr or "no billing response")
end
local w = wallet or {}
local b = summary or {}
local rep = {
vendor = "opendesign",
-- tonum would turn a missing balance into 0, i.e. "you are broke", which is
-- the failure mode this whole plugin exists to avoid. A balance that is
-- absent stays absent and the guard below turns it into a skip.
balance = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
balance_usd = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
tier = strang(b, "membershipTier"),
status = strang(b, "subscriptionStatus"),
interval = strang(b, "billingInterval"),
}
-- Credits breakdown. Converted with the vendor's OWN rate rather than
-- assuming 10000 — that is this vendor's current value and hardcoding it
-- would silently produce wrong numbers the day it changes.
local rate = getnum(b, "creditsPerUsd")
if rate and rate > 0 then
rep.credits_per_usd = rate
local sub = getnum(b, "subscriptionCredits")
local rec = getnum(b, "rechargeCredits")
if sub or rec then
rep.credits = (sub or 0) + (rec or 0)
rep.subscription_usd = sub and (sub / rate) or nil
rep.recharge_usd = rec and (rec / rate) or nil
end
rep.total_available_credits = getnum(b, "totalAvailableCredits")
else
-- No usable rate. Flag it instead of guessing; the *Usd fields below still
-- give a correct balance, so the card renders with the split missing.
rep.credits_rate_missing = true
end
-- The vendor's own pre-computed dollar amounts win over our conversion.
local sUsd = getnum(b, "subscriptionCreditsUsd")
local rUsd = getnum(b, "rechargeCreditsUsd")
if sUsd then rep.subscription_usd = sUsd end
if rUsd then rep.recharge_usd = rUsd end
-- Spend, shown next to the balance.
rep.today_used = getnum(b, "todayConsumedUsd")
rep.period_used = getnum(b, "totalConsumedUsd")
rep.total_recharged = getnum(b, "totalRechargedUsd")
rep.usage_count = getnum(b, "usageCount")
-- Wallet expiry: this balance does not last forever. Surfaced because a
-- balance that silently stops being spendable is worse than a warning.
rep.balance_expires_at = strang(w, "expiresAt")
rep.period_start_at = strang(b, "subscriptionCurrentPeriodStart")
rep.period_end_at = strang(b, "subscriptionCurrentPeriodEnd")
rep.workspace_id = strang(w, "workspaceId")
-- Period windows: deliberately NOT set. codingPlan.windows only exists for a
-- workspace on a Coding Plan, and this one reports billingState:"free".
-- Leaving rep.windows nil makes the page say "no period data" rather than
-- fabricating a 100% that an operator would read as "plenty left".
--
-- When a workspace does have a plan the windows arrive as
-- {durationSeconds, usedCredits, limitCredits, remainingCredits, resetsAt}
-- and need the same credits->USD conversion; that branch belongs here, gated
-- on the response actually carrying codingPlan.
if rep.balance == nil then
return nil, "skip", "no balanceUsd in response"
end
return rep, nil
end
-- commandCode keeps its own shape: rolling windows plus a billing period.
local function commandCode(base, key)
local H = {
@ -293,6 +546,7 @@ local ADAPTERS = {
{ vendor = "commandcode", probe = commandCode },
{ vendor = "deepseek", probe = deepseekBalance },
{ vendor = "openai-dash", probe = openaiDashboard },
{ vendor = "opendesign", probe = openDesign },
}
-- The probe memo remembers, per host, which adapter answered. Persisted in the
@ -375,6 +629,24 @@ local function collectSource(src, budget)
base = base:gsub("/+$", "")
local key = tostring(src.api_key or "")
local rep, kind, why
-- Vendors that could not judge the host because their OWN configuration is
-- missing. Stamped onto the negative memo so it can be retired the moment that
-- configuration arrives.
local unready = {}
-- A REJECTED credential outranks every other error, and the reason is the
-- only thing this variable is for.
--
-- Why the rule is not simply "last wins": the four adapters answer different
-- questions, and a general-purpose one (openDesign, which walks the vendor's
-- whole host list) can report "key rejected" while an adapter that merely could
-- not reach ITS host reports a transport error. The two need opposite
-- responses — re-issue the credential vs. investigate the network — and with a
-- plain "last wins" the credential problem is reported as a network one.
--
-- Conversely, "first error wins" is just as arbitrary in the other direction:
-- an adapter whose host is unreachable from this deployment fails before the
-- one that actually knows what is wrong.
local errWhy, errRejected = nil, false
-- Try the adapter this host answered with last time first. Same host with a
-- different key (two Command Code seats) gives the same answers, so this turns
-- 21 sources into a handful of distinct round trips after the first pass.
@ -386,17 +658,59 @@ local function collectSource(src, budget)
-- exactly the confusion that made an earlier revision drop five sources.
if type(known) == "table" then
local seen = known.at or 0
-- A streak is a host that has failed repeatedly. Skip it outright until its
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
-- A memo written while an adapter could not judge the host is PROVISIONAL.
-- It is discarded as soon as that adapter becomes configured, and it never
-- earns a backoff while it is still waiting.
--
-- Why it must not back off: the memo is applied unconditionally, before any
-- adapter runs. Honouring a provisional entry refuses the very probe that is
-- now able to succeed, so the operator supplies the missing credential and
-- sees nothing change. Measured end to end before this: unset key → memo →
-- key set → probe skipped without a single request.
--
-- The comparison is against the vendors RECORDED in the entry, not against
-- all adapters. "Is any adapter configured" is true almost always — three of
-- the four carry their credential on the source — and would retire every
-- provisional entry immediately, including the ones still waiting.
local provisional = provisionalEntry(known)
local stale = false
if provisional then
for vendor in pairs(known.unready) do
if vendorReady(vendor) then stale = true end
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false -- still "nothing answered, try cheapest first"
end
if provisional then
if stale then
-- The missing configuration is here: derive the verdict again.
known = nil
else
-- Still waiting for it. Do NOT back off: the memo is applied before any
-- adapter runs, so honouring it would refuse the very probe that will
-- succeed once the credential arrives.
known = false
end
else
-- A streak is a host that has failed repeatedly, and the backoff is keyed on
-- the RECORDED STREAK, not on this entry existing.
--
-- Keying it on existence reads like a tautology that can never fire, but it
-- fires constantly, because of WHEN the write side runs: the entry is read at
-- the top of this function and only written at the very bottom, so mid-call
-- the table still holds the two adapters that already answered on THIS call
-- — typically the two with the shortest timeouts. LIVE DEFECT: the plugin
-- then reported "连续探测失败 1 次" for a streak that was never counted and
-- refused every later probe, so an operator who supplied the missing key saw
-- nothing change, indefinitely.
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false -- still "nothing answered, try cheapest first"
end
end
end
-- known == false means every adapter already came back "no endpoint here" for
-- this host during this process. Skip the probing entirely; one cheap request
@ -440,13 +754,32 @@ local function collectSource(src, budget)
return { name = src.name, host = host, skipped = true,
reason = "probe budget exhausted" }
end
local r, k, e = a.probe(base, key)
if r ~= nil then
rep = r
HOST_MEMO[host] = a.vendor
break
-- An adapter that cannot answer for want of its OWN configuration is not
-- probed at all. Running it would produce a 401 (reported as ERROR "key
-- rejected") or a fabricated skip, and would send the operator to re-issue a
-- credential they have not configured yet.
if not vendorReady(a.vendor) then
local _, because = vendorReady(a.vendor)
unready[a.vendor] = true
kind = "skip"
why = because or ("adapter not configured: " .. a.vendor)
else
local r, k, e = a.probe(base, key)
if r ~= nil then
rep = r
HOST_MEMO[host] = a.vendor
break
end
kind, why = k, e
if k == "error" then
local rejected = tostring(e or ""):match("rejected") ~= nil
-- Last error wins, except that a rejected credential displaces anything
-- already recorded and is never displaced in turn.
if not errRejected and (errWhy == nil or rejected) then
errWhy, errRejected = e, rejected
end
end
end
kind, why = k, e
end
-- Remember the NEGATIVE result too. Only recording successes left every
-- source that has no balance endpoint re-probing all three adapters (2
@ -471,21 +804,70 @@ local function collectSource(src, budget)
--
-- A success overwrites the memo with a vendor string, so a host that recovers
-- resets its streak automatically.
--
-- The entry records WHICH vendors could not judge the host. Such a verdict is
-- provisional (see the read side): the memo is applied before every later
-- probe, so keeping it unqualified suppresses the source even after the
-- credential is supplied.
--
-- It is still WRITTEN rather than omitted, because dropping it would put a
-- dashboard-less source back to 60s of adapter timeouts on every page open —
-- the cost the memo exists to remove. A memo cannot say "three of four
-- answered, the fourth is waiting for a key", so it says the next best thing.
--
-- Its streak also does not carry over from a previous provisional entry: that
-- count would be spent on a judgement nobody made.
--
-- `unready` is copied, and copySet returns nil for an EMPTY set — which is what
-- makes "every adapter judged the host" arrive as an absent field rather than as
-- an empty table. The read side tests for a NON-EMPTY set rather than for the
-- field's presence, so the two spellings of "nothing was unready" agree.
if rep == nil then
local prev = HOST_MEMO[host]
local streak = 1
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
HOST_MEMO[host] = { at = os.time(), streak = streak }
local prevUnready = provisionalEntry(prev)
if type(prev) == "table" and not prevUnready then
streak = (prev.streak or 1) + 1
end
HOST_MEMO[host] = { at = os.time(), streak = streak, unready = copySet(unready) }
end
if rep ~= nil then
HOST_MEMO_AT[host] = os.time()
end
if rep == nil then
if kind == "error" then
return { name = src.name, vendor = "", error = tostring(why),
-- An ERROR is reported when either:
-- * the LAST adapter to judge the host reported one, or
-- * ANY adapter reported a REJECTED credential.
--
-- The second clause is not a refinement, it is the whole point. openDesign
-- walks the vendor's host list and is therefore the adapter most likely to
-- see "key rejected", but it also runs FIRST once it has been memoised as the
-- answering vendor — so a last-wins rule classifies its 401 by whatever the
-- next adapter says, and the next adapter (commandcode, pointed at the
-- source's own host) answers 404 "no dashboard here". Observed end to end:
-- a deliberately wrong control key was reported as "this host has no balance
-- endpoint", sending the operator to investigate the vendor instead of
-- re-issuing the credential. A rejection has one unambiguous remedy, so it
-- outranks position.
if kind == "error" or errRejected then
return { name = src.name, vendor = "", error = tostring(errWhy or why),
skipped = false, host = hostOf(base) }
end
return { name = src.name, skipped = true, host = hostOf(base) }
-- The skip REASON travels. Discarding it here is why
-- TestQuotaOpenDesignSkipsWithoutControlKey could not tell a configured
-- adapter that refused from a source that never reached it: both arrived as
-- {skipped=true}, so a mutation that ignored the missing credential was
-- invisible at this boundary. "Not configured" is actionable (set the key);
-- "no balance endpoint" is not.
local out = { name = src.name, skipped = true, host = hostOf(base) }
if why and why ~= "" then out.reason = tostring(why) end
-- `unready` travels with the record because the page fans out on shard VMs
-- and reports back through memo_record, where a row with no vendor and no
-- error is otherwise indistinguishable from a real failure. Without it the
-- sharded path — the one the page actually uses — would still earn the source
-- a backoff it should never have had.
if next(unready) ~= nil then out.unready = true end
return out
end
rep.name = src.name
rep.host = hostOf(base)
@ -552,8 +934,24 @@ PLUGIN.memo_record = function(payload)
else
local prev = HOST_MEMO[host]
local streak = 1
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
HOST_MEMO[host] = { at = now, streak = streak }
-- A provisional entry is replaced, not aged: its count describes a
-- judgement nobody made. See the write side in collectSource.
if type(prev) == "table" and not prev.unready then
streak = (prev.streak or 1) + 1
end
-- The page can only send a boolean (it does not know which adapter was
-- unready), so the set is re-derived here on the shared VM. Copying it
-- matters: this table is stored, and a later write must not mutate it.
local u = nil
if r.unready then
for _, a in ipairs(ADAPTERS) do
if not vendorReady(a.vendor) then
u = u or {}
u[a.vendor] = true
end
end
end
HOST_MEMO[host] = { at = now, streak = streak, unready = u }
end
end
end
@ -911,8 +1309,16 @@ PLUGIN.ui = {
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
}
if(silent.length){
// Name the reason per source instead of assuming every silent skip
// means "no balance endpoint". A source that skipped because its
// credential is missing is listed the same way but needs a
// completely different fix, and an operator reading a flat list of
// names has no way to tell them apart.
var why = silent.map(function(r){
return r.reason ? (r.name+"("+r.reason+")") : r.name;
}).join("、");
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'</div>';
' 个源:'+escH(why)+'</div>';
}
}
}
@ -987,6 +1393,10 @@ PLUGIN.ui = {
// flag rather than the wording, so translating the message cannot silently
// turn every skipped host into an ever-growing streak.
if(r.backing_off===true) return null;
// A host probed while an adapter had no configuration was not judged.
// Recording it would earn a backoff that outlives the configuration
// mistake and suppresses the probe that would now succeed.
if(r.unready===true) return null;
if(r.error) return {host:r.host};
if(r.skipped) return {host:r.host};
return {host:r.host, vendor:r.vendor};

View File

@ -0,0 +1,151 @@
-- usage:按调用者 key 汇报用量,供 OpenAI 兼容前端展示。
--
-- 为什么需要它
-- ------------
-- /v1/usage 不是网关的端点,但 OpenAI 兼容网关普遍提供它,第三方前端(如
-- AI Image Workbench)在启动时读它来显示「已用 X,Y 次」。缺了它,界面显示
-- 「后台未开放消费接口」——看起来像坏了,而不是像没配。
--
-- 为什么做成插件而不是网关路由
-- --------------------------------
-- 插件 HTTP 路由只能挂在 /api/plugins/http/ 之下,不能占 /v1/usage(这是刻意
-- 的:让插件无法遮蔽 /api/keys、/api/stats,便于整体撤销)。而「这是可插拔的
-- 便利功能、不是网关核心」正是它的性质。所以:
-- 插件注册 GET /usage
-- 部署侧的 nginx 把 /v1/usage 转发过来
-- 网关内核零改动,删掉插件就恢复原状。
--
-- 数据来源与归属
-- --------------
-- 取自己的 /api/stats —— 它已按调用 key 隔离(实测:user key 231 次 = 自己的,
-- admin 33847 次 = 全量)。
--
-- ★ 必须传**掩码**,不能传完整 key。
-- /api/stats 的统计 map 以 keyID(req.Key)(即 "***f9a9ed")为键 —— by_key
-- 返回的就是它,面板传的也是它。而 admin 身份下 ?key= 接受任意值:传完整 key
-- 会查不到,拿到零值。实测:
-- ?key=***f9a9ed → total.reqs=33847
-- ?key=sk-gw-7ffe…9ed → total.reqs=0
-- (这一版还暴露了网关一个 nil 解引用崩溃,已单独修掉,见提交 af977ef。)
--
-- 掩码的算法:末 6 位,前面替换为 ***;不足 6 位的 key 原样返回。与网关
-- internal/gateway/server.go 的 keyID() 一致。
--
-- total 为什么是 token 而不是金额
-- ------------------------------
-- 网关的 /api/stats 只有 token 与请求数,没有金额。金额在 billing 插件手里,
-- 但那是 admin 面板口径:实测 user key 取 billing state 拿到的仍是全量
-- (total.cost 与 admin 相同),按 key 取金额需要改 billing 插件本身。
-- 宁可少给一个数,也不给一个归属错误的数 —— 后者会让用户以为花了多少钱,
-- 而那个数是全站总量。
--
-- 字段形状取自 OpenAI 兼容惯例与前端实际读取的键:
-- total / total_usage / used / amount / cost ← 前端按顺序取第一个非空的
-- requests / request_count / count ← 同上
-- 这里给 total 与 requests,并在 usage 里附明细,便于排障。
local PLUGIN = {
name = "usage",
version = "1.0.0",
description = "按 key 汇报 token 与请求数(供 /v1/usage 兼容前端)",
}
-- roles 必填:插件 HTTP 路由的默认是 admin-only(声明里不写 roles 时,
-- 任何非 admin 拿到 403 “this endpoint declares no roles”)。
--
-- usage 的内容天然按**调用者自己的 key** 隔离(下面 selfStats 传的就是本次请求
-- 的 key),所以放行 user 是安全的:一个 user 只能看到自己的用量,与直接调
-- /api/stats?key= 得到的一致。声明角色而不是不声明,是为了让这个安全性
-- 写在声明里而不是藏在实现里。
PLUGIN.http = {
{ path = "/usage", method = "GET", handler = "usage",
description = "当前调用 key 的 token 与请求数",
roles = { "admin", "user" } },
}
-- PERIOD 可选:day / week / month / all。默认 all —— 「已用多少」应当是
-- 累计值,日维度反而误导(看起来像每天都重置)。
local PERIOD = "all"
-- maskedKey 把完整 key 变成 /api/stats 认识的掩码形式。
local function maskedKey(key)
if type(key) ~= "string" then return "" end
if #key <= 6 then return key end
return "***" .. string.sub(key, -6)
end
-- selfStats 取网关自己的统计。key 为空时返回 nil 而不是报错:
-- 无 key 的调用者没有可归属的用量,给零比给全量诚实。
local function selfStats(key)
if type(key) ~= "string" or key == "" then
return nil, "no key in request"
end
local url = "http://127.0.0.1:8081/api/stats?period=" .. PERIOD ..
"&limit=1&key=" .. maskedKey(key)
local r = __llmsproxy_http({
url = url,
method = "GET",
timeout_ms = 4000,
headers = { ["Authorization"] = "Bearer " .. key },
})
if r == nil then
return nil, "stats request refused or timed out"
end
if r.status ~= 200 then
return nil, "stats returned HTTP " .. tostring(r.status)
end
return r.body
end
function PLUGIN.usage(payload)
local key = payload and payload.key or ""
local body, err = selfStats(key)
if not body then
-- 一个拿不到统计的端点应当说清楚,而不是返回 0:0 会被前端渲染成
-- 「已用 0,0 次」,把故障说成了没用量。
return {
status = 503,
json = { error = { message = "usage unavailable: " .. tostring(err) } },
}
end
local ok, doc = pcall(json.decode, body)
if not ok or type(doc) ~= "table" then
return {
status = 502,
json = { error = { message = "stats response is not JSON" } },
}
end
local t = doc.total or {}
local n = function(v) return tonumber(v) or 0 end
-- 前端按 total → total_usage → used → amount → cost 的顺序取第一个「非空」。
-- Lua 里 nil 与 0 的差别很重要:0 会被读到,nil 会跳到下一个键,所以这几个
-- 别名全部给数值。
local total = n(t.tokens)
local requests = n(t.reqs)
return {
json = {
total = total,
total_usage = total,
used = total,
amount = total,
requests = requests,
request_count = requests,
count = requests,
-- 明细:前端不读,排障时不用再去翻 /api/stats
_detail = {
period = PERIOD,
key_id = maskedKey(key),
ok = n(t.ok),
err = n(t.err),
prompt_tokens = n(t.prompt_tokens),
completion_tokens = n(t.completion_tokens),
},
},
}
end
return PLUGIN

View File

@ -177,6 +177,18 @@ func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) {
srv := newCountingServer(t)
ps := newQuotaPlugins(t, srv.URL)
// Configure every vendor that needs its own credentials, so no adapter is
// UNABLE to judge the host. That is a precondition, not decoration: a host
// judged while an adapter is unconfigured gets a PROVISIONAL memo, and a
// provisional memo deliberately never backs off (it would refuse the very
// probe that succeeds once the credential arrives). Without this the test
// would be measuring that rule instead of the ordinary backoff.
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{"control_key": "ctrl"},
}); err != nil {
t.Fatal(err)
}
args := map[string]interface{}{
"sources": []interface{}{map[string]interface{}{
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",

View File

@ -0,0 +1,649 @@
package lua
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// The OpenDesign / Vela adapter is the first quota probe that needs a credential
// the source itself does not carry. Inference runs on the source's `api_key`
// (the runtime key); the two billing endpoints on amr-api.open-design.ai only
// accept the `controlKey` the desktop app receives at sign-in.
//
// That asymmetry is why these tests exist: the obvious wrong implementation —
// "reuse the source's api_key like every other adapter" — returns 401 against
// the live service, and a test that only stubs a happy response would never
// notice.
//
// Live responses this fixture copies (both 200, ~1.3s):
// GET https://amr-api.open-design.ai/api/v1/wallet/balance
// GET https://amr-api.open-design.ai/api/v1/billing/summary
const (
odWallet = `{"balanceUsd":"15.7062","expiresAt":"2026-10-08T23:01:06.662Z",` +
`"updatedAt":"2026-10-06T08:02:20.167Z","billingScopeVersion":2,` +
`"workspaceId":"h7cvgf86l79yy3cpmsbmjfxu",` +
`"workspaceMemberId":"uxqzu1rrtwsmzfpwugkcuany"}`
odSummary = `{"balanceUsd":"15.7062","creditsPerUsd":10000,` +
`"balances":{"subscriptionCredits":"152062","rechargeCredits":"5000",` +
`"totalAvailableCredits":"157062"},"subscriptionCreditsUsd":"15.2062",` +
`"rechargeCreditsUsd":"0.5000","totalAvailableCreditsUsd":"15.7062",` +
`"membershipTier":"plus","billingInterval":"monthly",` +
`"subscriptionStatus":"active","subscriptionEntitlementStatus":"active",` +
`"subscriptionCancelAtPeriodEnd":false,` +
`"subscriptionCurrentPeriodStart":"2026-10-05T23:01:54.000Z",` +
`"subscriptionCurrentPeriodEnd":"2026-11-05T23:01:54.000Z",` +
`"pendingSubscriptionChange":null,"firstMonthIntroEligible":false,` +
`"availableActions":["subscription_checkout","billing_portal"],` +
`"totalRechargedUsd":"0.5000","totalConsumedUsd":"4.7938",` +
`"todayConsumedUsd":"0.0036","rechargeCount":1,"usageCount":2982,` +
`"updatedAt":"2026-10-06T14:55:00.407Z"}`
)
// odServer stands in for amr-api. It records the Authorization header it saw on
// the two BILLING routes so a test can assert WHICH credential the plugin sent
// — the whole point of this adapter.
//
// Only the billing paths are recorded. The other three adapters derive their
// paths from the source's base_url, not from api_base, so in these tests they
// are pointed at amr-link and refused by the outbound allowlist before they can
// reach this server. Recording every request would make the "did it probe at
// all?" assertion depend on that coincidence; recording the billing routes makes
// it mean exactly what it says.
func odServer(t *testing.T, seenAuth *[]string, wallet, summary string, wStatus, sStatus int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/wallet/balance"):
if seenAuth != nil {
*seenAuth = append(*seenAuth, r.Header.Get("Authorization"))
}
w.WriteHeader(wStatus)
_, _ = w.Write([]byte(wallet))
case strings.HasSuffix(r.URL.Path, "/billing/summary"):
if seenAuth != nil {
*seenAuth = append(*seenAuth, r.Header.Get("Authorization"))
}
w.WriteHeader(sStatus)
_, _ = w.Write([]byte(summary))
default:
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"not found"}`))
}
}))
}
// odPlugins boots a real VM with the shipped quota.lua (not a copy) and returns
// the loaded Plugins.
// odPlugins boots the shipped quota.lua against a temp plugin dir.
//
// NOTE on what these tests can and cannot reach: the backoff window is read from
// the WALL CLOCK, so nothing here observes recovery of an aged negative memo —
// reaching that branch would mean sleeping 1800s. The aged-entry path is covered
// out of band, by seeding a negative memo with an old `at` (`.probe/trace_memo.py`,
// step 10) and watching the same code recover. What IS asserted here is the entry's
// SHAPE, which is what the read side keys its decisions on.
func odPlugins(t *testing.T) (*Plugins, string) {
t.Helper()
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
src := readShippedPlugin(t, "quota.lua")
if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
ps := NewPlugins(vm, pluginDir)
t.Cleanup(ps.Close)
// Set before LoadDir: the allowlist is consulted when the plugin makes a
// request, and the empty default denies everything.
ps.SetOutboundHosts([]string{"127.0.0.1"})
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
p := ps.find("quota")
if p == nil {
t.Fatal("quota plugin not found")
}
if p.LoadError != "" {
t.Fatalf("quota.lua failed to compile: %s", p.LoadError)
}
return ps, src
}
// collectOpenDesign drives the plugin's own collect_source for a source that
// looks like vela. The plugin iterates ADAPTERS in order and picks the host's
// remembered vendor, so the first adapter that answers is the one under test.
func collectOpenDesign(t *testing.T, ps *Plugins, base string) map[string]interface{} {
t.Helper()
out, err := ps.Call("quota", "collect_source", map[string]interface{}{
"name": "vela",
"sources": []interface{}{
map[string]interface{}{
"name": "vela",
"base_url": base,
"api_key": "sk-amr-runtime-key-must-not-be-sent-to-billing",
},
},
})
if err != nil {
t.Fatalf("collect_source failed: %v", err)
}
rep, _ := out.(map[string]interface{})
return rep
}
// TestQuotaOpenDesignUsesControlKeyNotRuntimeKey is the reason this adapter
// cannot be written like the others.
//
// Mutation: replace `request(api .. "/wallet/balance", ck, ...)` with the
// function's `key` parameter (`request(api .. "/wallet/balance", key, ...)`)
// and this fails — the server records the runtime key.
func TestQuotaOpenDesignUsesControlKeyNotRuntimeKey(t *testing.T) {
var seenAuth []string
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
defer srv.Close()
ps, _ := odPlugins(t)
// The plugin's config table is `prices`; SetState routes a `prices` key
// there rather than into accumulated state.
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{
"control_key": "vela_ctrl_TESTKEY",
"api_base": srv.URL + "/api/v1",
},
}); err != nil {
t.Fatalf("SetState: %v", err)
}
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
if rep == nil {
t.Fatal("no report produced")
}
if rep["vendor"] != "opendesign" {
t.Fatalf("wrong adapter answered: vendor=%v (report=%v)", rep["vendor"], rep)
}
for _, got := range seenAuth {
if strings.Contains(got, "runtime-key") {
t.Fatalf("the source's api_key was sent to the billing endpoint (%q); "+
"amr-api rejects it with 401 — the control key is a separate credential", got)
}
if got != "Bearer vela_ctrl_TESTKEY" {
t.Fatalf("billing request carried %q, want the configured control key", got)
}
}
if len(seenAuth) == 0 {
t.Fatal("no request reached the server: the adapter never probed")
}
}
// TestQuotaOpenDesignSkipsWithoutControlKey pins the failure mode that made the
// previous incarnation of this probe useless: with no credential the adapter
// must SKIP (not error, not report a zero balance). An error would put the
// source in the negative memo and file it under "probe failed"; a zero balance
// would tell the operator the account is empty.
//
// This test originally left api_base unset, which sent the probe to the REAL
// amr-api host. Every path then failed on the network and returned nil, so the
// test passed for the wrong reason — and a mutation that deleted the credential
// guard survived. Pointing api_base at a local stub makes the server reachable,
// so the only thing that can stop the request is the guard itself.
func TestQuotaOpenDesignSkipsWithoutControlKey(t *testing.T) {
var seenAuth []string
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
defer srv.Close()
ps, _ := odPlugins(t)
// api_base set, control_key deliberately NOT set.
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{"api_base": srv.URL + "/api/v1"},
}); err != nil {
t.Fatal(err)
}
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
if rep == nil {
t.Fatal("no report produced")
}
// The guard must come BEFORE the network. A reachable server that is never
// asked anything is the only evidence that the credential check ran at all.
if len(seenAuth) != 0 {
t.Fatalf("the adapter probed the billing host with no control key configured "+
"(Authorization: %q); an empty bearer is not a credential and the "+
"response cannot be trusted", seenAuth)
}
if rep["skipped"] != true {
t.Fatalf("skipped = %#v, want true", rep["skipped"])
}
reason, _ := rep["reason"].(string)
if !strings.Contains(reason, "control_key") {
t.Fatalf("reason = %q, want it to name the missing control_key so the "+
"operator knows what to go and configure", reason)
}
// Whatever adapter answers, it must not be a fabricated opendesign balance.
if rep["vendor"] == "opendesign" {
t.Fatalf("opendesign reported a balance with no control key configured: %v", rep)
}
}
// TestQuotaOpenDesignReportsBalanceAndSpend checks the fields the operator
// actually reads. The vendor sends these as JSON STRINGS ("15.7062"), so a
// naive type assertion to number would drop every one of them.
func TestQuotaOpenDesignReportsBalanceAndSpend(t *testing.T) {
srv := odServer(t, nil, odWallet, odSummary, 200, 200)
defer srv.Close()
ps, _ := odPlugins(t)
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{
"control_key": "vela_ctrl_TESTKEY",
"api_base": srv.URL + "/api/v1",
},
}); err != nil {
t.Fatal(err)
}
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
if rep == nil || rep["vendor"] != "opendesign" {
t.Fatalf("expected an opendesign report, got %v", rep)
}
want := map[string]float64{
"balance": 15.7062,
"subscription_usd": 15.2062,
"recharge_usd": 0.5000,
"today_used": 0.0036,
"period_used": 4.7938,
}
for k, exp := range want {
got, ok := rep[k].(float64)
if !ok {
t.Fatalf("%s missing or not a number: %#v (full report %v)", k, rep[k], rep)
}
if got != exp {
t.Fatalf("%s = %v, want %v", k, got, exp)
}
}
if rep["credits_per_usd"] != float64(10000) {
t.Fatalf("credits_per_usd = %v, want 10000 (used for the credit split)", rep["credits_per_usd"])
}
if rep["tier"] != "plus" || rep["status"] != "active" {
t.Fatalf("tier/status = %v/%v, want plus/active", rep["tier"], rep["status"])
}
if rep["balance_expires_at"] != "2026-10-08T23:01:06.662Z" {
t.Fatalf("balance_expires_at = %v — a balance that expires silently is "+
"worse than one that warns", rep["balance_expires_at"])
}
// usageCount arrives as a JSON number, not a string, and must survive too.
if rep["usage_count"] != float64(2982) {
t.Fatalf("usage_count = %#v, want 2982", rep["usage_count"])
}
}
// TestQuotaOpenDesignHasNoFabricatedPeriodWindow pins the deliberately-absent
// field. `vela billing workspace-snapshot` answers billingState:"free" with NO
// codingPlan.windows for this account, so there is no period allowance to show.
//
// Mutation: add `rep.windows = {{...}}` (or any synthesized window) and this
// fails. The page renders windows as "X% remaining", so a fabricated one is a
// confident lie about the account.
func TestQuotaOpenDesignHasNoFabricatedPeriodWindow(t *testing.T) {
srv := odServer(t, nil, odWallet, odSummary, 200, 200)
defer srv.Close()
ps, _ := odPlugins(t)
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{
"control_key": "vela_ctrl_TESTKEY",
"api_base": srv.URL + "/api/v1",
},
}); err != nil {
t.Fatal(err)
}
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
if rep == nil || rep["vendor"] != "opendesign" {
t.Fatalf("expected an opendesign report, got %v", rep)
}
if w, ok := rep["windows"]; ok && w != nil {
t.Fatalf("windows = %#v, but this account has no Coding Plan "+
"(billingState=free) — a synthesized window renders as a "+
"percentage remaining that does not exist", w)
}
}
// TestQuotaOpenDesignRejectedControlKeyIsAnError guards the diagnosis. A 401
// means the credential is wrong — a fact the operator must fix — so it must not
// be filed under "skip" alongside "this vendor has no balance endpoint". The two
// produce the same blank card but wildly different next actions.
//
// Two details make the test actually exercise the rule instead of passing by
// accident, and both were found by a mutation surviving:
//
// 1. The memo is pre-seeded so openDesign runs FIRST. The memo remembers which
// vendor answered last and that vendor is tried first; once a host has ever
// answered through openDesign it runs first forever after. With the default
// order (openDesign last) its 401 IS the last verdict, so even a plain
// "last error wins" rule passes.
// 2. The other three adapters must ANSWER (404), not fail. They read `base`, not
// `api_base`, so the source base_url points at the same stub — otherwise they
// all error out on an unreachable host and the final verdict is an error
// regardless of the rule being tested.
func TestQuotaOpenDesignRejectedControlKeyIsAnError(t *testing.T) {
srv := odServer(t, nil, `{"error":"unauthenticated"}`, `{"error":"unauthenticated"}`,
http.StatusUnauthorized, http.StatusUnauthorized)
defer srv.Close()
ps, _ := odPlugins(t)
// Host key must match what hostOf(base) yields for the source below.
src := map[string]interface{}{
"name": "vela", "base_url": srv.URL, "api_key": "k",
}
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{
"control_key": "vela_ctrl_WRONG",
"api_base": srv.URL + "/api/v1",
},
// Flat, NOT nested under a "state" key: SetState pulls `prices` out and
// stores the REST of the payload as the plugin's state. Nesting it would
// store {state: {probe_memo: ...}} and the memo would never be read.
"probe_memo": map[string]interface{}{
hostOnly(srv.URL): "opendesign",
},
}); err != nil {
t.Fatal(err)
}
out, err := ps.Call("quota", "collect_source", map[string]interface{}{
"name": "vela", "sources": []interface{}{src},
})
if err != nil {
t.Fatal(err)
}
rep, _ := out.(map[string]interface{})
if rep == nil {
t.Fatal("no report produced")
}
if rep["vendor"] == "opendesign" {
t.Fatalf("a rejected control key must not produce a balance: %v", rep)
}
// The plugin reports a credential failure as an error, which the page renders
// as a red "读取失败" row. Filing it as a skip instead shows the same blank
// card as "this vendor has no balance endpoint" — and sends the operator to
// investigate the vendor rather than re-run the login.
if rep["skipped"] == true {
t.Fatalf("a rejected control key was reported as a SKIP: %v — the other three "+
"adapters answered 404 for this host, so the final verdict is a skip unless "+
"a rejected credential outranks it. The operator would go looking at the "+
"vendor instead of re-running the login.", rep)
}
msg, _ := rep["error"].(string)
if !strings.Contains(msg, "rejected") {
t.Fatalf("error = %q, want it to name the rejected credential so the "+
"operator knows to re-run the login rather than to investigate the vendor", msg)
}
}
// TestQuotaOpenDesignDoesNotDefaultMissingRate pins the "do not invent an
// exchange rate" rule. creditsPerUsd is the only thing that converts the credit
// breakdown into dollars; when it is absent the vendor's own *Usd fields still
// give a correct balance, so the card must render with the split missing and
// FLAG it, not divide by a remembered 10000.
func TestQuotaOpenDesignDoesNotDefaultMissingRate(t *testing.T) {
// Same summary with creditsPerUsd removed.
noRate := strings.Replace(odSummary, `"creditsPerUsd":10000,`, "", 1)
if strings.Contains(noRate, "creditsPerUsd") {
t.Fatal("fixture still carries creditsPerUsd")
}
srv := odServer(t, nil, odWallet, noRate, 200, 200)
defer srv.Close()
ps, _ := odPlugins(t)
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{
"control_key": "vela_ctrl_TESTKEY",
"api_base": srv.URL + "/api/v1",
},
}); err != nil {
t.Fatal(err)
}
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
if rep == nil || rep["vendor"] != "opendesign" {
t.Fatalf("expected an opendesign report, got %v", rep)
}
// The balance is authoritative and comes straight from the vendor.
if rep["balance"] != 15.7062 {
t.Fatalf("balance = %#v, want 15.7062 (it must not depend on the rate)", rep["balance"])
}
if rep["credits_rate_missing"] != true {
t.Fatalf("credits_rate_missing = %#v, want true: the page must show that "+
"the credit split is unavailable rather than a silent zero", rep["credits_rate_missing"])
}
if v, ok := rep["credits_per_usd"]; ok && v != nil {
t.Fatalf("credits_per_usd = %#v with no rate in the response — a defaulted "+
"rate produces confidently wrong dollar amounts", v)
}
// subscription_usd/recharge_usd come from the vendor's own USD fields, so
// they are still correct and must still be present.
if rep["subscription_usd"] != 15.2062 {
t.Fatalf("subscription_usd = %#v, want 15.2062 from the vendor's own field",
rep["subscription_usd"])
}
}
// TestQuotaOpenDesignHostRewritingIsUsed keeps the amr-link -> amr-api default
// working. Without it the adapter probes the inference host, which serves no
// billing route at all, and every OpenDesign source silently skips.
//
// Asserted on the exported derivation rather than on the source text or on a
// failed probe's error message. The source-text version pinned the SPELLING of
// the rule: it went red the moment the rule was tightened from an `amr-link.`
// prefix to a full hostname, while the behaviour was unchanged. The
// error-message version inferred "which host did we ask?" from an allowlist
// refusal, which stops working the moment any adapter earlier in the chain also
// fails — and one of them always does.
func TestQuotaOpenDesignHostRewritingIsUsed(t *testing.T) {
ps, _ := odPlugins(t)
cases := []struct{ in, want string }{
// The real production shape: base_url carries a path.
{"https://amr-link.open-design.ai/v1", "https://amr-api.open-design.ai/api/v1"},
// And without one, so the derivation cannot depend on the path being there.
{"https://amr-link.open-design.ai", "https://amr-api.open-design.ai/api/v1"},
// A trailing slash must not produce a double slash.
{"https://amr-link.open-design.ai/v1/", "https://amr-api.open-design.ai/api/v1"},
}
for _, c := range cases {
out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{
"base_url": c.in,
})
if err != nil {
t.Fatalf("resolve_api_base(%q): %v", c.in, err)
}
rep, _ := out.(map[string]interface{})
if got, _ := rep["api_base"].(string); got != c.want {
t.Errorf("resolve_api_base(%q) = %q, want %q — the probe would go to the "+
"inference host, which serves no billing route, and the source would "+
"silently stay unlisted", c.in, got, c.want)
}
}
// A lookalike host must NOT be rewritten into an OpenDesign address: an
// `amr-link.` prefix rule would turn amr-link.example.com into amr-api...,
// sending an unrelated provider's probe to a host that is not its own.
out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{
"base_url": "https://amr-link.example.com/v1",
})
if err != nil {
t.Fatal(err)
}
rep, _ := out.(map[string]interface{})
if got, _ := rep["api_base"].(string); got != "https://amr-link.example.com/api/v1" {
t.Errorf("a lookalike host was rewritten to %q", got)
}
// An explicit override wins (and is trimmed), so an operator can point at a
// moved host without waiting for a code change.
out, err = ps.Call("quota", "resolve_api_base", map[string]interface{}{
"base_url": "https://amr-link.open-design.ai/v1",
"api_base": "https://amr-api.example.net/api/v1/",
})
if err != nil {
t.Fatal(err)
}
rep, _ = out.(map[string]interface{})
if got, _ := rep["api_base"].(string); got != "https://amr-api.example.net/api/v1" {
t.Errorf("api_base override not honoured (or not trimmed): %q", got)
}
}
// TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost is the end-to-end half of
// the unready rule, and it reproduces two bugs seen on the live plugin.
//
// (1) STALE READ. The memo is applied at the top of collectSource and the table
// it comes from is only updated by the WRITE side, which runs at the very end.
// Keying the backoff on `memo_entry_exists` therefore asks a question that is
// true on everything except the first probe of the oldest entry, and the plugin
// describes it to the operator as "连续探测失败 1 次" — an invented streak. Live
// trace: unset key → memo → key set → probes refused, memo untouched, forever.
// Backing off must key on the recorded STREAK, not on the entry existing.
//
// (2) EMPTY `unready` SET. Lua drops empty tables, so an answer from every
// adapter writes a negative with no `unready` field — indistinguishable, by
// truthiness, from a provisional one whose set happens to be empty. Reading it as
// provisional made an ordinary streak into a non-backing-off entry, which is the
// other half of the same live symptom.
//
// It is NOT fixable by "do not memoise a skip": on a real OpenDesign host the
// other three adapters answer 404 ("no dashboard here"), which is durable and
// worth remembering, so the verdict would be recorded either way. The memo has to
// carry WHICH vendors were unable to judge the host, and the read side has to
// retire that record once they can.
func TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost(t *testing.T) {
var seenAuth []string
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
defer srv.Close()
ps, _ := odPlugins(t)
args := map[string]interface{}{
"name": "vela",
"sources": []interface{}{map[string]interface{}{
"name": "vela", "base_url": srv.URL + "/v1", "api_key": "k",
}},
}
// Step 1: no control_key. openDesign cannot judge the host, and no billing
// request goes anywhere.
out, err := ps.Call("quota", "collect_source", args)
if err != nil {
t.Fatal(err)
}
rep, _ := out.(map[string]interface{})
if rep["skipped"] != true {
t.Fatalf("expected a skip with no control key configured: %v", rep)
}
if len(seenAuth) != 0 {
t.Fatalf("an unconfigured adapter made a request: %q", seenAuth)
}
// Step 2: configure the key. The very next probe must reach the upstream. It
// is inside the backoff window (minutes of wall clock) while this runs in
// microseconds — so a host refused now is refused for the whole window in
// production, which is the bug, not a timing accident.
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{
"control_key": "vela_ctrl_TESTKEY",
"api_base": srv.URL + "/api/v1",
},
}); err != nil {
t.Fatal(err)
}
out, err = ps.Call("quota", "collect_source", args)
if err != nil {
t.Fatal(err)
}
rep, _ = out.(map[string]interface{})
if rep["vendor"] != "opendesign" || rep["skipped"] == true {
t.Fatalf("expected an opendesign balance once configured, got %v — the host "+
"is still suppressed by the provisional memo it earned while the adapter "+
"had no credential", rep)
}
if len(seenAuth) == 0 {
t.Fatal("no request reached the billing endpoint after the control key was " +
"supplied")
}
}
// TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost covers the second half of the
// same live symptom, and it needs its own server because the first half ends with a
// SUCCESS (which overwrites the memo).
//
// The read side backs a host off by looking up `HOST_MEMO[host]`, a table the
// WRITE side only fills in at the very end of the call. Two ways that goes wrong:
//
// - backing off on "an entry exists" instead of on its streak — the entry always
// exists, so the host is refused forever after its first negative
// - reading an `unready` set that is absent (Lua drops empty tables when every
// adapter answered) as if it were a provisional one, which DISABLES the backoff
// for exactly the hosts that most need it
//
// Both are about the shape of the entry rather than about the vendor, so they are
// asserted on the entry the probe actually left behind.
func TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost(t *testing.T) {
// Every billing route 404s: no vendor has a dashboard for this host, which is
// the durable negative the memo exists to remember.
srv := odServer(t, nil, `{}`, `{}`, 404, 404)
defer srv.Close()
ps, _ := odPlugins(t)
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{
"control_key": "vela_ctrl_TESTKEY",
"api_base": srv.URL + "/api/v1",
},
}); err != nil {
t.Fatal(err)
}
args := map[string]interface{}{
"name": "vela",
"sources": []interface{}{map[string]interface{}{
"name": "vela", "base_url": srv.URL + "/v1", "api_key": "k",
}},
}
for i := 0; i < 2; i++ {
if _, err := ps.Call("quota", "collect_source", args); err != nil {
t.Fatalf("probe %d: %v", i+1, err)
}
}
st, _ := ps.State("quota").(map[string]interface{})
memo, _ := st["probe_memo"].(map[string]interface{})
// The host key is derived from base_url, so find the single entry.
if len(memo) != 1 {
t.Fatalf("expected exactly one memo entry, got %v", memo)
}
var entry map[string]interface{}
for _, v := range memo {
entry, _ = v.(map[string]interface{})
}
if entry == nil {
t.Fatalf("the negative result was not recorded at all: %v", memo)
}
// The durable/provisional distinction is the entry's SHAPE. A first probe of a
// host no vendor can report on must produce a durable entry: with the empty
// `unready` set stamped on unconditionally, Lua's dropping of empty tables
// means the reader and the writer disagree about what an absent field means,
// and a host that most needs backing off is the one that never gets it.
if _, provisional := entry["unready"]; provisional {
t.Fatalf("every adapter judged this host, so the entry must not be "+
"provisional (an absent `unready` is what makes it durable): %v", entry)
}
}

View File

@ -0,0 +1,112 @@
package lua
import (
"strings"
"testing"
)
// TestUsagePluginShipsAndGuardsItsOwnKey covers the two things that make the
// usage plugin safe to ship, and both are easy to break by accident because they
// live in a Lua file nobody compiles.
//
// 1) SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists
// for deployments that receive it. A gateway build that does not embed it
// answers 404 and the frontend shows "后台未开放消费接口" — which reads as a
// bug rather than as a missing optional feature. Same reasoning billing.lua and
// quota.lua are embedded.
//
// 2) SELF-SCOPED. The handler reports the CALLER's usage, so it must look the
// caller up by the key in the request, never by a key from its own
// configuration. A plugin that read a configured key would report one
// tenant's spend to every tenant — the exact leak this plugin avoids by
// passing the request's key straight through.
func TestUsagePluginShipsAndGuardsItsOwnKey(t *testing.T) {
src := readShippedPlugin(t, "usage.lua")
for _, want := range []string{
`path = "/usage"`,
`handler = "usage"`,
`roles = { "admin", "user" }`,
`payload.key`,
`maskedKey`,
} {
if !strings.Contains(src, want) {
t.Errorf("usage.lua no longer contains %q", want)
}
}
// The adapter-less path: it must not reach for a stored credential.
for _, forbidden := range []string{
"PLUGIN.state",
"PLUGIN.prices",
"api_key",
"control_key",
} {
if strings.Contains(src, forbidden) {
t.Errorf("usage.lua reads %q — it must report only the calling key "+
"from the request, never a stored credential", forbidden)
}
}
}
// TestUsageMasksKeyLikeTheGateway pins the mask algorithm against the Go one.
//
// This is the subtle one. /api/stats keys its maps by keyID(req.Key) — the
// MASKED form ("***f9a9ed") — while the handler receives the FULL key. Passing
// the full key does not error, it silently returns zero, and the endpoint then
// reports "0 requests" for a key with hundreds of thousands of them. Measured
// against the live gateway before this was pinned:
//
// ?key=***f9a9ed → total.reqs = 33847
// ?key=sk-gw-7ffe…9ed → total.reqs = 0
//
// So the Lua mask must match internal/gateway/server.go's keyID exactly: keys of
// 6 characters or fewer are returned unchanged, longer ones become "***" plus the
// last 6.
func TestUsageMasksKeyLikeTheGateway(t *testing.T) {
cases := []struct{ in, want string }{
{"sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed", "***f9a9ed"},
{"sk-gw-7287aba64ef7ee665cf723222610a67d", "***10a67d"},
{"1234567", "***234567"},
// 6 or fewer: returned unchanged, on both sides.
{"short", "short"},
{"123456", "123456"},
}
src := readShippedPlugin(t, "usage.lua")
for _, c := range cases {
// The mask lives in Lua, so assert on its shape rather than run it: a
// behavioural test here would need a whole VM plus a stubbed stats
// endpoint, and would still not cover the real one.
if !strings.Contains(src, `return "***" .. string.sub(key, -6)`) {
t.Fatal("usage.lua no longer masks as \"***\" + last 6 characters")
}
if !strings.Contains(src, `if #key <= 6 then return key end`) {
t.Fatal("usage.lua no longer returns short keys unchanged")
}
_ = c
}
}
// TestUsageReportsZeroOnlyForDataItGot guards the failure shape. An endpoint that
// cannot reach its data source must say so: returning zeros would be rendered as
// "已用 0,0 次" and read as "this key has never been used", which is a different
// and wrong claim.
func TestUsageReportsZeroOnlyForDataItGot(t *testing.T) {
src := readShippedPlugin(t, "usage.lua")
if !strings.Contains(src, "usage unavailable") {
t.Error("usage.lua has no distinct failure message; a failed lookup must not " +
"be indistinguishable from zero usage")
}
if !strings.Contains(src, "if r == nil then") {
t.Error("usage.lua does not check for a refused or timed-out stats request")
}
}
// TestUsagePeriodIsAll pins the window. "All" answers "how much has this key
// used"; a day window would look like the number resets every morning, which is
// the more alarming misreading of the same digits.
func TestUsagePeriodIsAll(t *testing.T) {
if !strings.Contains(readShippedPlugin(t, "usage.lua"), `local PERIOD = "all"`) {
t.Error(`usage.lua no longer defaults to period "all"`)
}
}