Compare commits

...

41 Commits

Author SHA1 Message Date
37545b6dbb chore(gui): 版本号 1.9.3 → 1.9.4 2026-10-04 14:38:51 +08:00
314f4da8f2 feat(billing): 长上下文阶梯定价(边际费率,与缓存折扣/峰段可叠加)
## 影响面实测

审计里 1000 条请求:**883 条(88.3%)prompt 超过 128k**,中位数 246849,
p90 298179,最大 354251。这不是小众场景,是 agent 负载的主流形态。

按「模型 × 源」拆开:其中真正按 token 计价的只有 alittokenplan 的 10 次
(deepseek-v4.1-flash,约 350k prompt),其余走 $70 套餐(commandcode)或
免费源(localzen),记 0 是正确的,对阶梯无影响。

那 10 次当前少算 54.9%($0.3148 → $0.4877)。绝对值不大,但比例严重,
而且这个偏差只会随上下文变长而变大。

## 边际而非混合平均

阶梯只对**超过阈值的部分**重新计价,不是把整段按高价算。理由是可叠加性:
缓存读取同时享受缓存折扣**和**长上下文阶梯价,这与供应商的计费方式一致。
混合平均必须二选一,选错就正好低估「缓存命中的长请求」——而这恰是缓存
存在的意义所在(本部署缓存命中率 88%)。

超出部分按缓存/新鲜的比例拆分,不重不漏。

## DSL 两种写法

供应商两种发布方式都支持:绝对费率(OpenAI 报 \$3.00/M past 200k)和
倍数(DeepSeek 报 2x past 128k)。两者都缺必须报错 —— 一个没有费率的阶梯
会编译成一条「看起来处理了长上下文、实际没处理」的规则,静默少算且无迹可寻。
两者都给时以绝对费率为准,因为那是供应商真正报出的数字。

## 判据

Lua 侧两条(边际性、与缓存折扣叠加)+ Go 侧一条(两种写法与拒绝规则)。
变异自证三次:整段按高价 / 缓存部分不叠加折扣 / 缓存部分不计阶梯,
分别命中 0.40 vs 0.272、0.165 vs 0.2325、0.09 vs 0.2325。

规则编辑器加了三个输入(阈值 + 绝对费率 + 倍数),三项都留空 = 无阶梯 ——
与「阶梯费率为 0」刻意区分,所以是三个独立输入而不是从 0 推断是否存在阶梯。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 14:36:38 +08:00
b93799a919 feat(billing): reasoning 价接入 DSL 与规则编辑器
上一提交把链路打通后,reasoning_price 只能靠手改 prices 生效 —— 而 prices 是
编译产物,不是人能编辑的东西。这条补上让它真正可用:

- BillingToken 加 reasoning 字段(USD/百万,与 prompt/completion 同单位)
- compile 只在规则显式声明时写 reasoning_price,缺省时字段不出现
- 规则编辑器的模型行加「思考 $/M」输入框,含新增行的模板

缺省时「不出现」是刻意的:写 0 会被 billing 插件读成「思考免费」,
每个来自该模型的请求都会静默少算。判据专门钉住这一点,变异成「缺省写 0」
即变红。

输入框 placeholder 写明「留空=按输出价计(OpenAI/DeepSeek/Anthropic 都这样)」,
因为一个没有解释的空输入框会被当成「必填但我不知道填什么」。

判据还要求 m-r 出现两次 —— 只改已有行的模板而不改「+ 模型」新建的行,
会让新加的模型在下一次保存时静默丢掉这个价。

生产验证:PUT 一个 reasoning: "1.80" 的规则,编译产物里出现
reasoning_price: 1.8000000000000001e-06(USD/token)。测完已把生产配置恢复为
6 条规则(备份 config.yaml.bak-billing-20261004-122151 为基准重建,
20 个源数量核对一致)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 13:15:02 +08:00
c36200b78b feat(billing): 记录 reasoning token,并支持按规则单独定价(默认不启用)
## 断掉的链路

types.go 里 ReasoningTokens 从被定义起就没人读过。适配器层早就解析并透传了
(opencodego / gemini / anthropic 都写了那段提取),网关在 chat.go 记录 usage
时读了 PromptTokensDetails.CachedTokens,却在隔壁的
CompletionTokensDetails.ReasoningTokens 上把它丢了 —— 数字到了响应里,
在唯一能留住它的地方被扔掉。

后果:任何计费规则都无法区分「思考」和「可见输出」。

## 为什么默认不启用定价

实测四个源(deepseek-v4.1-flash / claude-opus-4-8 / glm-5.3-flash /
space-bunny-free,含 anthropic thinking opt-in)全部不返回该字段。所以
reasoning_price 默认为 nil,costFor 完全跳过该分支,标准公式与改动前逐位相同
—— 这是关键:现有账单不能因为这次改动而变动。

启用时只应用「差额」:把思考部分从普通输出价重定价到思考价。完整再收一次会
对每个思考 token 双重计费,这正是判据里最容易被写出的错误实现。

clamp 到 completion_tokens:供应商报出比 completion 还大的 reasoning 是畸形
数据,不 clamp 会得到 92 倍于整个请求的费用,而且这个数字看起来足够合理,
能在仪表盘上活下来。

判据三条 + 三次变异自证(移除 clamp / 双重计费 / 默认启用)全部变红。

## 遗留

reasoning_price 未接入 BillingRule DSL —— 目前只能通过直接写 prices 生效。
DSL 字段要连带改 config 结构、编译器和规则编辑器 UI,是独立一步。
实测全部源都不返回该字段,所以这条暂时没有实际影响面。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 13:04:06 +08:00
ba6de89d57 fix(billing): 未定价按天统计,让「已修好」和「还在漏」不再长得一样
计费页只有一个终身计数器 unpriced_reqs,实测 6378 —— 占 18981 请求的 34%。
但这个数字回答不了运维唯一会问的问题:「还在漏吗?」

原因是它把两件事混在一起:加规则之前的历史欠账,和加规则之后仍在发生的漏计。
修好一个源之后终身计数纹丝不动,于是仪表盘上「未定价 6378」和一个仍在
什么都没记的系统完全无法区分。

改动:
- 钩子按 UTC 天写入 unpriced_by_day{day:{reqs, models}}。终身计数保留
  (报表不该改写历史),天桶是新增的观察维度。
- 页面加「未定价(近 7 天)」一行。为 0 时额外显示「近 7 天无」——这时候
  零本身就是结论:加的规则确实生效了。
- 没有天桶数据时返回 null 而不是 0。旧的 state 文件说「未知」,
  不该假装有一份干净记录。

生产实测:全部视图 终身 6378 / 近 7 天 2;日视图隐藏终身计数(null)但保留
近 7 天 —— 周期视图原本把两者都置 null,因为那时根本没有天桶。

失败的请求同样计入(上游 502 的 gpt-5.5 也进了天桶):一次失败的请求照样
消耗上游配额,只是没拿到 token,单看 token 维度会漏掉。

判据:两个不同日期的请求必须落到两个天桶且终身计数不重复累加;变异成
「总记今天」即变红(只判首日会漏掉这一点)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 12:34:51 +08:00
3b28fde22e feat(plugins): 访问控制在插件页配置 + 修复 active 仪表在周期视图显示 0
## 插件管理页(用户要求:权限不要只放 config.yaml)

插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改:
- 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人)
- 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public)

原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。

## 修掉的三个真缺陷

1. endpointAccess 读错了 override 的键
   SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。
   后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。
   鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。

2. override 只在内存里
   内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失,
   日志里没有任何线索。现在落盘到 plugin_dir/.access.json,
   插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件,
   "没有覆盖"用文件不存在来诚实表达。

3. 可见性列表泄漏内部键
   端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello"
   并可能把它当 audience 存回去。现在按已注册端点集合过滤。

另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的
集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会
显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。

## 活跃请求:三个视图显示 0

active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。
前端 `st.active || 0` 于是把"字段缺失"变成了一句自信的"0 活跃",
而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。
现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。

判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。
生产实测 day/week/month/all 均为 4。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 120ba9e957)
2026-10-04 12:11:23 +08:00
9823773330 fix(plugins): quota 页面加载时不再自动探测全部上游
首屏 19.1 秒 → 1.0 秒(生产实测)。

症状是"页面出来了但数据卡在加载"。量出来的原因不是渲染,也不是后端:
所有接口都在 0.4–2ms,而 Network 事件显示页面在头 70 毫秒内并发发出了 21 个
/api/plugins/quota/call/collect_source —— 每个源一次。CPU 采样 98% 空闲,说明
主线程不是在算,是在等。

插件页面的脚本里有 mount(); refresh();,而 refresh 会为每个源发一次探测。
探测要出网,且每次调用都持插件写锁,于是 21 次串行排队;冷启动实测 100 秒,
有缓存后 22 秒。WebUI 的首屏就这么被堵住了。

改动:
- 页面加载时不再自动探测。运维没要求的余额不值得用它换首屏。
- 改为先渲染上次的快照(pluginAPI.fetchState),标题栏标注"快照 HH:MM(点刷新
  取实时)"。点刷新才真探测。这样打开页面立刻有内容,实时数字在后面更新。
- billing 页面同样有 refresh(),但它只读本机 state(1.5ms),无害,保留。

判据:首屏填充时间实测 19.1s → 1.0s,且首屏请求里不再出现 collect_source。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit c17a851ec1)
2026-10-04 12:11:23 +08:00
5eef5ad635 feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。

UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。

覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。

过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。

角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。

不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。

有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。

插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。

roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。

**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。

写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。

readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。

readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。

去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。

生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
2026-10-04 12:11:23 +08:00
40636abcc2 feat(plugins): quota 通用套餐余量插件(取代 ccquota)
ccquota asked one vendor one question. The shape that generalizes is two parts:
a small set of probe adapters, and a declarative override for a provider nobody
anticipated.

Three adapters, each verified against a LIVE endpoint rather than assumed:

  - deepseek family: GET /user/balance →
    {is_available, balance_infos:[{currency,total,granted,topped_up}]}.
    Granted vs topped-up is kept separate on purpose: gift credit expiring on a
    schedule is a different operational fact from prepaid money.
  - OpenAI console shape: /v1/dashboard/billing/subscription +
    .../billing/usage. This reports LIMITS and SPEND, not a balance — the live
    provider has hard_limit_usd 1e8. There is deliberately no `remaining`:
    ceiling - spend would read as "$99,999,339 left", a number nobody should
    believe. It renders as spend against a ceiling instead.
  - commandcode: rolling 5h / weekly / monthly windows plus billing period.

A provider with no working adapter is SKIPPED and listed by name at the bottom.
Most of these upstreams are small re-sells whose dashboard paths return an HTML
page rather than JSON; a table full of "unknown" rows teaches the operator to
ignore the page entirely. Failures that are NOT "no endpoint" — a 403 on a key we
hold — stay visible, because those are the ones worth fixing.

## Cost control, and a lock the probe runs under

First load measured 101s for 21 sources: 3 adapters x 2 attempts x 4s, times 21.
Call() holds the plugin's write lock for the whole invocation, so every
__llmsproxy_http round trip inside it blocks request_end hooks — which is how
billing accrues money. A slow upstream is not just a slow card.

  - Timeout cut to 4s (from 10s).
  - A host->adapter memo, persisted in plugin.state, so the dead ends are
    discovered once per process instead of on every page open. Steady state is
    22s.
  - The memo records NEGATIVE results too, which is what actually paid: only
    recording successes left every silent source re-probing forever.
  - Negatives carry a timestamp and expire after 30 minutes. "This upstream has
    no balance endpoint" is a fact that changes — vendors ship dashboards — and a
    permanent negative makes the page quietly stop reporting a source that
    started working.
  - The page calls collect_source concurrently, one call per source, so a single
    502 over 21 sequential probes becomes 21 independent answers.

## Two bugs the page found

- deepseek reports topped_up_balance -0.02 against a positive grant; the `v <= 0
  → "0.00"` branch flattened a real debit to zero. Negative balances now render
  as -¥0.02.
- PLAN_CAPS was declared below the adapter that reads it, so Command Code hit
  "attempt to index global 'PLAN_CAPS' (a nil value)" on every probe. A Lua local
  is nil until its assignment runs.

Verified on production: 5 sources report, 16 skipped by name, 1 visible failure
(tabitoken 403). deepseek ¥0.32 (赠送 0.34 · 充值 -0.02), justwoker $360,865.71
spent against an ∞ ceiling, both Command Code seats with their three windows.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 098a4a5eb4)
2026-10-04 12:11:18 +08:00
22c9215b02 feat(plugins): ccquota 多账户额度插件 + 页面→Lua 调用通道
A quota plugin cannot work on billing's model. Billing reports what the gateway
already measured, delivered on hooks during request forwarding. A plan balance
has no such arrival path: it exists only on the vendor's side, and the only way
to learn it is to ask — at the moment the operator clicks Refresh. No hook fires
on a click, and pluginAPI offered only state read/write.

So this adds the missing piece: POST /api/plugins/{name}/call/{fn}, plus
pluginAPI.call on the page side.

## Why the gate is an explicit export list

That endpoint is an HTTP surface into a Lua state. Without a declared allowlist
it would let any caller invoke the plugin's private helpers with crafted
arguments and read whatever it can see — for a plugin holding an API key, that
is handing the key to anyone who can POST. So a function is callable only if the
plugin listed it in plugin.exports. A plugin with no exports has no callable
surface; reading the missing list as "allow all" is exactly the failure mode
worth designing against. The endpoint is also admin-only.

## ccquota

One account per configured source on api.commandcode.ai, keyed by that source's
own key — two keys on one host are two seats, and both are shown. Reads
/alpha/whoami, /alpha/billing/credits, /alpha/billing/subscriptions and
/alpha/usage/summary. Each endpoint degrades independently: a vendor retiring
one route must not blank the others, and a key out of quota still reads credits
fine while inference 401s, so the report records WHICH part failed rather than
rendering a zero that reads as "no quota left".

The 401-is-everything trap: those endpoints need a bearer token plus three
headers. The token lives in the table now, per request. Passing the shared
header table straight through — my first version — yields 401 on every
endpoint, which is indistinguishable from a revoked key and sent me auditing the
account instead of the plugin.

## Also fixed: three real defects found by going live

- The bridge was registered as `httpGlobal` (the Go const's IDENTIFIER) instead
  of `__llmsproxy_http` (its VALUE). Green unit tests, "attempt to call global
  '__llmsproxy_http' (a nil value)" on first real request. Now a test boots a
  real plugin and calls it the way a plugin does.
- The call router kept `call/` in the function name, so every call 502'd with
  `does not export "call/collect_account"` while the exports list looked fine.
- readPluginExports used luaToJSON, which needs the `json` global — not yet
  installed while a plugin is registering — so it silently returned an empty
  list and every export looked undeclared. It uses luaTableToGoValue now.

Two tests hardcoded counts ("1 bundled plugin", "4 pages") and turned red the
moment a second bundled plugin existed. They now assert what they mean: billing
is among the bundled plugins, and the pages this test contributed all merged in
order. A count assertion is a changelog detector.

## Verified on production

  commandcode    2198972886@qq.com  active  $0.0811  5h 0/14  wk 0.803/35  44,921 req
  commmandcode2  jianf3112@gmail.com active  $0.0700  5h 0/14  wk 0/35      43,827 req

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 111dc6d53f)
2026-10-04 12:11:18 +08:00
e76e1623e4 feat(plugins): 给 Lua 插件加受限出站 HTTP(__llmsproxy_http)
billing 插件从不主动出站——它报告的一切都随网关转发请求时的 hook payload
一起到达。额度插件是第一个必须自己发请求的东西:套餐余量不主动问就拿不到。

问题在于这恰好是插件模型最锋利的边。插件是 WebUI 文本框里装进来的单个
.lua 文件,跑在网关进程里:给它一个通用 HTTP 客户端,等于让每次安装都成为
一件常备的 SSFP 工具,还顺带打开了读环境变量拿凭据的第二扇门。

所以这个桥刻意做窄:

- 目标必须命中某个「已配置源」已经在通信的主机。那些是运维声明过的,
  不是插件声明的——插件无法靠编一个 URL 扩大自己的触及面,而源的主机
  正是本部署已经愿意交付 API key 的那批上游。
- 非 loopback 主机上拒绝明文 http。凭据只该走 HTTPS。
- loopback 免登记:它出不了本机,而 billing 规则页读网关自己的 API 正是
  这么做的。
- 每请求都有超时、响应体有 4MiB 上限,插件无法占死 worker 或吃光内存。
- 绝不从环境变量读凭据。需要 key 的插件必须被显式给(state 或调用方配置),
  这样授权对运维可见,而不是环境里默默存在的东西。
- 不跟随重定向:白名单是逐跳检查的,跟随就等于绕过它,3xx 原样返回。

能力只装在插件运行时(bootPluginState),不装进 setupGlobals——后者被
adapter 池共用,adapter 是运维的配置而非安装的代码。

判据 8 条钉的是拒绝路径而非快乐路径:未登记主机(含 169.254.169.254、
后缀欺骗 api.example.com.evil.test)、明文 http、空名单必须全拒、不得跟随
重定向、超时与响应体被钳制、超大响应宁可报错也不截断(截断会解析成损坏
JSON,插件会报解析错而不是真因)。

测试当场抓到一个真 bug:loopback 被白名单一起拦了,与代码注释里「loopback
免登记」自相矛盾。变异(把主机检查换成 if false)被 3 条判据抓住。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 0fbb8e5e88)
2026-10-04 12:11:18 +08:00
0bf0653d00 fix(ui): 优先级页的保存/重置/添加按钮每次打开都被画布渲染冲掉
renderSortEditor 会整体替换 mount 的 innerHTML。per-key AUTO 弹窗的 mount
只有画布,覆写无害;但全局优先级页把 .card(连同里面装着工具栏的 h2)当
mount 传了进去,于是每次打开页面,画布一渲染就把工具栏删了。

症状很隐蔽:泳道和积木照常渲染、拖拽也正常,只是「保存排序」「重置」
「+ 添加档位」和聊天/生图切换全部消失 —— 优先级变得无从调整,也无从保存。
DOM 里数画布元素一切正常,只有量按钮的几何尺寸才看得见它没了。

修法:工具栏留在 .card 里,另给一个 #sort-mount 专供画布。per-key 弹窗
本来就是这个结构,无需改动。

判据 1 条,断言工具栏必须落在 #sort-mount 之外,且 renderSortEditor 必须
接收 #sort-mount 而不是 .card。变异(mount 改回 .card)判红。

生产 CDP 实测(真实点击,非仅 DOM 断言):
  5 个按钮全部可见,坐标尺寸正常(保存排序 72x29)
  画布 4 泳道 / 10 块
  点击「保存排序」→ toast「排序已保存并热重载 · 链上冷却已复位」
  切走再回来,按钮仍在位

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 07:05:36 +08:00
48e7e6b2a5 fix(gateway): AUTO 范围冲突的提示要放在 checkQuota,否则是死代码
上一版把消息加在请求路径的范围检查上,实测才发现它永远走不到:
AUTO 请求先经过 checkQuota(ctx, "AUTO")(在链被读取之前),范围不含 AUTO
时它当场返回旧文案「model \"AUTO\" is not allowed for this key」——那句会
把运维引去查配额,而配额并不是原因。

改为在 checkQuota 内对 AUTO 分流,输出说明真实原因与修法。请求路径那处
换成注释说明为何不再重复。

判据 1 条,断言 checkQuota 函数体内必须同时出现 isAuto(model) 与新文案
——放在别处不算数。变异(删掉该分支)导致编译失败,被捕获。

生产实测(临时把某密钥范围改成不含 AUTO 再恢复):
  HTTP 403  model_not_allowed
  model \"AUTO\" is not in this key's model scope, so it cannot use AUTO;
  add \"AUTO\" to the key's models or remove the scope restriction

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 23:06:42 +08:00
a21d0ca5a1 chore(gui): 版本号 1.9.2 → 1.9.3
v1.9.3 收一处误导性错误与编辑器提示:
- AUTO 被密钥模型范围拦下时返回 model_not_found(像说 AUTO 没配置),
  改为 model_not_allowed 并说明修法
- per-key AUTO 链编辑器在该密钥范围不含 AUTO 时给出警告

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 21:50:20 +08:00
cf14f66ad4 fix(gateway): AUTO 被密钥模型范围拦下时报错误导 + 编辑器给出提示
一个密钥的模型范围不含 AUTO 时,它用不了 AUTO —— 范围过滤发生在链之前。
但这两条路径都不说真话:

1. 请求侧:AUTO 走的是通用分支,返回 model_not_found "model \"AUTO\" is
   not configured",读起来像 AUTO 没配置。非 AUTO 模型早就有 model_not_allowed
   的专门提示,AUTO 漏了。补上,并说明修法(把 AUTO 加进该密钥的 models,
   或去掉范围限制)。

2. 编辑器侧:per-key AUTO 链编辑器可以正常配链、保存也成功,看起来一切正常,
   但该密钥的每个请求都会 403。管理员无从得知。现在弹窗顶部在检测到冲突时
   显示警告,并列出当前范围。

刻意不做的事:不自动把 AUTO 加进该密钥的模型范围。那等于悄悄授予运营
没要求的访问权,比一个显眼的警告更糟。

判据 1 条,除确认提示出现外还断言该函数体内没有 PUT/POST/fetch/api ——
它只报告,不得写回。变异(去掉 AUTO 判断)判红。

CDP 实测四种场景:范围含 AUTO → 无提示;范围不含 → 警告并列出范围;
空范围(不受限)→ 无提示;范围含 AUTO → 无提示。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 21:23:02 +08:00
4e3b905b58 chore(gui): 版本号 1.9.1 → 1.9.2
v1.9.2 收两处 UI 修复:
- 统计区间文案在日视图恒为空(bucket 是小时粒度,盲目拼 T00:00:00Z 使
  toISOString 抛 RangeError,整句赋值丢失),且天数按小时桶数误报
- per-key AUTO 链默认复制全局链作为编辑起点,而不是空白画布

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 13:36:11 +08:00
130b3f2e8d feat(ui): per-key AUTO 链默认复制全局链作为编辑起点
此前给没有独立链的密钥打开编辑器是空白画布,管理员要从 226 个模型里
一条条重新挑。真实任务是"这个用户拿全局链,但去掉他不该碰的两个源",
所以起点应该是全局链本身。

- 无独立链时读 /api/auto 的 rules 预填(实测 4 泳道 / 10 块,与全局链
  一致),不是 discovery 兜底(那会把每个源的每个模型都堆进来)。
- 提示区分三种状态,因为它们在画布上长得一样、点取消的后果完全不同:
  复制来的起点 / 该密钥已有的独立链 / 清空后保存将恢复跟随全局。
- seeded 标记随关闭清理,避免下次的提示说错来源。

CDP 实测:打开无独立链的密钥 → 画布 4 泳道 10 块 + 提示"已复制全局链
作为编辑起点";删一个块服务端仍 inherits:true;保存后 inherits:false
n=9、弹窗关闭;重开读回 4 泳道 9 块且提示消失(它现在有自己的链了)。
横向滚动可达性一并验证:scrollLeft 172 = scrollWidth-clientWidth,
滚到底后无残留不可达块。

判据 2 条 + 变异(恢复空白起点 / 去掉 seeded 标记均判红)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 13:36:11 +08:00
dac0b4ce93 fix(ui): 统计区间文案在日视图恒为空、天数按小时桶误报
v1.9.0 加的「统计区间:起 → 止 (Nd)」正是用来解释"周 > 月"的,但它自己
在日视图上从来不出现在屏幕上。

原因:区间终点从最后一个 bucket 的标签拼出来,而 bucket 粒度随视图变化
—— 日视图按小时("2026-10-03T09"),周/月按天("2026-10-01")。代码无条件
拼 "T00:00:00Z",日视图就成了 "2026-10-03T09T00:00:00Z",Date 拒绝该值,
toISOString() 抛 RangeError,整个赋值语句丢失。页面无任何报错,只是那行
字不见了——恰好是用户最常看的"今日"视图。

第二个问题同源:天数直接用了 st.buckets.length。日视图 5 个桶是 5 个小时,
界面却显示 "(5d)"。改为按窗口起止算天数。

CDP 实测(修复后):
  day   统计区间: 2026-10-03 → 2026-10-03 (1d)   ← 原为空,且曾误报 5d
  week  统计区间: 2026-09-28 → 2026-10-03 (6d)
  month 统计区间: 2026-10-01 → 2026-10-03 (3d)
  all  (空,正确:终身累计没有窗口)

判据一条 + 变异:恢复原来的盲目拼接 → 判红。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 13:36:11 +08:00
97864895d4 chore(gui): 版本号 1.9.0 → 1.9.1
v1.9.1 收 v1.9.0 之后发现并修复的四个 UI/统计缺陷:

- per-key 编辑器画布画进后台标签页的全局画布,弹窗空白且污染全局链
- per-key 保存成功却不关弹窗,toast 文案还把已保存说成会失败
- 首页日/周/月视图请求记录表恒为空,滚动加载新旧行错位
- per-key 弹窗复用共享 #modal-wrap id、编辑即落盘(取消撤不回)、
  无独立链时画布填满全部 226 个模型

v1.9.0 的 tag 与附件保持不动,作为发布时点的凭据。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:48:01 +08:00
f9cd0770a9 fix(ui): per-key 弹窗复用共享弹窗ID、编辑即落盘、空画布显示226个模型
截图看渲染 + 交互实测后发现的四个问题,都出在我自己新写的代码里。

一、弹窗用了全站共享的 #modal-wrap。
这个 id 是 App 内所有弹窗共用的,且允许多个同时存在("添加档位"选择器就
开在本弹窗之上)。既有代码靠 .closest() 或 closeTopModal() 从按钮往上找,
是有效的——但本弹窗还要从画布侧被查(sortCanvasEl / keyAutoClose /
keyAutoInherit),那里没有按钮可走。实测点"添加档位"后 DOM 里同时有
modal-wrap=1 和 key-auto-modal=1,getElementById 会命中文档里的第一个,
也就是用户没在看的那一个:取消关掉错误的框,画布也可能解析到选择器上。
改为独立 id #key-auto-modal。

二、编辑即落盘,保存/取消形同虚设。
scrAddFromForm / sortScopeSave / scrDelSlot 等 6 处编辑后立刻调
persistAuto()。全局页这样是有意的(改动立即生效),但它和自己页面上那句
"点击保存排序后生效"的提示自相矛盾;在 per-key 弹窗里更严重——加一个槽位
就已经写进那个用户的链了,取消也无法撤销。收敛到 afterChainEdit():
有 scope 时只提示、不落盘,保存是唯一写入口。

三、无独立链时画布显示全部 226 个模型。
注释写的是"显示空画布",实现却是 buildLanes(idx, [], 'chat')——空 rules
会走 discovery 兜底,把每个源的模型全填进去。用户打开弹窗看到 226 个色块,
会以为这些就是这个密钥的链。改为空 rules 直接给 []。

四、"使用全局链"提示不随画布变化。
加完槽位后仍显示"该密钥使用全局 AUTO 链",与旁边的色块直接矛盾。改为由
afterChainEdit 触发、按画布是否有内容决定去留。

另:批量替换时正则的缩进假设错了一次,把 afterChainEdit 自己改成了自调用
(无限递归),node --check 抓到后修掉。这类批量替换必须先过语法检查。

判据两条 + 变异:弹窗回到共享 id → 判红;去掉 scope 分支 → 判红。
CDP 实测:弹窗 id 唯一、添加后画布 0→1 块、添加后服务端 inherits=true
(未落盘)、取消后仍 inherits=true、选择器与弹窗并存互不干扰。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:20:29 +08:00
6311913812 fix(stats): 日/周/月视图请求记录表空白,滚动加载顺序错乱
首页记录表由 paintRecords(st.records) 渲染,而 /api/stats 的 period 分支
手写了一份响应 map,压根没有 records 字段 —— 只有 all 分支走 Snapshot()
才带得上。实测切到日/周/月,记录表恒为空。

两处数据源也不一致:

| 端点 | 数据源 | 排序 |
|---|---|---|
| /api/stats(all)| 内存ring(几百条)| oldest-first |
| /api/stats/records(翻页)| 审计文件(全部)| newest-first |
| /api/stats(period)| 无 | 无 |

前端靠 paintRecords 里一句 .reverse() 把 all 的 oldest-first 转成 newest-first,
翻页端点则本来就是 newest-first。首屏和翻页方向相反,滚动时新旧行会错位
拼接;且 all 分支不带 next_cursor,表永远停在第一屏。

修法:两个分支都改用 AuditPage(与翻页端点同一份代码路径),记录方向统一
由它决定,前端去掉 .reverse()。附带好处:记录深度不再受 maxRecs 限制,
"全部"视图真的能翻到底。

判据三条,变异验证:period 分支返回 nil records → 前两条判红;all 分支不
覆盖 records → "no next_cursor"判红。变异过程中我自己也犯了两次错:第一版
判据硬编码 newest-first,而AuditPage 在单文件(整块读完)与多文件(分块反向
读)下方向不同,改为断言两处方向一致;第三版想测 admin 过滤,但
newTestGateway 的 sk-test 不是 admin,改为测真正的越权边界(非admin 不能
用 ?key= 看别人的记录)。

CDP 实测:四个视图各 100 行、时间从新到旧,滚动加载 100→200 行且新末行
时间更旧(顺序衔接正确)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 10:32:51 +08:00
aa003e84d3 fix(ui): per-key 编辑器画布画错容器、保存成功却不关弹窗
CDP 实测发现两个真 bug,都是复用画布时引入的。

一、画布画进了后台标签页。sortCanvasEl() 原本按文档顺序找,先命中
#scr-canvas(全局优先级页的画布)。而弹窗是挂在设置页之上的,两个画布
同时存在于 DOM —— 于是 per-key 的 36 条泳道被画进隐藏的全局画布,弹窗
里空空如也,回���全局页还会发现自己的链被覆盖了。

改为按 sortState.scope 解析:有 scope 就用 #key-auto-canvas,否则用
#scr-canvas。

二、保存成功却弹窗不关。!j.slots 分支里我写了 return,跳过了
keyAutoClose。写确实成功了(服务端已存),但用户看到弹窗还开着、按钮
还是禁用状态,会反复点保存。而且那条 toast 文案写成"保存后 AUTO 请求
会失败",读起来像保存失败了 —— 一并改成"已保存,但槽位无法解析…"。

判据两条,变异验证:恢复按文档顺序查找 → 第一条判红;!slots 分支加
return → 第二条判红。第二条最初只查 keyAutoClose() 是否存在,变异加了
return 仍然通过,属于判据漏放,已改为扫描 try 块内 close 调用之前的
所有 return 语句。

CDP 复测(先开全局页制造干扰,再开 per-key 弹窗):
- 弹窗画布 36 泳道,全局画布保持 4 泳道未被污染
- 保存 gpt-5.2@xinjianya 后弹窗关闭、服务端存储正确、scope 清空
- 重开弹窗读回 [["gpt-5.2@xinjianya"]]

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 10:32:51 +08:00
28149bdd1a merge: release/v1.7.x → main(per-key AUTO 链 + GUI 插件管理 + UI 复用)
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0
- 13e2397 fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口
- ed9baae feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:50:55 +08:00
ed9baae8e4 feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。

补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
  名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
  什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
  保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。

后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。

CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:48:46 +08:00
13e2397e9c fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口
两件事。

一、per-key AUTO 链的 UI 之前自己写了一套表格,只有 model + tier 两列,
比全局 AUTO 编辑器弱:没有拖拽排序、没有每槽位的 quota/period/hours。
这是重复实现且必然漂移。改为复用同一套泳道画布:

- 抽出 buildSortIndex / buildLanes / lanesToRules / renderSortEditor,
  全局编辑器与 per-key 编辑器共用。
- sortState.scope 决定保存目标(null=全局,key=该密钥),persistAuto
  按此分支;空 lane 列表即"恢复继承",与后端空 PUT 等价。
- 画布查找走 sortCanvasEl(),两个 id 都能解析,否则拖拽在弹窗里静默失效。
- keyAutoClose 必须清 scope,否则关掉弹窗后全局编辑器会存到这个 key。
- 删掉旧的 keyAutoRow/keyAutoAddRow/keyAutoClear。

判据改为断言"复用"而非断言具体实现,并新增两条:
- 关闭弹窗不清 scope(全局编辑器会存错目标)
- per-key 编辑器不再挂载共享画布

二、"周视图总量大于月视图"不是 bug:日历周期并非嵌套。"本周"从周一开始,
所以月初会回溯到上月末几天,本周总量合法地大于本月。实测今天 2026-10-03
(周六):周窗口 09-28→10-03(6 天,9.2B tokens),月窗口 10-01→10-03
(3 天,4.0B tokens)。

真正的缺陷是看不见这一点。统计页新增"统计区间:YYYY-MM-DD → YYYY-MM-DD
(Nd)",让周 > 月 自解释。判据钉住这个事实,并做变异验证:把周窗口钳制到
月内(一个看起来很自然的"修复")会被两条判据同时抓住。

CDP 实测:周视图显示"2026-09-28 → 2026-10-03 (6d)",月视图显示
"2026-10-01 → 2026-10-03 (3d)";per-key 编辑器渲染出 36 条泳道 / 226 个
块,与全局编辑器同款。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:26:57 +08:00
0cd1cd51f6 merge: release/v1.7.x → main(per-key AUTO 链 + 部署 v1.9.0)
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:15:00 +08:00
9dc2b64013 chore(gui): 版本号 1.8.0 → 1.9.0
per-key AUTO 链(22decf2)与并发判据(c66e1b5)是 v1.8.0 发布后的新特性,
v1.8.0 的 tag 已存在,不能复用。package-lock.json 里剩下的一处 1.8.0 是
@electron/fuses 依赖自身的版本,不应改动。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:08:37 +08:00
c66e1b5955 test(keys): per-key AUTO 链的并发与压力验证
AutoChainFor 在请求热路径上无锁读 keyAutoChains,而 SaveKeyAuto 与
rebuildRegistry 会整表替换它。顺序调用测不到任何交错,所以补并发判据。

- 8 读者 + 2 写者并发改链,断言读到的链必属于 {自身链, 全局链} 这个合法
  集合,不断言固定值(写者本来就在合法地来回切换,钉死值会在正确代码上
  失败)。
- 源增删期间读者保持可用链。churn 源用 "churn" 前缀,避免与链依赖的 s1
  重名——删掉链引用的源会合法地清空该链,拿它当断言等于测虚构。
- 500 个 key 的查表不退化。

变异验证:把 rebuildKeyAutoChains 改成原地清空重填(破坏原子替换),
-race 报 DATA RACE,判据有效。

真实压力(20 key 网关 + 并发 AUTO + 后台持续改链):
- 40 并发 25s:9616 请求 / 382 QPS / 零 panic / 零 DATA RACE
- 出现 111 次 503,根因是 mock 上游被打爆后 "no free slot within 2s",
  属预期降级而非缺陷:并发降到 6 时 1395/1395 全 200。
- 每次压测都断言"未改链的用户始终命中自己该走的模型",未改链的 19 个
  key 全程零错分。
2026-10-03 08:01:37 +08:00
22decf2bd3 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
此前 AUTO 链是全局单值(cfg.Auto + Core.AutoChain()),所有用户共用一条链。
管理员无法为某个用户单独指定调度链。

按 per-key 覆盖 + 全局兜底实现:

- config.GWKey 增加 Auto 字段与 HasOwnAuto()。未配置即继承全局链,
  存量部署零改动,新密钥天然继承全局链。
- Core 把 buildAutoChain 的归一化逻辑抽成 chainForRules,全局链、
  生图链、per-key 链共用同一套编译,避免两处漂移。
- Core 增加 keyAutoChains 缓存 + AutoChainFor(key)。请求路径读缓存不
  加锁,与全局链查询一致。缓存整表原子替换,不会看到半成品。
- 请求侧 chat.go 改用 AutoChainFor(reqKey)。冷却仍在 Provider 上按
  model+source 共享:两条链指向同一个 slot 时共用冷却,与今天单链行为
  相同,也避免为 per-key 维度重构冷却而改变现有可观测语义。
- API:GET/PUT/DELETE /api/keys/{key}/auto(admin),GET
  /api/keys/me/auto(任意角色,只能读自己的)。空 PUT 与 DELETE 等价于
  "恢复继承",无法持久化一条会 503 的空链。写入时回报解析出的槽位数,
  让管理员当场看到模型名写错,而不是等用户下次请求 503。
- 源变更时一并重编译 per-key 链,加源后无需重启即可生效。

判据 18 条,5 个变异全部被抓住:AutoChainFor 忽略 key、清空后不重建
缓存、源变更不重建、空 PUT 落盘成空链、me/auto 误要求 admin。

UI 判据做变异时发现漏放:只查函数定义存在,删掉按钮后仍通过。已改为
断言 keyCanvasHtml 内的调用点。

端到端实测(真实 HTTP + 两个 mock 上游):admin 与 bob 初始同为 m-fast,
给 bob 配 m-cheap 后两者分流,重启后仍分流,DELETE 后 bob 回到 m-fast。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 07:45:54 +08:00
4b07be67b4 Merge commit 'ba01da9' into HEAD 2026-10-03 00:16:21 +08:00
913974bb6c Merge commit 'c8edea2' into HEAD 2026-10-02 20:16:18 +08:00
ffb2b8f2f2 Merge release/v1.7.x into main for v1.8.0
main had fallen a full feature generation behind: 13739 lines and 29 files
that main did not contain at all (the whole Lua plugin mechanism, the billing
package, stats_period, plugins_api, docs/plugins.md). v1.7.x contains
everything main has and nothing main lacks, so this is a one-way merge rather
than two divergent lines.

Eight conflicts, each read before resolving — the script that does it lives at
.probe/resolve_merge_conflicts.py and aborts rather than guess:

* cmd/gui/package.json, cmd/gui/package-lock.json — main already carries 1.8.0
  (4b37e1a); the release line carries 1.7.6. Kept 1.8.0. Taking 1.7.6 would
  ship tag v1.8.0 next to a manifest claiming 1.7.6.
* packaging/config.example.yaml (2 hunks) — release side is a superset both
  times: it documents the `auto:` field, and it replaces the older one-line
  runtime_file note with a paragraph that also states where templates and
  deleted-markers live and warns that the AUTO chain, gateway keys and sources
  are in config.yaml. HEAD's wording is strictly less.
* internal/config/config.go, internal/core/core.go, internal/gateway/api.go,
  internal/gateway/apiv1.go, internal/gateway/stats.go (6 hunks) — HEAD is
  zero-length in every one and the release side is the new code: BillingDSL
  types, applyBillingDSL, the pointer-semantics source upsert, the
  optional_fields doc string, the AUTO chain-walk field. The resolver asserts
  the empty-HEAD property instead of assuming it.

Verified before committing: no residual conflict markers, both manifests read
1.8.0, `go build -tags luajit ./...` clean, `go test -tags luajit ./...` all nine
packages pass.
2026-10-02 19:38:42 +08:00
1fe379f630 fix(gui): 缓存解封结果,否则每个请求都要 spawn 一个进程
The auth rule calls readAdminKey() on every outbound request so injection
never depends on ordering. Once the sealed-config path shells out to the
core, that turns each request into a process spawn: 200 simulated requests
took 1012ms and launched 200 cores.

Cache the unsealed key against config.yaml's mtime. Editing the config still
invalidates it, which is what the auth rule actually needs -- the port
rewrite, the first write, and a user edit all change mtime. Measured: 200
requests now cost 10ms and one spawn.

Only a successful unseal is cached. Caching a failure would pin an empty key
until the config next changes, turning a momentary spawn error into a locked
out user.
2026-10-01 19:23:20 +08:00
b03b12148f fix(gui): 桌面版被自己的密钥封存挡住登录
The desktop build authenticates the embedded core by reading the admin key
out of config.yaml with a regex and injecting it as a gw_key cookie. The core
seals credentials at rest (enc:v1:...), so from the second start onward that
regex yields ciphertext, the cookie is worthless, and the app asks the user for
a key they never set. The key is generated and hidden by the app itself.

Reproduced end to end: first start writes a plaintext profile, the core seals
it, every later start reads back "enc:v1:..." and falls through to the login
prompt.

- when the stored value is sealed, ask the core to unseal it via
  -show-secrets, which only reads, prints and exits. Reimplementing the core's
  AEAD in JS would be a second source of truth for its key format.
- cwd must be the profile dir. The core locates master.key relative to the
  config's runtime_file, so a call made from anywhere else has it generate a
  second master key in the CWD and then fail to decrypt ("master key changed?").
  Electron's CWD is not the profile dir, so without this the desktop build
  cannot read its own key even after unsealing is wired up.
- the loose regex is kept as a fallback so a future change to the -show-secrets
  output degrades to a login prompt rather than to a wrong credential.

Verified: plaintext start -> core seals -> restart recovers the same key, with
the core running the whole time. Dropping cwd:PROFILE_DIR makes the unseal fail
and leaves a stray master.key in the CWD, so the cwd argument is load-bearing
rather than tidiness.
2026-10-01 19:19:40 +08:00
a1d4365515 fix(sources): implement PUT and stop partial edits from clobbering api_key
Two defects on the admin source write path, both found while adding a model
to a live source by hand.

PUT /api/sources/{name} was advertised in the API index but never
implemented — handleSourcesAPI only switched on GET/POST/DELETE, so the
documented update verb answered 405 while the POST upsert behind it worked.

POST is an upsert that replaces the whole source, so a partial edit that did
not carry api_key persisted an empty or placeholder credential. The source
kept its name, base_url and models, the write returned 200, and the source
then answered 401 on the next request — long after the writing script exited
0. The WebUI had been routing around this by loading the real key through
?reveal=credentials; any script or partial update went straight into it.

- implement PUT, taking the name from the path and rejecting a body name
  that disagrees rather than silently resolving to one of them
- inherit the stored credential when api_key is omitted or sent as the
  literal "__KEEP__"; an explicit new key still rotates
- an empty api_key on a source that does not exist yet stays empty, since
  credential-less local upstreams are legitimate
- add model_ids, an additive shorthand, so "add these models" never has to
  read and echo the existing list back
- align the API index with the implementation

The model_ids merge had a first cut that dropped the existing list when the
request carried no models field; TestSourceModelIDsIsAdditive caught it.

Verified by mutation: removing PUT turns three tests red, flattening
resolveAPIKey into a pass-through turns TestSourceUpsertKeepsAPIKey red
on both subtests, and making model_ids replace instead of merge turns
TestSourceModelIDsIsAdditive red.
2026-10-01 18:15:33 +08:00
3548745f98 fix(startup): 密钥警告改读真实生效的 key 集合
启动时那条「gateway_keys is EMPTY — without a key every request is rejected」
读的是 legacy 的 cfg.GatewayKeys 段,而鉴权实际用 cfg.Keys(core.ListKeys)。
seedKeys 首次启动把 gateway_keys 搬进 keys[] 之后,YAML 里那个列表就不再
被鉴权使用。于是在它被清空(例如轮换掉 starter key 之后)而 keys[] 仍有
7 把可用 key(含 admin)时,进程每次启动都谎报「所有请求都会被拒绝」。

实测:生产日志出现该警告,而同一个 key 请求 /v1/models 返回 200。

- main.go 改为检查 c.ListKeys(),文案改成不绑定字段名。
- 顺带删掉 gateway.New 的 gatewayKeys 参数:函数体从未使用它,
  只读 ListKeys(),留着会继续诱导人以为鉴权来自那个列表。

判据:e2e/TestStartupWarningReflectsRealKeysNotLegacyList —— 构造
「gateway_keys 空 + keys[] 有 key」的真实形态,先断言该 key 确实能鉴权,
再断言日志里不再出现那句谎报。变异验证:回退成 GatewayKeys() 即变红。
2026-09-28 23:43:20 +08:00
3fe0df5bf2 fix(packaging): 发行包不再内置可用的 admin key
打包时把本地 config.yaml(gitignored,含运维真实密钥)原样复制成
config.example.yaml,而 postinst 在首次安装且 /etc 无配置时又把它
cp 成生产配置 ⇒ 每次安装都得到一个同值的、公开已知的 admin key。
实测该 key(sk-gw-local-0001)在生产上真实有效(/v1/models 返回 200,
而网关监听 0.0.0.0)。

三处修正:
- 新增 packaging/config.example.yaml(受 git 跟踪的净化模板),
  gateway_keys 留空、sources 留空,并写明不要填死值。
- core-dist.sh / nfpm.yaml 改为打包该模板,不再碰本地 config.yaml。
- postinst.sh 不再投递示例配置:留空文件会让网关启动但拒绝所有请求
  (无门可入)。改为让二进制首启时自行生成随机 admin key 并打印 ——
  每次安装都不同,且开箱可用。示例文件仅作为 /usr/share 下的参考保留。

实测首启:生成 sk-gw-838d66a1... 并打印,与旧的共享固定值不同。
2026-09-28 23:28:20 +08:00
8ca59b4bb2 fix(core): 修复启动重复播种 admin key + 配置封存非幂等
根因是 unseal 时序:NewFromConfig 把解密放在最后,而之前几步已经在读凭据。

1. seedKeys 重复播种(生产已累积 4 个同名 admin key)
   seedKeys 用 cfg.Keys[i].Key 与明文 gateway_keys 比对去重,但此时内存里的
   key 还是密文 enc:v1:…,比对永不命中 ⇒ 每次重启追加一个同值 admin key。
   实测:core.New(path) 连续重启,seeded key 数 2→3→4 递增。
   (旧测试用 NewFromConfig 构造全新内存对象,没有「盘上已有密文」这个前提,
    复现不出 —— 必须走 core.New 这条读盘的生产路径。)

2. 启动恒重写 config.yaml
   migratePlaintextSecrets 按内存状态判断,而 Save() 末尾会把内存恢复为明文,
   于是每次调用都判定「还有明文」并重写;注释却自称幂等。
   改为 UnsealSecrets 在解密前记录「盘上是否明文」,SealIfNeeded 据此决定
   是否写回 ⇒ 已封存的配置启动不再落盘。

原测试 TestMigratePlaintextSecretsIsIdempotent 用 ModTime 比较,两次写落在同一
时间戳刻度内就看不出来,所以表现为 ~1/6 概率的 flake 而非稳定失败。已改为比较
文件内容并走真实启动路径(UnsealSecrets + SealIfNeeded),并顺带消除该 flake。

附带更正:先前判断「rebuildRegistry 也会拿到密文 API key」不成立 ——
mergedSources → resolveSourceKey 对每个 source 独立解密(belt-and-braces),
provider 始终拿到明文。unseal 前置仍予保留,以消除对该兜底路径的隐性依赖、
并让 seedKeys 在明文下比较。

判据:
- TestRestartDoesNotDuplicateSeededKeys(敏感:回退顺序必红)
- TestSealingIsIdempotentAcrossStarts(12/12 稳定,原先 1/6 flake)
- TestProvidersGetPlaintextCredentials(钉 provider 必须拿到明文这一不变量)
2026-09-28 22:53:33 +08:00
980408d045 fix(tokens): 流式统计改用上游真实 usage,图片不再记 token
两处 token 单位错误,均影响 per-model 配额计费:

1. 流式路径的 prompt/completion 只是「字节÷3」估算。
   pumpStream 明明收到了上游最后一帧的真实 usage,却只发给客户端、
   从不回写审计记录,于是配额按估算值扣。生产实测同一请求:
   上游 prompt=37/completion=179 → 记账 27/262,prompt 低估 1.4x、
   completion 高估 1.5x(双向失真)。同模型流式 completion 中位数
   是非流式的 4-27 倍。非流式路径本就用真实值,两路不一致。
   修法:lastUsage 非零时写回 rec.Prompt/rec.Compl,估算降为兜底
   (上游不报 usage 时仍保留原估算行为)。

2. 图片请求把「图片张数」记成 completion_tokens。
   rec.Compl = int64(len(resp.ImageData)),len 是切片长度即张数
   (生产 38 条 image 记录全是 1),且被计入 token 总量。
   图片生成无 token 概念 ⇒ 新增 Req.ImageCount 独立字段,
   Prompt/Compl 归 0;UI 记录表 image 行改显示张数(新增 i18n thImgs)。

顺带补 TestUILocaleKeyParity:此前无人校验 zh/en 键集合一致,
单边加键不会报错,只会显示原始键名。

新增 token_units_test.go(定值上游 6 项),做过变异验证:
回退修复实测复现 stream=16/173 vs chat=44/100、image completion=3。
2026-09-28 22:20:14 +08:00
4b37e1a0db chore(version): 1.7.1 -> 1.8.0
main 的版本号此前停在 1.4.2(v1.6.0 回流时按纪律未带入 bump),
落后于已发布的 v1.7.1。按 SemVer,main 指向下一个未发布的中版本。
2026-09-27 19:08:37 +08:00
ebe60028f5 refactor(quota): 配额改为按模型,删除整钥总配额
用户明确要求:配额应当是密钥对应的**每个模型的单独配额**,而非整体配额。

## 语义变更

删除 GWKey.TokenQuota / ReqQuota / Period / Hours(整钥总额)。
ModelScope 新增 ReqQuota —— 请求数配额下沉到每条模型范围。

现在:每条 models[] 各自带 token 配额 + 请求数配额 + 重置周期,
彼此独立。一个模型用满只影响该模型。

★ 为什么不保留整钥总额:它会让「把 A 模型的额度挪给 B」变成一次全局
重分配;按模型独立计费则每个模型各自可控,运维能直接看出哪个模型在吃预算。

## 连带改动

- checkQuota 合并 key 级与 scope 级判定;checkKeyQuotaRetry 整体删除
  (顺带修掉上轮遗留的双重判定:入口不再先判空再重算)
- core:CreateKeyWithQuota / UpdateKeyWithQuota / ApplyQuota 全部删除,
  改由 ValidateScopeQuotas 校验每条 scope 的配额
- admin key:scope 上的配额不强制(admin 的 scope 仍限制模型范围,
  但不强制配额)—— 否则管理员会把自己锁在门外
- /api/v1/keys 不再回显 key 级配额字段(scope 里已含)
- WebUI:删除整钥配额徽标 / 「配额」按钮 / 创建表单的配额组 /
  putScope 的整钥回传;模型砖块与范围编辑器新增「请求数配额」输入,
  徽标显示 `1.0K 77×·1h`(未设配额显示 ∞)

## 判据

- TestOneModelsQuotaDoesNotBlockAnother 是本次核心保证。
  ★ 它第一版是**假判据**:m2 从不消耗,key-wide 计数器与 m1 自己的计数器
  读数恰好相同,退回 key-wide 仍通过。变异测试抓到后改为「先用 m2 花掉
  远超 m1 配额的量,再验证 m1 仍可用」—— 这样两种设计才可区分。
- TestUncappedModelNeverBlocked / TestAdminKeyScopesAreNotEnforced 新增
- UI 契约判据重写:整钥配额界面必须彻底消失(13 个符号)、
  scope 编辑器必须往返 req_quota、putScope 只发 scope 列表
- 错误消息点名具体模型(TestKeyAPIRejectionNamesTheModel)
- 3/3 变异全被抓

实测(真实进程 + 浏览器):m2 配额 500000 连打 25 次全成功,
m1 配额 1000 立即 429「token quota exceeded for "m1" (4315/1000)」,
此后 m2/m3 仍 200。UI:整钥配额元素全为 0,砖块各显配额,
编辑器预填/保存正确,零 JS 异常。

(cherry picked from commit c51066f0b6)
2026-09-27 19:07:28 +08:00
42 changed files with 7876 additions and 157 deletions

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.8.0",
"version": "1.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.8.0",
"version": "1.9.3",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

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

View File

@ -226,6 +226,7 @@ async function loadPlugins() {
return;
}
const rows = j.on_disk || [];
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
const active = rows.filter((p) => p.loaded && !p.disabled).length;
const broken = rows.filter((p) => !p.loaded).length;
hint.textContent =
@ -250,11 +251,30 @@ async function loadPlugins() {
const builtin = p.builtin
? '<span class="pl-builtin">内置</span>'
: "";
// Edit / view source / delete are the operations an operator
// actually needs after enable/disable: a plugin that cannot be
// read back cannot be fixed, and one that cannot be removed cannot
// be rolled back.
const acts = [
btn,
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
p.builtin ? "查看源码" : "编辑"
}</button>`,
builtin
? ""
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
p.name,
)}">删除</button>`,
]
.filter(Boolean)
.join("");
const pages = Array.isArray(p.pages) ? p.pages : [];
return `<div class="pl-item ${cls}">
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
<div class="pl-acts">${btn}</div>
<div class="pl-acts">${acts}</div>
</div>`;
})
.join("")
@ -262,6 +282,160 @@ async function loadPlugins() {
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
});
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
b.onclick = () => editPlugin(b.dataset.name);
});
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
b.onclick = () => deletePlugin(b.dataset.name);
});
}
// editPlugin shows the plugin source in a modal. Built-in plugins are
// read-only: their file ships inside the binary, so a save would either be
// ignored or silently do nothing — showing an editable text area that
// discards on save is worse than saying so up front.
function editPlugin(name) {
// The plugin list is the only place `builtin` is reported; keep the last
// fetch so the modal does not need a second round trip (and cannot guess).
const row = (window.__plRows || []).find((p) => p.name === name);
const isBuiltin = !!(row && row.builtin);
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
${isBuiltin
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
: `<textarea id="pl-code" spellcheck="false"></textarea>
<p class="hint" id="pl-code-err"></p>`}
<div class="row actions">
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
<button class="ghost" id="pl-code-close">关闭</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-code-close").onclick = () => wrap.remove();
const save = $("#pl-code-save");
if (save) {
save.onclick = async () => {
save.disabled = true;
try {
// Saving writes through POST /api/plugins — the same install path the
// installer uses, which validates the name, compiles the source and
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
// save there would silently disable nothing and store nothing.
await window.modelrouter.plugins.request("POST", "/api/plugins", {
name,
code: $("#pl-code").value,
});
toast(`${name} 已保存`);
wrap.remove();
await loadPlugins();
} catch (e) {
$("#pl-code-err").textContent = e.message;
save.disabled = false;
}
};
}
if (isBuiltin) return;
(async () => {
const ta = $("#pl-code");
try {
const j = await window.modelrouter.plugins.request(
"GET",
`/api/plugins/${encodeURIComponent(name)}`,
);
ta.value = j.code || "";
const err = $("#pl-code-err");
if (err && j.parse_error) err.textContent = String(j.parse_error);
} catch (e) {
$("#pl-code-err").textContent = e.message;
}
})();
}
function deletePlugin(name) {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card">
<div class="overlay-head"><h2>删除插件</h2></div>
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
<div class="row actions">
<button class="danger" id="pl-del-ok">删除</button>
<button class="ghost" id="pl-del-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-del-no").onclick = () => wrap.remove();
$("#pl-del-ok").onclick = async () => {
try {
await window.modelrouter.plugins.request(
"DELETE",
`/api/plugins/${encodeURIComponent(name)}`,
);
toast(`已删除 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
};
}
// installPlugin creates a new plugin from source. The name becomes the file
// name, so it is validated client-side to save a round trip on the obvious
// mistakes (empty, path separators, non-portable characters).
function installPlugin() {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>安装插件</h2></div>
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
<p class="hint" id="pl-new-err"></p>
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件&#10;plugin = {}&#10;..."></textarea>
<div class="row actions">
<button id="pl-new-ok">安装</button>
<button class="ghost" id="pl-new-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-new-no").onclick = () => wrap.remove();
$("#pl-new-ok").onclick = async () => {
const name = $("#pl-new-name").value.trim();
const code = $("#pl-new-code").value;
const err = $("#pl-new-err");
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
err.textContent = "名称只能包含字母、数字、下划线和连字符";
return;
}
if (!code.trim()) {
err.textContent = "源码不能为空";
return;
}
$("#pl-new-ok").disabled = true;
try {
await window.modelrouter.plugins.request("POST", "/api/plugins", {
// The backend appends .lua itself (installPlugin writes name+".lua"),
// so passing it here would create name.lua.lua.
name,
code,
});
toast(`已安装 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
err.textContent = e.message;
$("#pl-new-ok").disabled = false;
}
};
}
async function togglePlugin(name, disabled) {
@ -318,6 +492,8 @@ function init() {
if (plReload) plReload.onclick = loadPlugins;
const plAll = document.getElementById("pl-toggle-all");
if (plAll) plAll.onclick = enableAllPlugins;
const plInstall = document.getElementById("pl-install");
if (plInstall) plInstall.onclick = installPlugin;
$("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => {

View File

@ -214,6 +214,7 @@
<div id="pl-list" class="pl-list"></div>
<div class="row actions">
<button class="ghost" id="pl-toggle-all">全部启用</button>
<button class="ghost" id="pl-install">安装插件</button>
<button class="ghost" id="pl-reload">刷新</button>
</div>
<div class="row actions">

View File

@ -674,3 +674,51 @@ html[data-theme="dark"] .overlay {
color: var(--muted);
padding: 10px 0;
}
/* ---- plugin editor / installer --------------------------------------------
The plugin source editor and the install dialog reuse the settings overlay
(overlay / overlay-card) rather than a second popup system. They need three
things the settings card does not: a wider column for code, a monospace
textarea, and a destructive button for delete. */
.overlay-card.pl-wide {
width: 760px;
}
.pl-wide textarea {
width: 100%;
min-height: 380px;
resize: vertical;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.55;
tab-size: 2;
}
.pl-wide textarea:focus {
outline: none;
border-color: var(--primary);
}
.pl-wide .row input {
flex: 1;
padding: 7px 10px;
border-radius: 9px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
}
.pl-editor .hint,
.pl-wide .hint {
min-height: 16px;
color: var(--muted, #888);
}
button.danger {
color: #e5484d;
border-color: rgba(229, 72, 77, 0.4);
}
button.danger:hover {
border-color: #e5484d;
color: #e5484d;
}

View File

@ -195,6 +195,36 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName
if t.CacheDiscount != nil {
entry["cache_discount"] = *t.CacheDiscount
}
// reasoning is optional and stays ABSENT (not nil, not 0) when the
// rule does not set it, which is what makes the billing plugin skip
// the repricing branch entirely. Writing an explicit 0 here would
// mean "thinking is free" and silently under-bill every request
// from a provider that bills it like ordinary output.
if strings.TrimSpace(t.Reasoning) != "" {
rp, err := usdPerM(t.Reasoning)
if err != nil {
return fmt.Errorf("model %q reasoning: %w", modelID, err)
}
entry["reasoning_price"] = rp
}
if lc := t.LongContext; lc != nil {
if lc.Above <= 0 {
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
}
above, err := longContextRate(lc, prompt)
if err != nil {
return fmt.Errorf("model %q long_context: %w", modelID, err)
}
entry["long_context"] = map[string]interface{}{
"above": lc.Above,
// Marginal, not blended. The plugin charges the tokens past the
// threshold at this rate and the rest at the base rate, so the
// threshold composes with a cache discount (which applies to the
// cached portion) instead of forcing the plugin to guess how
// the two interact.
"prompt": above,
}
}
if rule.Peak != nil {
entry["peak"] = compilePeak(rule.Peak)
}
@ -262,3 +292,32 @@ func compilePeak(p *config.BillingPeak) map[string]interface{} {
}
return out
}
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
// threshold, given the model's base per-million prompt price.
//
// Two ways to express it are supported because providers publish both: an
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
// silent no-op, and an operator who wrote one meant something.
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
hasMult := lc.Multiplier > 0
switch {
case hasPrompt && hasMult:
p, err := usdPerM(lc.Prompt)
if err != nil {
return 0, err
}
// Both given: the absolute rate wins, because it is the one the provider
// actually quotes. Silently multiplying instead would produce a number
// the operator never wrote.
return p, nil
case hasPrompt:
return usdPerM(lc.Prompt)
case hasMult:
return basePerToken * lc.Multiplier, nil
default:
return 0, fmt.Errorf("needs either prompt or multiplier")
}
}

View File

@ -86,3 +86,129 @@ func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
t.Error("an unrelated source was marked priced by a specific-url rule")
}
}
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
// thinking-token rate.
//
// A rule that says nothing about reasoning must produce a price entry with NO
// reasoning_price key, so the billing plugin skips the repricing branch and
// bills thinking at the ordinary output rate — which is what every provider
// actually charges. Writing an explicit 0 instead would read as "thinking is
// free" and silently under-bill every request from that model.
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
// Case 1: no reasoning declared → the key must not appear.
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00"},
},
}}}
prices, err := Compile(p1, sources)
if err != nil {
t.Fatal(err)
}
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
if _, present := entry["reasoning_price"]; present {
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
entry["reasoning_price"])
}
// Case 2: declared → present, and converted to per-token like every other rate.
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
},
}}}
prices2, err := Compile(p2, sources)
if err != nil {
t.Fatal(err)
}
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
rp, ok := e2["reasoning_price"].(float64)
if !ok {
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
}
if rp != 6e-6 {
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
}
// A malformed rate must be rejected, not silently ignored: an operator who
// typed "6 USD" expects either that value or an error, never a rule that
// looks applied and bills nothing.
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
},
}}}
if _, err := Compile(p3, sources); err == nil {
t.Error("a non-numeric reasoning rate must fail the compile")
}
}
// TestLongContextStepCompiles pins the long-context step's two accepted forms
// and its rejection of a step that would silently do nothing.
//
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
// missing: a step with no rate is a rule that LOOKS like it handles long
// context and does not, which under-bills without any visible sign.
func TestLongContextStepCompiles(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
},
}}}
prices, err := Compile(p, sources)
if err != nil {
return nil, err
}
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
return e["long_context"].(map[string]interface{}), nil
}
// Absolute rate (OpenAI's published shape).
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
if err != nil {
t.Fatalf("absolute long_context rate rejected: %v", err)
}
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
}
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
if err != nil {
t.Fatalf("multiplier long_context rejected: %v", err)
}
if lc2["prompt"] != 2e-6 {
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
}
// Both given: the absolute rate wins, because that is the number the
// provider quotes. Multiplying instead would produce a figure the operator
// never wrote.
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
if err != nil {
t.Fatal(err)
}
if lc3["prompt"] != 5e-6 {
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
}
// Neither: a no-op step must fail, not compile into a rule that silently
// bills long context at the base rate.
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
t.Error("a step with neither prompt nor multiplier must be rejected")
}
// A zero threshold would make every request "long".
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
t.Error("above=0 must be rejected")
}
}

View File

@ -39,6 +39,15 @@ type Config struct {
// so peak traffic was billed at off-peak rates — no error, the bill just
// quietly halved. A schema-checked config file catches both at load.
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
// PluginVisibility overrides the audience a plugin declared for itself,
// keyed by plugin name: "admin" hides a plugin (and its state) from user
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
// explicitly. An absent name leaves the plugin's own declaration in force.
//
// This exists because a plugin author cannot know the operator's policy, and
// the operator should not have to edit a bundled .lua to change one — the
// next upgrade overwrites it.
PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"`
// box seals credentials (sources' api_key/headers, keys' key) at rest.
// In-memory values are always plaintext; only the bytes on disk are sealed.
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
@ -384,15 +393,25 @@ type SourceTemplate struct {
// (full management) or "user" (sees only its own key); Models is the allowed
// model scope with per-model token quota (0 = unlimited).
type GWKey struct {
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
// Auto is this key's own AUTO scheduling chain. It overrides the global
// cfg.Auto for requests authenticated with this key; nil (or an empty
// list) means "inherit the global chain", so keys created before per-key
// chains existed keep working untouched.
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
}
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
// inheriting the global one. An explicitly empty list still counts as "no own
// chain" so a user can clear an override back to the global default.
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
//
// Profiles are the "let the user pick" axis: the same upstream URL can be
@ -434,6 +453,19 @@ type BillingToken struct {
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
// provider that charges them differently from ordinary output. Omit it and
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
// Anthropic all do, and what every source on this deployment reports.
//
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
// billing plugin applies only the difference between this and Completion, so
// setting it to a multiple of Completion reprices the thinking share rather
// than billing it twice.
Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
// LongContext is the prompt-price step above a token threshold. Nil = one
// flat rate, which is what a rule means when it says nothing.
LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"`
}
// BillingPeak declares peak-hour pricing.
@ -445,6 +477,31 @@ type BillingPeak struct {
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
}
// BillingLongContext is a prompt-price step for long inputs.
//
// Every major provider charges the input tokens ABOVE a threshold at a higher
// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A
// flat prompt price therefore under-bills exactly the traffic an agent workload
// generates, and it under-bills it silently: the number is plausible, the bill
// adds up, and nothing says the rate was wrong.
//
// The step applies to the PROMPT only. Output and cache reads are billed at
// their own rates regardless of input length, which is how providers publish
// it — the higher rate is for the extra context you made the model read, not for
// what it wrote back.
type BillingLongContext struct {
// Above is the token count the step starts at. A request with exactly this
// many prompt tokens is still at the base rate; the step covers the tokens
// BEYOND it. Inclusive-below matches how the providers document it.
Above int `yaml:"above" json:"above"`
// Prompt is the USD-per-million rate for the tokens above Above.
Prompt string `yaml:"prompt" json:"prompt"`
// Multiplier is an alternative to Prompt: a factor applied to the base rate
// (DeepSeek's published step is exactly 2x). When both are set Prompt wins,
// and setting neither is a validation error rather than a silent no-op.
Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
}
// Validate rejects a BillingDSL that could not work as written.
func (b *BillingDSL) Validate() error {
if b == nil {

View File

@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"sort"
"net/url"
"strings"
"sync"
"sync/atomic"
@ -37,6 +38,12 @@ type Core struct {
registry *provider.Registry
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
// keyAutoChains caches per-key AUTO chains, keyed by the gateway key.
// Entries are rebuilt by rebuildKeyAutoChains whenever sources or key
// config change; AutoChainFor reads them without taking c.mu so request
// handling stays lock-free like the global chain lookup.
keyAutoChains atomic.Pointer[map[string]*scheduler.Chain]
}
// New builds the core from a config file plus runtime overlay.
@ -62,6 +69,29 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
// to boot the gateway because a third-party .lua has a typo would be the
// wrong trade.
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
// Plugin outbound HTTP is scoped to the hosts of configured sources. Set
// before LoadDir so a plugin that requests on its first hook already sees
// the real allowlist rather than the deny-all placeholder.
c.plugins.SetOutboundHosts(sourceHosts(cfg))
// Let the plugin layer validate access specs against the keys that exist.
// A stored allowlist naming a key that does not exist refuses every caller,
// which looks exactly like a broken endpoint.
lua.SetKeyNameLookup(func() []string {
// ListKeys is the structured view (bare `gateway_keys:` entries are
// expanded into records with names); cfg.GatewayKeys is still []string.
keys := c.ListKeys()
out := make([]string, 0, len(keys))
for _, k := range keys {
if k.Name != "" {
out = append(out, k.Name)
}
}
return out
})
// Operator overrides win over the plugin's own declared audience.
for name, aud := range cfg.PluginVisibility {
c.plugins.SetPluginVisibility(name, aud)
}
if err := c.plugins.SeedBundled(); err != nil {
log.Printf("[core] seed bundled plugins: %v", err)
}
@ -257,6 +287,68 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
// AutoChain returns the current AUTO scheduling chain.
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
// AutoChainFor returns the AUTO chain to use for a request authenticated with
// key. A key that declares its own chain gets that chain; every other key
// inherits the global chain, so keys created before per-key chains existed
// keep their current behaviour with no configuration change.
//
// It returns (chain, ok). ok is false only when the key has its own chain but
// it compiled to nothing usable (e.g. every slot names a model that no longer
// exists) — the caller must surface that rather than silently downgrading the
// user to the global chain, which would be a confusing, invisible switch.
func (c *Core) AutoChainFor(key string) (*scheduler.Chain, bool) {
if m := c.keyAutoChains.Load(); m != nil {
if ch, ok := (*m)[key]; ok {
return ch, true
}
}
return c.autoChain.Load(), true
}
// rebuildKeyAutoChains recompiles every per-key AUTO chain from the current
// config and provider registry, then swaps the cache in one shot so requests
// never observe a half-built map. Keys without their own chain are absent
// from the map and fall back to the global chain.
func (c *Core) rebuildKeyAutoChains() {
m := make(map[string]*scheduler.Chain)
for _, k := range c.cfg.Keys {
if !k.HasOwnAuto() {
continue
}
m[k.Key] = c.chainForRules(k.Auto, false)
}
c.keyAutoChains.Store(&m)
}
// SaveKeyAuto persists one key's own AUTO chain and recompiles the per-key
// chain cache. Passing an empty list clears the override so the key inherits
// the global chain again.
func (c *Core) SaveKeyAuto(key string, entries []config.ModelScope) error {
c.mu.Lock()
defer c.mu.Unlock()
idx := -1
for i, k := range c.cfg.Keys {
if k.Key == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("key not found")
}
if err := ValidateScopeQuotas(entries); err != nil {
return err
}
c.cfg.Keys[idx].Auto = cleanScopes(entries)
if err := c.saveConfig(); err != nil {
return err
}
// Rebuild the whole cache so a cleared override stops shadowing the
// global chain for this key.
c.rebuildKeyAutoChains()
return nil
}
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
@ -546,26 +638,64 @@ func (c *Core) rebuildRegistry() error {
}
c.buildAutoChain()
c.buildAutoImageChain()
c.rebuildKeyAutoChains()
// A source edit can add or remove an upstream host, which changes what a
// plugin is allowed to reach. Keeping this beside the chain rebuilds means
// one call site owns "config changed, refresh derived state".
c.plugins.SetOutboundHosts(sourceHosts(c.cfg))
return nil
}
// sourceHosts extracts the host of every configured source's base_url — the
// 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.
func sourceHosts(cfg *config.Config) []string {
if cfg == nil {
return nil
}
out := make([]string, 0, len(cfg.Sources))
for _, s := range cfg.Sources {
if s.BaseURL == "" {
continue
}
u, err := url.Parse(s.BaseURL)
if err != nil || u.Hostname() == "" {
continue
}
out = append(out, u.Hostname())
}
return out
}
// 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
// back to the source's best chat model and cooldown/quota bookkeeping would
// key on a name that never matches.
func (c *Core) buildAutoChain() {
c.autoChain.Store(c.chainForRules(c.cfg.Auto, false))
}
// chainForRules compiles a chat AUTO chain from a rule list. kindFilter is
// false for the chat chain (image models are skipped) and true for the image
// chain (only image models participate). Per-key chains always compile as
// chat chains: a user's key configures the models *they* talk to.
func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *scheduler.Chain {
prov := func(model, source string) scheduler.Provider {
p := c.registry.ProviderForSlot(model, source)
if p == nil {
return nil
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
m := p.ModelByID(model)
if imageOnly && (m == nil || m.Kind != "image") {
return nil
}
if !imageOnly && m != nil && m.Kind == "image" {
return nil
}
return p
}
rules := c.cfg.Auto
sr := make([]scheduler.Rule, 0, len(rules))
for _, e := range rules {
model, source := e.Model, e.Source
@ -574,7 +704,11 @@ func (c *Core) buildAutoChain() {
model = exact
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
continue // image-kind models never join the chat AUTO chain
if !imageOnly {
continue // image-kind models never join the chat AUTO chain
}
} else if imageOnly {
continue
}
if source == "" {
source = p.Name()
@ -589,7 +723,7 @@ func (c *Core) buildAutoChain() {
Hours: e.Hours,
})
}
c.autoChain.Store(scheduler.BuildChain(sr, prov))
return scheduler.BuildChain(sr, prov)
}
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.

View File

@ -0,0 +1,189 @@
package core
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"llmsproxy/internal/config"
)
// AutoChainFor sits on the request hot path and reads keyAutoChains with no
// lock, while SaveKeyAuto / rebuildRegistry replace that map from the admin
// path. A "call it twice" test never interleaves those, so it would pass even
// if the map were mutated in place. These tests run both sides at once under
// -race and assert what readers actually observe.
//
// The property: a reader sees a chain that is either the key's own or the
// global one — never a torn or empty one. If rebuildKeyAutoChains built the
// map in place instead of swapping a fresh pointer, readers would sometimes
// see a partially-filled map and silently fall back to the global chain, i.e.
// a user quietly losing their configured chain under load.
// concurrentChainConfig builds N keys, half with their own chain pointing at a
// distinct model so a fallback to the global chain is distinguishable.
func concurrentChainConfig(t *testing.T, n int) *config.Config {
t.Helper()
cfg := perKeyTestConfig(t)
cfg.Keys = nil
cfg.GatewayKeys = []string{"sk-gw-admin"}
cfg.Auto = []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}}
for i := 0; i < n; i++ {
k := config.GWKey{
Key: fmt.Sprintf("sk-gw-k%03d", i),
Role: "user",
Name: fmt.Sprintf("user%d", i),
}
if i%2 == 0 {
k.Auto = []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}
}
cfg.Keys = append(cfg.Keys, k)
}
return cfg
}
func TestAutoChainForConcurrentWithSaveKeyAuto(t *testing.T) {
cfg := concurrentChainConfig(t, 40)
c := newTestCore(t, cfg)
var stop atomic.Bool
var wg sync.WaitGroup
var bad atomic.Int64
for r := 0; r < 8; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for !stop.Load() {
for i := 0; i < 40; i++ {
key := fmt.Sprintf("sk-gw-k%03d", i)
ch, ok := c.AutoChainFor(key)
if !ok || ch == nil || len(ch.Tiers) == 0 {
bad.Add(1)
continue
}
// Legal values are this key's own chain model (either
// side of the writer's flip) or the global chain's. The
// assertion is about the legal set, not a fixed value:
// the writers legitimately change the chain underneath.
got := ch.Tiers[0].Slots[0].Model
if got != "gpt-4o" && got != "gpt-4o-mini" {
bad.Add(1)
}
}
}
}()
}
for w := 0; w < 2; w++ {
wg.Add(1)
go func() {
defer wg.Done()
model := "gpt-4o-mini"
for i := 0; i < 25 && !stop.Load(); i++ {
for j := 0; j < 40; j += 2 {
if err := c.SaveKeyAuto(fmt.Sprintf("sk-gw-k%03d", j), []config.ModelScope{
{Model: model, Source: "s1", Tier: 1},
}); err != nil {
bad.Add(1)
return
}
}
if model == "gpt-4o-mini" {
model = "gpt-4o"
} else {
model = "gpt-4o-mini"
}
}
}()
}
time.Sleep(400 * time.Millisecond)
stop.Store(true)
wg.Wait()
if n := bad.Load(); n > 0 {
t.Fatalf("%d reads saw a chain outside {own, global} — the cache is "+
"being swapped in place instead of atomically", n)
}
}
// The registry rebuild path (AddSource / RemoveSource) also replaces the
// cache, so readers must keep a usable chain throughout.
//
// Churned sources use a "churn" prefix on purpose: deleting a source a chain
// references legitimately empties that chain (BuildChain drops unresolvable
// slots), and asserting otherwise would be testing a fiction rather than the
// code.
func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
cfg := concurrentChainConfig(t, 20)
c := newTestCore(t, cfg)
var stop atomic.Bool
var wg sync.WaitGroup
var bad atomic.Int64
for r := 0; r < 6; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for !stop.Load() {
for i := 0; i < 20; i++ {
ch, ok := c.AutoChainFor(fmt.Sprintf("sk-gw-k%03d", i))
if !ok || ch == nil || len(ch.Tiers) == 0 {
bad.Add(1)
continue
}
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" && got != "gpt-4o-mini" {
bad.Add(1)
}
}
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < 30 && !stop.Load(); n++ {
name := fmt.Sprintf("churn%d", n)
_ = c.AddSource(config.Source{
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
)
_ = c.RemoveSource(name)
}
}()
time.Sleep(500 * time.Millisecond)
stop.Store(true)
wg.Wait()
if n := bad.Load(); n > 0 {
t.Fatalf("%d reads lost the chain while unrelated sources were churned", n)
}
}
// Many distinct keys: the lookup must stay a map hit and must not degrade into
// returning the global chain as the key count grows.
func TestAutoChainForManyKeys(t *testing.T) {
cfg := concurrentChainConfig(t, 500)
c := newTestCore(t, cfg)
for i := 0; i < 500; i++ {
key := fmt.Sprintf("sk-gw-k%03d", i)
ch, _ := c.AutoChainFor(key)
if ch == nil || len(ch.Tiers) == 0 {
t.Fatalf("key %s lost its chain with 500 keys configured", key)
}
want := "gpt-4o"
if i%2 == 0 {
want = "gpt-4o-mini"
}
if got := ch.Tiers[0].Slots[0].Model; got != want {
t.Fatalf("key %s got %q, want %q", key, got, want)
}
}
}

View File

@ -0,0 +1,221 @@
package core
import (
"path/filepath"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/scheduler"
)
// Per-key AUTO chains let an admin give one user their own scheduling chain
// while every other key keeps the global one. These tests pin the three
// properties that make that safe:
//
// 1. a key WITH its own chain gets it;
// 2. a key WITHOUT one inherits the global chain (backward compatible);
// 3. the override is actually persisted, so it survives a restart.
//
// A test that only checked (1) would pass even if AutoChainFor ignored the key
// and always returned the global chain whenever the two happened to be equal,
// so (2) uses deliberately *different* chains.
func perKeyTestConfig(t *testing.T) *config.Config {
t.Helper()
dir := t.TempDir()
return &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-gw-admin"},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
},
// Global chain points at gpt-4o.
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
Keys: []config.GWKey{
{Key: "sk-gw-admin", Role: "admin"},
{Key: "sk-gw-inherits", Role: "user", Name: "inherits"},
// This key's own chain points at a DIFFERENT model, so returning
// the global chain by mistake is detectable.
{Key: "sk-gw-own", Role: "user", Name: "own",
Auto: []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}},
},
}
}
func firstSlotModel(t *testing.T, c *Core, key string) string {
t.Helper()
ch, _ := c.AutoChainFor(key)
if ch == nil || len(ch.Tiers) == 0 {
t.Fatalf("key %s: no chain tiers", key)
}
slots := ch.Tiers[0].Slots
if len(slots) == 0 {
t.Fatalf("key %s: tier 1 has no slots", key)
}
return slots[0].Model
}
// A key that declares its own chain must be scheduled by it, not the global.
func TestAutoChainForUsesKeyOwnChain(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o-mini" {
t.Fatalf("key with own chain must use it, got %q (want gpt-4o-mini)", got)
}
// Sanity: the global chain really is different, so the assertion above
// cannot pass by accident.
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
t.Fatalf("admin must use global chain, got %q (want gpt-4o)", got)
}
}
// A key with no own chain inherits the global one. This is what keeps every
// pre-existing deployment working with no config change.
func TestAutoChainForInheritsGlobalWhenUnset(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o" {
t.Fatalf("key without own chain must inherit global, got %q (want gpt-4o)", got)
}
}
// A key that is not in the key table at all (e.g. a seed key) must also
// inherit the global chain rather than erroring or returning nil.
func TestAutoChainForUnknownKeyInheritsGlobal(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
ch, ok := c.AutoChainFor("sk-gw-never-configured")
if !ok || ch == nil || len(ch.Tiers) == 0 {
t.Fatalf("unknown key must fall back to global chain, got ok=%v chain=%v", ok, ch)
}
}
// The per-key chain must survive a save/reload round trip: an admin configuring
// a chain in the WebUI and restarting the gateway must not silently lose it.
func TestSaveKeyAutoPersistsAndApplies(t *testing.T) {
cfg := perKeyTestConfig(t)
c := newTestCore(t, cfg)
if err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
{Model: "gpt-4o-mini", Source: "s1", Tier: 1},
}); err != nil {
t.Fatalf("SaveKeyAuto: %v", err)
}
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o-mini" {
t.Fatalf("saved chain must apply immediately, got %q", got)
}
// Reload from disk exactly like a restart does.
reloaded, err := config.Load(cfg.Path)
if err != nil {
t.Fatalf("reload config: %v", err)
}
c2 := newTestCore(t, reloaded)
if got := firstSlotModel(t, c2, "sk-gw-inherits"); got != "gpt-4o-mini" {
t.Fatalf("per-key chain must survive restart, got %q", got)
}
// The other key must be unaffected by its neighbour's override.
if got := firstSlotModel(t, c2, "sk-gw-admin"); got != "gpt-4o" {
t.Fatalf("override must not leak to other keys, got %q", got)
}
}
// Clearing the override must return the key to the global chain, both in
// memory and after a restart. This is what the WebUI's "use global" toggle
// does, so a stale shadowing entry would silently pin the user to their old
// chain forever.
func TestSaveKeyAutoClearRestoresGlobal(t *testing.T) {
cfg := perKeyTestConfig(t)
c := newTestCore(t, cfg)
if err := c.SaveKeyAuto("sk-gw-own", nil); err != nil {
t.Fatalf("clear: %v", err)
}
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o" {
t.Fatalf("cleared key must inherit global again, got %q", got)
}
reloaded, err := config.Load(cfg.Path)
if err != nil {
t.Fatalf("reload: %v", err)
}
c2 := newTestCore(t, reloaded)
if got := firstSlotModel(t, c2, "sk-gw-own"); got != "gpt-4o" {
t.Fatalf("clear must survive restart, got %q", got)
}
}
// Saving an unknown key must fail loudly rather than creating a shadow entry
// that no request can ever match.
func TestSaveKeyAutoUnknownKeyFails(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
if err := c.SaveKeyAuto("sk-gw-nope", []config.ModelScope{{Model: "gpt-4o", Source: "s1"}}); err == nil {
t.Fatal("SaveKeyAuto on unknown key must return an error")
}
}
// Per-key quota validation must run, exactly like the model scope path: an
// override is another place an operator can write a bad budget.
func TestSaveKeyAutoRejectsBadQuota(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
{Model: "gpt-4o", Source: "s1", TokenQuota: -5},
})
if err == nil {
t.Fatal("negative token quota in a per-key auto chain must be rejected")
}
}
// Source changes must recompile per-key chains. A key whose chain names a
// model that does not exist yet compiles to an empty chain (BuildChain drops
// slots it cannot resolve — sending the request into a black hole would be
// worse), and must start working as soon as the source appears.
func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
cfg := perKeyTestConfig(t)
cfg.Keys = append(cfg.Keys, config.GWKey{
Key: "sk-gw-late", Role: "user", Name: "late",
Auto: []config.ModelScope{{Model: "new-model", Source: "s2", Tier: 1}},
})
c := newTestCore(t, cfg)
// s2 does not exist yet, so the chain has no usable slot.
if ch, _ := c.AutoChainFor("sk-gw-late"); chainSlotsOf(ch) != 0 {
t.Fatalf("chain naming an unknown model must compile empty, got %d slots",
chainSlotsOf(ch))
}
// The other keys are unaffected by the broken one.
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
t.Fatalf("broken per-key chain must not affect others, got %q", got)
}
// Add the source; the per-key chain must pick it up without a restart.
cfg.Sources = append(cfg.Sources, config.Source{
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
Models: []config.Model{{ID: "new-model", Priority: 10}},
})
if err := c.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
if got := firstSlotModel(t, c, "sk-gw-late"); got != "new-model" {
t.Fatalf("per-key chain must resolve after the source is added, got %q", got)
}
}
// chainSlotsOf counts usable slots, tolerating a nil chain.
func chainSlotsOf(ch *scheduler.Chain) int {
if ch == nil {
return 0
}
n := 0
for _, tn := range ch.Tiers {
n += len(tn.Slots)
}
return n
}

View File

@ -445,6 +445,15 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
return
}
out := g.stats.PeriodSnapshot(p, key, time.Now())
// The dashboard paints its records table from this payload's `records`
// field (paintRecords(st.records)), so a period view that omits the key
// renders an empty table no matter how much traffic the window had. The
// aggregate above is period-scoped; the record list is the same
// newest-first page the scroll handler pulls from /api/stats/records,
// which is deliberately not period-filtered (the cursor, not a date
// range, is its state) — that asymmetry is what the paging code
// already assumes.
page := g.stats.AuditPage("", limit, key)
writeJSON(w, http.StatusOK, map[string]interface{}{
"period": out.Period,
"from": out.From,
@ -455,7 +464,21 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
"by_status": out.Status,
"buckets": out.Bucket,
"truncated": out.Truncated,
"key_names": g.keyNamesFor(),
// "Active requests" is a live gauge (requests in flight right now),
// not a period aggregate. Omitting it from the period response made
// the KPI read `st.active || 0` → 0 on every day/week/month view while
// the all-time view showed the truth, which read as "the gateway is
// idle" precisely when it was busiest. A calendar window cannot
// filter it, so it travels with every response unfiltered.
"active": g.stats.Active(),
"records": page.Records,
// The paging cursor has to travel with the first page, otherwise
// the scroll handler has nothing to page from and the table is
// stuck at one screen forever.
"next_cursor": page.Next,
"has_more": page.HasMore,
"rotated": page.Rotated,
"key_names": g.keyNamesFor(),
})
return
}
@ -567,6 +590,23 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
}
snap := g.stats.Snapshot(limit, key)
snap["key_names"] = g.keyNamesFor()
// Records come from the audit files via the same AuditPage the scroll
// handler uses, not from Snapshot's in-memory ring. Two reasons:
//
// - Ordering. The ring is oldest-first, AuditPage is newest-first. The
// first screen and the paged continuation have to agree, or scrolling
// splices new rows in above old ones.
// - Depth. The ring holds maxRecs entries (a few hundred); the audit
// files hold everything, so "all" actually means all and the cursor
// can keep paging.
//
// It also carries next_cursor, without which the table can never load a
// second page.
page := g.stats.AuditPage("", limit, key)
snap["records"] = page.Records
snap["next_cursor"] = page.Next
snap["has_more"] = page.HasMore
snap["rotated"] = page.Rotated
writeJSON(w, http.StatusOK, snap)
}

View File

@ -256,7 +256,17 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
}
return nil
}
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
// A scope that simply does not list the model is a different problem from
// a quota being exhausted, and the message has to say which: telling an
// operator "not allowed for this key" when they configured a quota sends
// them to the wrong setting. AUTO is checked here before the chain is even
// consulted, so this is the message that surfaces for AUTO.
if isAuto(model) {
return &quotaRejection{msg: fmt.Sprintf(
"model %q is not in this key's model scope, so it cannot use AUTO; "+
"add %q to the key's models or remove the scope restriction", model, model)}
}
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
}
// quotaWindowSuffix describes a quota's reset window for an error message, so
@ -400,7 +410,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
// TestHooksFireOnRealDirectChat, not by reading the code.
g.fireStart(r.Context(), &req, "chat", model, len(req.Messages), len(req.Tools))
if isAuto(model) {
chain := g.core.AutoChain()
// The key's own chain wins; keys without one inherit the global chain.
chain, _ := g.core.AutoChainFor(reqKey(r.Context()))
if chain == nil || len(chain.Tiers) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
return
@ -451,6 +462,11 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
}
// Note: a key whose scope excludes AUTO is rejected earlier, by
// checkQuota(ctx, "AUTO") above, which produces the same message. That
// ordering matters — checkQuota runs before the chain is consulted, so it
// is the check an AUTO request actually hits. Duplicating it here would be
// dead code.
cands, effective := g.resolveCands(r.Context(), &req)
if len(cands) == 0 {
writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model))
@ -773,6 +789,18 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
// Cache accounting: whichever source format the adapter normalized into
// (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens),
// read it back so the request record carries the hit/miss split.
// reasoning_tokens is a SUBSET of completion_tokens and is billed at the
// completion rate by every provider measured, so it must NOT be added to
// Compl. Recording it separately is what lets a price rule split thinking
// from output for the providers that do price them differently, and what
// keeps the billing plugin from guessing.
//
// Leaving it unrecorded is worse than useless: the adapters already parse it
// and forward it (opencodego/gemini/anthropic all do), so the number exists
// in the response and is thrown away at the one place that could keep it.
if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
rec.Reasoning = int64(d.ReasoningTokens)
}
if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 {
rec.CacheHit = int64(d.CachedTokens)
}
@ -1028,6 +1056,9 @@ func (g *Gateway) fireEnd(rec *Req) {
// carried this (rec.CacheReported); the plugin payload did not, so any
// plugin-level cache accounting had to guess.
"cache_reported": rec.CacheReported,
// A SUBSET of completion_tokens, not an extra. Billing plugins that
// add it to completion would double-charge every thinking token.
"reasoning_tokens": rec.Reasoning,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,

View File

@ -0,0 +1,71 @@
package gateway
import (
"testing"
"llmsproxy/internal/types"
)
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
//
// The adapters already parse completion_tokens_details.reasoning_tokens and
// forward it (opencodego, gemini, anthropic all do), and types.go has carried
// the field since it was defined — but nothing ever READ it. The number arrived
// in the response and was discarded at the one place that could have kept it,
// so a pricing rule had no way to split thinking from output.
//
// The property that matters most: reasoning is a SUBSET of completion. Every
// provider measured bills it at the completion rate, so a billing plugin that
// added it to completion_tokens would double-charge every thinking token. The
// test asserts completion is left alone.
func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) {
rec := &Req{}
resp := &types.UnifiedResponse{
TokenUsage: types.TokenUsage{
Prompt: 100,
Completion: 500,
CompletionTokensDetails: &types.CompletionTokensDetails{
ReasoningTokens: 320,
},
},
}
recordChatUsage(rec, &types.ChatRequest{}, resp)
if rec.Reasoning != 320 {
t.Errorf("Reasoning = %d, want 320", rec.Reasoning)
}
// The subset must not be folded into completion — it is already counted there.
if rec.Compl != 500 {
t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl)
}
if rec.Prompt != 100 {
t.Errorf("Prompt = %d, want 100", rec.Prompt)
}
}
// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as
// zero" must both leave the field at 0, and neither may invent a number. A
// plugin reading Reasoning cannot tell them apart, which is why the field is
// documented as "0 means not reported" rather than "0 means no thinking".
func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
for _, tc := range []struct {
name string
u types.TokenUsage
}{
{"no details object", types.TokenUsage{Prompt: 10, Completion: 20}},
{"details without reasoning", types.TokenUsage{
Prompt: 10,
Completion: 20,
CompletionTokensDetails: &types.CompletionTokensDetails{},
}},
} {
rec := &Req{}
recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u})
if rec.Reasoning != 0 {
t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning)
}
if rec.Compl != 20 {
t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl)
}
}
}

View File

@ -0,0 +1,257 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"os"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// Per-key AUTO chain admin API. The behaviours pinned here are the ones that
// are easy to get wrong and invisible when they are:
//
// - /api/keys/me/auto must be reachable by a NON-admin (it is their own
// chain); routing it through the generic {key}/auto handler would 403 it.
// - /api/keys/{key}/auto must 403 a non-admin.
// - an empty PUT and a DELETE mean the same thing ("inherit global"), so the
// WebUI cannot persist a chain that would then 503 with no slots.
// - a PUT that resolves to zero slots is reported back, so an admin who
// mistyped a model learns it now instead of from the user's next 503.
// keyAutoGateway builds a gateway with one admin key and one user key.
func keyAutoGateway(t *testing.T) *Gateway {
t.Helper()
td := t.TempDir()
cfgPath := filepath.Join(td, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
Path: cfgPath,
AdapterDir: filepath.Join(td, "adapters"),
RuntimeFile: filepath.Join(td, "runtime.json"),
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-admin"},
Keys: []config.GWKey{
{Key: "sk-admin", Role: "admin", Name: "admin"},
{Key: "sk-user", Role: "user", Name: "user"},
},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
},
}
if err := cfg.ApplyDefaults(); err != nil {
t.Fatal(err)
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
return g
}
// doReqAs issues an authenticated request as a specific key.
func doReqAs(t *testing.T, g *Gateway, key, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req, _ := http.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rr := httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
return rr
}
func TestKeyAutoAPIPermissions(t *testing.T) {
g := keyAutoGateway(t)
// A user may read their own chain...
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me/auto", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys/me/auto as user = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
// ...but not another key's.
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/sk-admin/auto", ""); rr.Code != http.StatusForbidden {
t.Fatalf("GET other key's auto as user = %d, want 403", rr.Code)
}
// ...and not their own via the admin route either.
if rr := doReqAs(t, g, "sk-user", "PUT", "/api/keys/sk-user/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusForbidden {
t.Fatalf("user PUT own auto = %d, want 403 (only admin configures)", rr.Code)
}
}
func TestKeyAutoAPICrudAndInherit(t *testing.T) {
g := keyAutoGateway(t)
// Initially inherits.
var got struct {
Inherits bool `json:"inherits"`
Auto []config.ModelScope `json:"auto"`
}
rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if rr.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rr.Code, rr.Body.String())
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits || len(got.Auto) != 0 {
t.Fatalf("fresh key must inherit global, got inherits=%v auto=%v", got.Inherits, got.Auto)
}
// Admin sets a chain; the response reports the resolved slot count.
rr = doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"gpt-4o-mini","source":"s1","tier":1}]}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
var put struct {
Inherits bool `json:"inherits"`
Slots int `json:"slots"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
t.Fatal(err)
}
if put.Inherits {
t.Fatal("after a non-empty PUT the key must no longer inherit")
}
if put.Slots != 1 {
t.Fatalf("resolved slots = %d, want 1", put.Slots)
}
// And it reads back as an own chain.
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Inherits || len(got.Auto) != 1 || got.Auto[0].Model != "gpt-4o-mini" {
t.Fatalf("own chain must read back, got inherits=%v auto=%v", got.Inherits, got.Auto)
}
// DELETE restores inheritance.
if rr := doReqAs(t, g, "sk-admin", "DELETE", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rr.Code, rr.Body.String())
}
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits {
t.Fatal("after DELETE the key must inherit the global chain again")
}
}
// An empty PUT must behave like DELETE. Otherwise a WebUI that submits an
// empty list (every slot deleted in the editor) would persist an empty chain
// and the next AUTO request would fail with no_provider.
func TestKeyAutoAPIEmptyPutClearsOverride(t *testing.T) {
g := keyAutoGateway(t)
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1}]}`); rr.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rr.Code, rr.Body.String())
}
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto", `{"auto":[]}`)
if rr.Code != http.StatusOK {
t.Fatalf("empty PUT = %d: %s", rr.Code, rr.Body.String())
}
var got struct {
Inherits bool `json:"inherits"`
}
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
t.Fatal(rr.Body.String())
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits {
t.Fatal("an empty PUT must clear the override, not persist an empty chain")
}
}
// A chain naming a model that does not exist compiles to zero slots. The API
// must say so at write time instead of letting the user's next request 503.
func TestKeyAutoAPIReportsUnresolvableSlots(t *testing.T) {
g := keyAutoGateway(t)
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"does-not-exist","source":"s1","tier":1}]}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
var put struct {
Inherits bool `json:"inherits"`
Slots int `json:"slots"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
t.Fatal(err)
}
if put.Slots != 0 {
t.Fatalf("unknown model must resolve to 0 slots, got %d", put.Slots)
}
}
func TestKeyAutoAPIUnknownKey404(t *testing.T) {
g := keyAutoGateway(t)
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-nope/auto", ""); rr.Code != http.StatusNotFound {
t.Fatalf("GET unknown key auto = %d, want 404", rr.Code)
}
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-nope/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusNotFound {
t.Fatalf("PUT unknown key auto = %d, want 404", rr.Code)
}
}
// A bad quota must be rejected with 400, not persisted and not silently
// clamped — same rule as the model scope path.
func TestKeyAutoAPIRejectsBadQuota(t *testing.T) {
g := keyAutoGateway(t)
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1,"token_quota":-1}]}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("negative quota PUT = %d, want 400 (body %s)", rr.Code, rr.Body.String())
}
// Nothing may have been persisted.
var got struct {
Inherits bool `json:"inherits"`
}
get := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if get.Code != http.StatusOK {
t.Fatal(get.Body.String())
}
if err := json.Unmarshal(get.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits {
t.Fatal("a rejected PUT must not persist a chain")
}
}
// The other key endpoints must keep working: the {key}/auto route must not
// shadow /api/keys/{key} itself.
func TestKeyAutoAPIDoesNotShadowKeyRoutes(t *testing.T) {
g := keyAutoGateway(t)
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys = %d, want 200", rr.Code)
}
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys/me = %d, want 200", rr.Code)
}
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys/me as user = %d, want 200", rr.Code)
}
}

View File

@ -7,6 +7,7 @@ import (
"strings"
"llmsproxy/internal/config"
"llmsproxy/internal/scheduler"
)
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
@ -17,10 +18,22 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
path = strings.Trim(path, "/")
role := reqRole(r.Context())
// /api/keys/me/auto — a user's own chain, readable without admin rights.
// Must be matched before the generic {key}/auto case below so it does not
// ask admin rights of a plain user asking about their own chain.
if path == "me/auto" {
g.handleKeyMeAuto(w, r)
return
}
if path == "me" {
g.handleKeyMe(w, r)
return
}
// /api/keys/{key}/auto — the per-key AUTO chain editor.
if i := strings.LastIndex(path, "/auto"); i > 0 && path[i+len("/auto"):] == "" {
g.handleKeyAutoAPI(w, r, path[:i])
return
}
if role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
@ -139,6 +152,101 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
return rec.Models
}
// handleKeyAutoAPI manages one key's own AUTO chain:
// GET /api/keys/{key}/auto returns it, PUT replaces it, DELETE clears the
// override so the key falls back to the global chain. Admin only — a user can
// inspect their own chain through GET /api/keys/me/auto.
//
// An empty chain and a cleared chain are deliberately the same state: "inherit
// the global chain". That keeps the WebUI's "use global" toggle a plain DELETE
// with no sentinel value to carry through config.yaml.
func (g *Gateway) handleKeyAutoAPI(w http.ResponseWriter, r *http.Request, key string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
rec, ok := g.core.FindKey(key)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "key not found")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": key,
"auto": rec.Auto,
"inherits": !rec.HasOwnAuto(),
})
case http.MethodPut, http.MethodPatch:
var body struct {
Auto []config.ModelScope `json:"auto"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if len(body.Auto) == 0 {
// Treat an empty PUT as "give up the override" so the endpoint
// cannot persist a chain that would 503 with no slots.
if err := g.core.SaveKeyAuto(key, nil); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
return
}
if err := g.core.SaveKeyAuto(key, body.Auto); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
// Report whether the chain actually resolved to usable slots. An
// admin who typed a model that no longer exists should learn it here,
// not from the user's next 503.
chain, _ := g.core.AutoChainFor(key)
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true, "inherits": false, "slots": chainSlots(chain),
})
case http.MethodDelete:
if err := g.core.SaveKeyAuto(key, nil); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// chainSlots counts the usable slots in a chain, for API feedback after an
// edit. A chain that compiled to zero slots is reported so the caller can warn
// instead of letting the next request fail with no_provider.
func chainSlots(ch *scheduler.Chain) int {
if ch == nil {
return 0
}
n := 0
for _, tn := range ch.Tiers {
n += len(tn.Slots)
}
return n
}
// handleKeyMeAuto lets a user read their own AUTO chain without admin rights.
func (g *Gateway) handleKeyMeAuto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
rec, ok := g.core.FindKey(reqKey(r.Context()))
if !ok {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": rec.Key, "auto": rec.Auto, "inherits": !rec.HasOwnAuto(),
})
}
// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the
// current rules; PUT /api/auto replaces them (admin only).
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {

View File

@ -0,0 +1,150 @@
package gateway
// The /api/plugins/{name}/call/{fn} endpoint splits its path with string
// surgery, and the split point is easy to get wrong in a way that still
// "works": keeping the "call/" segment in the function name produced
// `does not export "call/collect_account"` — the plugin was correct, the router
// was not, and every call 502'd while the exports list looked perfectly fine.
//
// These tests drive the real router, so a refactor that reintroduces the
// off-by-one is caught here rather than in production.
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// callPlugin exposes exactly one export plus a function that is NOT exported.
// If the exports gate is not honoured, secretFn is reachable and with it the
// plugin's private state — for a plugin holding an API key that means handing
// the key to anyone who can POST to the gateway.
const callPlugin = `
local P = {}
P.name = "calltest"
P.version = "1.0.0"
P.exports = { "add" }
P.secret = "do-not-leak"
function P.add(a)
return { result = (tonumber(a and a.x) or 0) + 1 }
end
function P.secretFn()
return { leaked = P.secret }
end
return P
`
func gatewayWithCallPlugin(t *testing.T) *Gateway {
t.Helper()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
body := "listen: :0\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"gateway_keys:\n - sk-test\n"
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil {
t.Fatalf("load calltest: %v", err)
}
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
return g
}
func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPost,
"/api/plugins/calltest/call/add", `{"x":41}`)
if rr.Code != http.StatusOK {
t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String())
}
var out struct {
Result struct {
Result int `json:"result"`
} `json:"result"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
}
// 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/"
// segment did not leak into the function name, and the payload arrived.
if out.Result.Result != 42 {
t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+
"function name or the argument table never reached Lua", out.Result.Result)
}
}
func TestPluginCallRefusesUndeclaredFunction(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPost,
"/api/plugins/calltest/call/secretFn", `{}`)
if rr.Code == http.StatusOK {
t.Fatalf("an unexported function must not be callable, got 200: %s",
rr.Body.String())
}
if strings.Contains(rr.Body.String(), "do-not-leak") {
t.Fatal("the refusal leaked the plugin's private state")
}
}
// A plugin that declares no exports at all has no callable surface. Reading the
// missing list as "allow everything" would turn every plugin into a remote
// evaluator the moment this endpoint ships.
func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) {
g := gatewayWithCallPlugin(t)
const bare = `
local P = {}
P.name = "bare"
P.version = "1.0.0"
function P.anything()
return { ok = true }
end
return P
`
if err := g.core.Plugins().LoadSource("bare", bare); err != nil {
t.Fatalf("load bare: %v", err)
}
rr := doReq(t, g, http.MethodPost,
"/api/plugins/bare/call/anything", `{}`)
if rr.Code == http.StatusOK {
t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s",
rr.Body.String())
}
}
func TestPluginCallIsAdminOnly(t *testing.T) {
g := gatewayWithCallPlugin(t)
// A real user-role key, not a bare one from config: sk-test/sk-user in the
// config both default to admin, so a test using them would pass the gate for
// the wrong reason and prove nothing.
rec, err := g.core.CreateKey("viewer", "user", nil, "")
if err != nil {
t.Fatal(err)
}
rr := doReqAs(t, g, rec.Key, http.MethodPost,
"/api/plugins/calltest/call/add", `{"x":1}`)
if rr.Code != http.StatusForbidden {
t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s",
rr.Code, rr.Body.String())
}
}

View File

@ -0,0 +1,207 @@
package gateway
// Mount for plugin-registered HTTP endpoints.
//
// The gateway owns the listener, the authentication, the role check and the
// response framing; the plugin owns the logic. That split is the point: a plugin
// cannot open a socket, cannot bypass auth, and cannot choose a path outside
// /api/plugins/http/.
//
// Why this exists rather than "plugins can call each other": some things are
// worth querying from OUTSIDE the browser — a CI job asking whether the upstream
// balance is above zero, a monitoring probe, another tool on the box. billing
// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a
// route per plugin would mean a gateway change for every such plugin, and an
// arbitrary plugin's code in the main mux is not something the routing table
// should ever grow.
import (
"net/http"
"strconv"
"strings"
"llmsproxy/internal/lua"
)
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
// routes live under one prefix, which makes them greppable, makes them easy to
// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats.
const pluginHTTPPrefix = "/api/plugins/http/"
// handlePluginHTTP dispatches a request to whichever plugin registered the
// longest matching path.
//
// Longest-match rather than first-match: a plugin may register /health and
// another /health/deep, and both must work. With registration order deciding it,
// the outcome would depend on load order and change when a plugin is added.
func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix)
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
matches := ps.HTTPHandlers()
var chosen *lua.HTTPHandler
var params map[string]string
for i := range matches {
h := matches[i]
if p, ok := matchHandlerPath(h.Path, rest); ok {
if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) {
chosen, params = &h, p
}
}
}
if chosen == nil {
writeError(w, http.StatusNotFound, "not_found",
"no plugin serves this path")
return
}
if !strings.EqualFold(r.Method, chosen.Method) {
w.Header().Set("Allow", chosen.Method)
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
"this endpoint accepts "+chosen.Method+" only")
return
}
role := reqRole(r.Context())
if !chosen.Allows(role, reqKeyName(r.Context())) {
writeError(w, http.StatusForbidden, "forbidden",
handlerAccessHint(*chosen))
return
}
body, err := readBody(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
req := &lua.HandlerRequest{
Method: r.Method,
Path: rest,
Query: flattenQuery(r.URL.Query()),
Params: params,
Headers: flattenHeader(r.Header),
Body: body,
Role: role,
Key: reqKey(r.Context()),
}
resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req)
if err != nil {
// A plugin error is a 502, not a 500: the gateway is fine, the plugin is
// not, and the operator needs to tell those apart in their logs.
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
lua.WriteHandlerResponse(w, resp)
}
// handlerAccessHint explains, in the refusal, exactly which declaration would
// open this endpoint. A 403 with no reason is the kind of thing an operator
// opens the source to work out, and then the fix is a guess.
func handlerAccessHint(h lua.HTTPHandler) string {
switch {
case len(h.Keys) > 0:
return "this endpoint allows only the named keys (" +
strings.Join(h.Keys, ", ") + "); authenticate with one of them"
case len(h.Roles) > 0:
return "this endpoint requires role " + strings.Join(h.Roles, " or ") +
" (widen it in the plugin's http declaration, or set public=true)"
default:
return "this endpoint declares no roles, so it is admin-only; " +
"set roles=… or keys=… in the plugin's http declaration"
}
}
// handlerOwner returns the plugin name that registered h. The gateway stores the
// owner alongside the handler precisely so this does not have to be guessed from
// the path prefix.
func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin }
// segmentCount is how deep a route is, used for longest-match. A path with more
// segments wins, so /health/deep beats /health when both are registered.
func segmentCount(p string) int { return strings.Count(p, "/") }
// matchHandlerPath matches a route pattern against a concrete path, capturing
// ":name" segments.
//
// Returns ok=false on a segment-count mismatch or a missing value for a
// param — a route must not match with an empty capture, or two different URLs
// would reach the same handler indistinguishably.
func matchHandlerPath(pattern, path string) (map[string]string, bool) {
pp := strings.Split(strings.Trim(pattern, "/"), "/")
sp := strings.Split(strings.Trim(path, "/"), "/")
if len(pp) != len(sp) {
return nil, false
}
var params map[string]string
for i := range pp {
if strings.HasPrefix(pp[i], ":") {
name := pp[i][1:]
if sp[i] == "" {
return nil, false
}
if params == nil {
params = map[string]string{}
}
params[name] = sp[i]
continue
}
if pp[i] != sp[i] {
return nil, false
}
}
return params, true
}
func flattenQuery(q map[string][]string) map[string]string {
out := make(map[string]string, len(q))
for k, v := range q {
if len(v) > 0 {
out[k] = v[0]
}
}
return out
}
func flattenHeader(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k := range h {
out[strings.ToLower(k)] = h.Get(k)
}
return out
}
func readBody(r *http.Request) (string, error) {
if r.Body == nil {
return "", nil
}
defer r.Body.Close()
// A cap, because the handler runs inside the gateway process: an unbounded
// body from an authenticated caller is a memory exhaustion vector.
const maxBody = 4 << 20
buf := make([]byte, 0, 4096)
tmp := make([]byte, 32*1024)
for {
n, err := r.Body.Read(tmp)
if n > 0 {
if len(buf)+n > maxBody {
return "", &bodyTooLarge{}
}
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
return string(buf), nil
}
type bodyTooLarge struct{}
func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" }
// limitBody is kept for the numeric form some callers want in a log line.
var _ = strconv.Itoa

View File

@ -93,6 +93,30 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
return
}
// /api/plugins/{name}/http/{path}/access — the operator's view of, and edit
// surface for, which keys an endpoint answers.
if i := strings.Index(path, "/http/"); i >= 0 {
name := path[:i]
rest := path[i+len("/http/"):]
if strings.HasSuffix(rest, "/access") {
epPath := strings.TrimSuffix(rest, "/access")
g.handleEndpointAccess(w, r, name, epPath)
return
}
}
// /api/plugins/{name}/call/{fn}
if i := strings.Index(path, "/call/"); i >= 0 {
name := path[:i]
fn := path[i+len("/call/"):]
if name == "" || fn == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name")
return
}
g.handlePluginCall(w, r, name, fn)
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
@ -106,6 +130,11 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
"on_disk": ps.OnDisk(),
"hook_errors": ps.HookErrors(),
"plugin_dir": g.pluginDir(),
// Visibility overrides and the endpoint access table, so the
// plugin page renders what is IN FORCE rather than making the
// operator read .lua files or config.yaml to find out.
"visibility": ps.VisibilityOverrides(),
"http_access": ps.EndpointAccess(),
})
case http.MethodPost:
var body struct {
@ -211,15 +240,33 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
// Any role may read: plugin state is reporting data (cost, counts),
// and the caller has already been authenticated. Admin-only would stop
// a user key's own billing widget from rendering.
// The plugin's audience gates its DATA as well as its page. Hiding the UI
// while leaving GET /state open would put the numbers one curl away, which
// is the leak the audience field exists to close — and billing's state is
// a global ledger, not the caller's own usage.
//
// A plugin that declares no audience is unchanged (every role may read
// it), so a single-purpose widget keeps working without edits.
role := reqRole(r.Context())
if !ps.StateVisibleTo(name, role) {
writeError(w, http.StatusForbidden, "forbidden",
"plugin "+name+" is not visible to role "+role)
return
}
// prices rides along: the billing rules editor must show the table
// that is actually in effect, not a reconstruction from config. It is
// absent for plugins with no prices (every plugin but billing).
state := ps.State(name)
// A plugin may SPLIT its state: global buckets stay deployment-wide while
// per-caller buckets are narrowed to the authenticated key. Role
// visibility alone cannot express this — the same role is allowed to read
// the plugin, just not another caller's row.
if scope := ps.StateScopeFor(name); scope != nil && role != "admin" {
state = splitStateForKey(state, scope, reqKey(r.Context()))
}
payload := map[string]interface{}{
"plugin": name,
"state": ps.State(name),
"state": state,
}
if pr := ps.Prices(name); pr != nil {
payload["prices"] = pr
@ -245,6 +292,45 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
}
}
// Call invokes a named function on a plugin's Lua state and returns its result.
//
// This exists because plugin pages otherwise have NO way to reach their own
// plugin code: pluginAPI offers state read/write and a couple of callbacks, and
// that is enough for billing (whose numbers arrive on hooks during request
// forwarding) but useless for a plugin that must do work on demand. A quota
// page has to ask the vendor when the operator clicks Refresh, and no hook
// fires at that moment.
//
// It is deliberately narrow:
// - The function must be one the plugin explicitly lists in plugin.exports.
// Anything else is refused, so this endpoint cannot become a generic "run
// arbitrary Lua with my credentials" oracle over a plugin's private state.
// - The caller must be admin, and the result is JSON — no Lua value is ever
// handed back to Go as a live closure.
// - Errors come back as (nil, message) from the plugin, matching every other
// plugin entry point, and surface as a 502 rather than raising.
func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
return
}
var args map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
result, err := g.core.Plugins().Call(name, fn, args)
if err != nil {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
}
func (g *Gateway) pluginDir() string {
if c := g.core.Config(); c != nil {
return c.PluginDir
@ -282,3 +368,178 @@ func (g *Gateway) removePlugin(name string) error {
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
return g.core.Plugins().Unload(name)
}
// handleEndpointAccess serves the operator's view and edit surface for one
// plugin endpoint's reachable keys.
//
// GET …/http/{path}/access the declared rules plus any override
// PUT …/http/{path}/access body {"access": "admin,@homeagent"}
// DELETE …/http/{path}/access clear the override
//
// The spec string is a comma list: a bare word is a role, "@name" is a gateway
// key's configured name, and the literal "public" opens it to any
// authenticated key. That is the same grammar the visibility override uses, and
// keeping one grammar means an operator learns it once.
//
// Key names are validated against the configured keys BEFORE being stored. An
// allowlist naming a key that does not exist is a silent no-op — every caller
// is refused — and the operator would have no way to tell that apart from the
// endpoint simply working.
func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
ps := g.core.Plugins()
// epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It
// shares this route (and therefore this store) on purpose: an operator
// setting "who can see this plugin" and "who can call this endpoint" is
// asking the same question at two granularities, and two stores would let
// them disagree. SetEndpointAccess cannot serve it — it validates that the
// endpoint exists, which "*" by definition does not.
if epPath == pluginWholeOverride {
g.handlePluginVisibility(w, r, name)
return
}
switch r.Method {
case http.MethodGet:
for _, e := range ps.EndpointAccess() {
if e.Plugin == name && e.Path == epPath {
writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e})
return
}
}
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := ps.SetEndpointAccess(name, epPath, spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
// the buckets that belong to them.
//
// The plugin declares which buckets are global and which are per-key. A per-key
// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log
// uses), so the caller's own row is found by masking the presented secret and
// matching the last six characters.
//
// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as
// "you spent nothing" and, worse, would leak that a row exists for someone else.
func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} {
m, ok := state.(map[string]interface{})
if !ok || scope == nil || key == "" {
return state
}
masked := maskKey(key)
out := make(map[string]interface{}, len(m))
for k, v := range m {
if !scope.IsGlobal(k) {
// Per-key bucket: keep only the caller's rows, at every nesting level
// the scope mentions (by_day_key is {day: {masked: bucket}}).
out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k))
continue
}
out[k] = v
}
return out
}
// filterKeyBuckets narrows a keyed structure to one masked key.
//
// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is
// declared rather than sniffed, because both shapes are dicts and sniffing would
// confuse an empty bucket with a nested one.
func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
switch depth {
case 0:
return v // not a per-key bucket; leave alone
case 1:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
if row, ok := m[masked]; ok {
return map[string]interface{}{masked: row}
}
return map[string]interface{}{}
default:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
out := make(map[string]interface{}, len(m))
for day, inner := range m {
im, ok := inner.(map[string]interface{})
if !ok {
continue
}
if row, ok := im[masked]; ok {
out[day] = map[string]interface{}{masked: row}
}
}
return out
}
}
// pluginWholeOverride is the epPath that addresses a plugin's overall visibility
// rather than one of its endpoints.
const pluginWholeOverride = "*"
// handlePluginVisibility is the whole-plugin half of the access API. The
// audience string uses the same vocabulary as an endpoint spec (roles, @key
// names, public) so the UI can drive both from one input.
func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugin": name,
"visibility": ps.VisibilityOverrides()[name],
})
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
ps.SetPluginVisibility(name, spec)
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
}
}

View File

@ -210,3 +210,54 @@ func TestPluginStatePUTIsAdminOnly(t *testing.T) {
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
}
}
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
// overrides and the endpoint access table from /api/plugins. Without them in the
// response the operator has to open a .lua or config.yaml to learn who can see
// what, which is exactly what the UI is meant to avoid.
func TestPluginsListCarriesAccessTables(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
if rr.Code != http.StatusOK {
t.Fatalf("GET /api/plugins = %d", rr.Code)
}
var out struct {
Visibility map[string]string `json:"visibility"`
HTTPAccess []map[string]interface{} `json:"http_access"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
}
// The calltest plugin registers no endpoints, so the table is present but
// empty — the KEY is that the field exists at all. A missing field decodes to
// nil and the UI silently renders nothing, which looks identical to "this
// plugin has no endpoints".
if !strings.Contains(rr.Body.String(), "http_access") {
t.Error("/api/plugins response lacks the http_access field")
}
if !strings.Contains(rr.Body.String(), "visibility") {
t.Error("/api/plugins response lacks the visibility field")
}
}
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
// edits a plugin's overall audience through the same route it edits endpoints
// with. Before this existed the UI had a visibility selector whose save 404'd,
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
`{"access":"admin, user"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), `"ok":true`) {
t.Errorf("unexpected body: %s", rr.Body.String())
}
// A user role is a legitimate audience; a nonsense one is not.
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
`{"access":"../../etc"}`)
if bad.Code != http.StatusBadRequest {
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
}
}

View File

@ -235,6 +235,10 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
// treats anything after the name as a plugin sub-resource and would
// 404 on "rules" instead of reaching the billing editor.
g.handleBillingRules(w, r)
case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix):
// Before the generic plugin router: these paths belong to a PLUGIN, not to
// the plugin manager, and "http" is a valid sub-resource name otherwise.
g.handlePluginHTTP(w, r)
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
g.handlePluginsAPI(w, r)
case r.URL.Path == "/api/chat":
@ -305,20 +309,43 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role)))
next.ServeHTTP(w, r.WithContext(withAuthNamed(r.Context(), key, rec.Role, rec.Name)))
})
}
// authCtx carries the authenticated gateway key and its role.
// authCtx carries the authenticated gateway key, its role, and the key's
// configured NAME.
//
// The name is here because plugin HTTP endpoints authorise by key name rather
// than by secret, and looking the name up from the key at the point of use would
// mean reading config on every request. It is "" for a key configured without
// one, which such an endpoint can never match.
type authCtx struct {
key string
role string
name string
}
type authCtxKeyT struct{}
func withAuth(ctx context.Context, key, role string) context.Context {
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role})
return withAuthNamed(ctx, key, role, "")
}
// withAuthNamed is withAuth plus the key's configured name.
func withAuthNamed(ctx context.Context, key, role, name string) context.Context {
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role, name: name})
}
// reqKeyName returns the authenticated key's configured name, or "" if it has
// none. Plugin endpoint authorisation is written against this, not against the
// secret: a secret is rotated without notice, so a rule keyed on it would
// silently stop matching.
func reqKeyName(ctx context.Context) string {
if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok {
return a.name
}
return ""
}
// reqKey returns the authenticated gateway key id (masked suffix for display).

View File

@ -43,6 +43,16 @@ type Req struct {
// accounting at all (even when the hit count is 0). The WebUI shows
// "0%" instead of "—" for such rows.
CacheReported bool `json:"cache_reported,omitempty"`
// Reasoning is completion_tokens_details.reasoning_tokens.
//
// It is a SUBSET of Compl, not an addition: reasoning tokens are billed at
// the completion rate and already counted there. This field exists so a
// pricing rule can split them when a provider prices thinking tokens
// differently (most do NOT — OpenAI and DeepSeek bill them as ordinary
// output), and so a reader can see what fraction of a bill is thinking.
// 0 means "not reported", which is NOT the same as "no reasoning happened" —
// three of the sources measured return no such field at all.
Reasoning int64 `json:"reasoning_tokens,omitempty"`
OK bool `json:"ok"`
// Status http status code
Status int `json:"status"`
@ -1211,3 +1221,16 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
"replay_partial": s.replayPartial,
}
}
// Active reports requests in flight right now.
//
// It is a live gauge, not an aggregate: there is no calendar period that scopes
// it, which is why the period-scoped /api/stats response has to carry it
// explicitly. Before it did, the day/week/month views fed `st.active || 0` a
// missing key and showed a hard 0 — the same gateway that was serving hundreds
// of requests a second looked idle in three of the four views.
func (s *Stats) Active() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.active
}

View File

@ -0,0 +1,159 @@
package gateway
import (
"encoding/json"
"net/url"
"path/filepath"
"testing"
"time"
)
// The dashboard paints its records table from the `records` field of
// /api/stats (paintRecords(st.records)). The period branch of that handler
// used to hand-write a response map and simply omit the key, so switching the
// selector to day / week / month rendered an EMPTY table no matter how much
// traffic the window had — while the lifetime view worked, because it goes
// through Snapshot() which carries records.
//
// These tests pin the contract across every period: same records, same cursor.
func TestStatsPeriodViewsShipRecords(t *testing.T) {
td := t.TempDir()
g := newTestGateway(t)
// Write audit records the period aggregator and the record pager both read.
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
now := time.Now().UnixMilli()
for i := 0; i < 5; i++ {
g.stats.Record(Req{
Time: now - int64(i)*1000, Type: "chat", OK: true,
Model: "m1", Source: "s1", Status: 200,
Prompt: 10, Compl: 2,
})
}
for _, p := range []string{"day", "week", "month", "all"} {
rr := doReq(t, g, "GET", "/api/stats?period="+p, "")
if rr.Code != 200 {
t.Fatalf("period=%s: HTTP %d: %s", p, rr.Code, rr.Body.String())
}
var st map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
t.Fatalf("period=%s: %v", p, err)
}
recs, ok := st["records"].([]interface{})
if !ok {
t.Fatalf("period=%s: response has no `records` array — the dashboard "+
"records table renders empty for this view (keys: %v)", p, keysOf(st))
}
if len(recs) != 5 {
t.Fatalf("period=%s: got %d records, want 5", p, len(recs))
}
// The scroll handler pages from next_cursor; without it the table is
// stuck at one screen no matter how far the user scrolls.
if _, ok := st["next_cursor"]; !ok {
t.Fatalf("period=%s: no next_cursor — paging can never continue", p)
}
}
}
// The records shipped with a period view must be the newest-first page the
// paging endpoint would return, in the same order. If /api/stats and
// /api/stats/records disagreed, scrolling would duplicate or skip rows.
func TestStatsPeriodRecordsMatchPager(t *testing.T) {
td := t.TempDir()
g := newTestGateway(t)
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
now := time.Now().UnixMilli()
for i := 0; i < 8; i++ {
g.stats.Record(Req{
Time: now - int64(i)*1000, Type: "chat", OK: true,
Model: "m1", Status: 200, Prompt: 5, Compl: 1,
})
}
rr := doReq(t, g, "GET", "/api/stats?period=day&limit=100", "")
var st struct {
Records []struct {
Time int64 `json:"time"`
Model string `json:"model"`
} `json:"records"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
t.Fatal(err)
}
rr2 := doReq(t, g, "GET", "/api/stats/records?limit=100", "")
var pg struct {
Records []struct {
Time int64 `json:"time"`
Model string `json:"model"`
} `json:"records"`
}
if err := json.Unmarshal(rr2.Body.Bytes(), &pg); err != nil {
t.Fatal(err)
}
if len(st.Records) != len(pg.Records) {
t.Fatalf("period view shipped %d records, pager returned %d — the two "+
"sources must agree or scrolling duplicates/skips rows",
len(st.Records), len(pg.Records))
}
for i := range st.Records {
if st.Records[i].Time != pg.Records[i].Time {
t.Fatalf("record %d differs: period view t=%d, pager t=%d",
i, st.Records[i].Time, pg.Records[i].Time)
}
}
// The two sources must agree on DIRECTION as well as content. Which
// direction that is depends on how many reverse-read chunks the audit file
// spans (a small file is read whole, a large one chunk-by-chunk), so the
// test asserts agreement rather than hard-coding newest-first — but they
// can never disagree, because the table interleaves both streams.
if len(st.Records) > 1 {
periodAsc := st.Records[1].Time > st.Records[0].Time
pagerAsc := pg.Records[1].Time > pg.Records[0].Time
if periodAsc != pagerAsc {
t.Fatalf("direction differs: period view %s, pager %s — the table "+
"would splice the two streams out of order",
map[bool]string{true: "oldest-first", false: "newest-first"}[periodAsc],
map[bool]string{true: "oldest-first", false: "newest-first"}[pagerAsc])
}
}
}
// A non-admin caller cannot widen their view by passing someone else's key in
// the query string: exportKey overrides ?key= with the caller's own masked id.
// This is a data-leak boundary, and it applies to the records the period view
// ships just as much as to the aggregates.
func TestStatsPeriodRecordsIgnoreForeignKeyForUsers(t *testing.T) {
td := t.TempDir()
g := newTestGateway(t)
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
other := keyID("sk-gw-cccccccccccccccc")
own := keyID("sk-test")
now := time.Now().UnixMilli()
g.stats.Record(Req{Time: now, OK: true, Model: "m", Status: 200, Key: other})
g.stats.Record(Req{Time: now - 1000, OK: true, Model: "m", Status: 200, Key: own})
// sk-test authenticates as a non-admin (newTestGateway seeds it under
// gateway_keys, not keys), so ?key=<other> must be ignored and the caller
// sees only its own rows.
rr := doReq(t, g, "GET",
"/api/stats?period=day&key="+url.QueryEscape(other), "")
if rr.Code != 200 {
t.Fatalf("HTTP %d: %s", rr.Code, rr.Body.String())
}
var st struct {
Records []struct {
Key string `json:"key"`
} `json:"records"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
t.Fatal(err)
}
for _, r := range st.Records {
if r.Key == other {
t.Fatalf("another key's record leaked into a user view "+
"(caller=%s, requested=%s)", own, other)
}
}
}

View File

@ -319,3 +319,116 @@ func TestPeriodFromQuery(t *testing.T) {
}
}
}
// TestPeriodWeekMayStartBeforeMonth pins a fact that looks like a bug and
// gets reported as one: the calendar periods are NOT nested.
//
// On 2026-10-03 (a Saturday) the ISO week runs Mon 2026-09-28 .. Sat
// 10-03, while the month window is 10-01 .. 10-03. The week therefore reaches
// back into the previous month and its total is legitimately LARGER than the
// month's. Anyone reading only the totals will conclude the aggregator is
// broken; it is not, and this test exists so a future "fix" that makes the
// windows nested (e.g. clamping the week to the month) is caught as the
// regression it would be.
func TestPeriodWeekMayStartBeforeMonth(t *testing.T) {
// Saturday 2026-10-03, UTC.
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
weekFrom := periodStart(PeriodWeek, now)
monthFrom := periodStart(PeriodMonth, now)
weekFromD := time.UnixMilli(weekFrom).UTC()
monthFromD := time.UnixMilli(monthFrom).UTC()
if got := weekFromD.Format("2006-01-02"); got != "2026-09-28" {
t.Fatalf("week window start = %s, want 2026-09-28 (the Monday)", got)
}
if got := monthFromD.Format("2006-01-02"); got != "2026-10-01" {
t.Fatalf("month window start = %s, want 2026-10-01", got)
}
if !weekFromD.Before(monthFromD) {
t.Fatal("this test only means anything when the week starts before the month")
}
// The week must reach strictly further back than the month window does,
// otherwise the nesting intuition would hold and the UI note would be noise.
if back := monthFromD.Sub(weekFromD).Hours() / 24; back <= 0 {
t.Fatalf("week should start at least a day before the month window, got %v days back", back)
}
}
// The same overlap must be visible in the aggregate numbers, not just in the
// window bounds: a week total larger than a month total is the exact symptom
// an operator reported.
func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
dir := t.TempDir()
audit := filepath.Join(dir, "audit.jsonl")
// Two records before the month window (inside this ISO week) and one
// inside October.
pre := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC).UnixMilli()
in1 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
in2 := time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC).UnixMilli()
auditPeriod(t, audit,
Req{Time: pre, OK: true, Prompt: 100, Compl: 10},
Req{Time: pre + 1000, OK: true, Prompt: 100, Compl: 10},
Req{Time: in1, OK: true, Prompt: 50, Compl: 5},
Req{Time: in2, OK: true, Prompt: 50, Compl: 5},
)
s := periodStats(t, audit)
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
week := s.PeriodSnapshot(PeriodWeek, "", now)
month := s.PeriodSnapshot(PeriodMonth, "", now)
if week.Total.Reqs != 4 {
t.Fatalf("week reqs = %d, want 4 (both September records fall in this ISO week)", week.Total.Reqs)
}
if month.Total.Reqs != 2 {
t.Fatalf("month reqs = %d, want 2", month.Total.Reqs)
}
if week.Total.Tokens <= month.Total.Tokens {
t.Fatalf("week tokens (%d) must exceed month tokens (%d): the week reaches into September",
week.Total.Tokens, month.Total.Tokens)
}
// Day must be a strict subset of both, which is the only nesting that holds.
day := s.PeriodSnapshot(PeriodDay, "", now)
if day.Total.Reqs != 1 {
t.Fatalf("day reqs = %d, want 1", day.Total.Reqs)
}
if day.Total.Tokens >= month.Total.Tokens || day.Total.Tokens >= week.Total.Tokens {
t.Fatal("today must be contained in both the week and the month totals")
}
}
// TestPeriodStatsCarryActiveGauge pins the live "active requests" gauge onto
// every period response.
//
// It is a live gauge, not an aggregate, so no calendar window can scope it — but
// the period response is what the day/week/month views render from, and it used
// to omit the field entirely. The UI then evaluated `st.active || 0` and printed
// a hard 0 in three of four views, while the all-time view (whose response did
// carry the field) showed the truth. Reading "0 active" off a busy gateway is
// worse than reading nothing: it is a confident wrong answer.
func TestPeriodStatsCarryActiveGauge(t *testing.T) {
for _, period := range []string{"day", "week", "month", "all"} {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodGet, "/api/stats?period="+period, "")
if rr.Code != http.StatusOK {
t.Fatalf("period=%s: %d", period, rr.Code)
}
var out map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("period=%s decode: %v", period, err)
}
v, ok := out["active"]
if !ok {
t.Errorf("period=%s response has no active field; the UI renders 0", period)
continue
}
if _, ok := v.(float64); !ok {
t.Errorf("period=%s active is %T, want a number", period, v)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,438 @@
package gateway
import (
"os"
"strings"
"testing"
)
// Per-key AUTO chain UI contract.
//
// The per-key editor deliberately REUSES the global lane canvas instead of
// shipping a second, simpler editor. That is a design choice with teeth: a
// reduced copy would lack drag ordering and per-slot quota/period/hours, and
// the two would drift apart silently. These checks pin the sharing, because
// the only symptom of un-sharing is "the per-key editor got worse" — no error,
// just a smaller feature set that nobody notices until they use it.
func TestUIPerKeyAutoWiring(t *testing.T) {
src := uiSource(t)
// The admin key list must offer the editor, otherwise the whole feature is
// unreachable from the UI. Assert the *call site* inside keyCanvasHtml,
// not just that the functions exist: removing the button leaves every
// definition intact, and that is exactly the regression to catch.
kcStart := strings.Index(src, "function keyCanvasHtml")
if kcStart < 0 {
t.Fatal("keyCanvasHtml is gone — cannot check the AUTO chain button")
}
kcEnd := strings.Index(src[kcStart:], "\n }")
if kcEnd < 0 {
t.Fatal("could not delimit keyCanvasHtml")
}
if !strings.Contains(src[kcStart:kcStart+kcEnd], "openKeyAutoModal(") {
t.Fatal("per-key AUTO chain button is missing from the key card header")
}
for _, fn := range []string{
"function openKeyAutoModal",
"function keyAutoInherit",
"function keyAutoSave",
"function keyAutoClose",
} {
if !strings.Contains(src, fn) {
t.Fatalf("UI is missing %s — the editor cannot function", fn)
}
}
}
// The per-key editor must render the SAME canvas the global editor uses.
// If this regresses, the per-key chain silently loses drag ordering and
// per-slot quota/period/hours while still looking like it works.
func TestUIPerKeyAutoReusesGlobalCanvas(t *testing.T) {
src := uiSource(t)
// One canvas implementation, two mount ids.
if !strings.Contains(src, "function renderSortEditor(") {
t.Fatal("no shared canvas renderer — the per-key editor must reuse renderSortEditor")
}
if !strings.Contains(src, `canvasId: "scr-canvas"`) {
t.Fatal("the global editor no longer mounts the shared canvas")
}
if !strings.Contains(src, `canvasId: "key-auto-canvas"`) {
t.Fatal("the per-key editor does not mount the shared canvas")
}
// Canvas lookup must consider both ids, or drag/drop silently no-ops on
// whichever surface is not #scr-canvas.
if !strings.Contains(src, "function sortCanvasEl(") ||
!strings.Contains(src, `getElementById("key-auto-canvas")`) {
t.Fatal("sortCanvasEl must resolve both canvas ids")
}
// The rules<->lanes conversion is shared too, so the per-key editor sends
// exactly the fields the server understands (quota/period/hours included).
for _, fn := range []string{"function buildLanes(", "function lanesToRules("} {
if !strings.Contains(src, fn) {
t.Fatalf("%s must be shared between the global and per-key editors", fn)
}
}
}
// Saving must route through persistAuto's scope switch, not a hand-rolled
// request, or the two editors will serialize rules differently.
func TestUIPerKeyAutoSavesThroughScope(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "sortState.scope = key") {
t.Fatal("openKeyAutoModal must set sortState.scope so the save targets the key")
}
if !strings.Contains(src, "sortState.scope = null") {
t.Fatal("closing the modal must clear sortState.scope, or the GLOBAL editor would save to this key")
}
if !strings.Contains(src, "if (sortState.scope)") {
t.Fatal("persistAuto must branch on sortState.scope")
}
if !strings.Contains(src, `"/auto"`) {
t.Fatal("per-key editor must call the /api/keys/{key}/auto endpoint")
}
// An empty lane list is how "inherit the global chain" is expressed.
if !strings.Contains(src, "JSON.stringify({ auto: lanesToRules(sortState.lanes) })") {
t.Fatal("keyAutoSave must PUT lanesToRules(sortState.lanes) verbatim")
}
}
// Both languages need the strings, otherwise a non-Chinese admin sees raw
// i18n keys in the modal.
func TestUIPerKeyAutoHasBothLanguages(t *testing.T) {
src := uiSource(t)
for _, k := range []string{
"kAutoChainTitle", "kAutoChainHint", "kAutoChainInherit",
"kAutoChainEmpty", "kAutoChainGlobal", "kAutoChainBad",
} {
n := strings.Count(src, k+`: "`)
if n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// The statistics dashboard must print the window it actually covers. Calendar
// periods are not nested — "this week" starts on its Monday and therefore
// reaches into the previous month early in a month, making its total LARGER
// than "this month". That is correct arithmetic, but without the dates on
// screen it reads as a bug, and it has been reported as one.
func TestUIStatsShowsPeriodWindow(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, `id="period-range"`) {
t.Fatal("the stats dashboard has no element for the period window range")
}
if n := strings.Count(src, "periodRange"); n < 2 {
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
}
}
// Both canvases can exist in the DOM at once: the modal is mounted on top of
// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
// canvas by document order (whichever comes first) painted the per-key chain
// into the hidden background tab and left the dialog empty — and worse,
// overwrote the global chain the user returns to. The lookup must follow
// sortState.scope instead.
func TestUISortCanvasResolvesByScope(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "sortCanvasEl")
if !strings.Contains(el, "if (sortState.scope)") {
t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
}
if strings.Contains(el, `getElementById("scr-canvas")`) &&
strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
"per-key editor paints into the global canvas")
}
}
// A successful save must close the dialog even when the slots do not resolve.
// The write DID succeed; leaving the modal open makes the user click Save
// again and again. This regressed once: the !slots branch returned early.
func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoSave")
// keyAutoClose() must be reached on every success path, not only in the
// happy branch.
iClose := strings.Index(body, "keyAutoClose()")
if iClose < 0 {
t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
}
iToast := strings.Index(body, "toast(")
if iToast >= 0 && iClose < iToast {
t.Fatal("keyAutoClose is called before the toast; the warning must be " +
"shown first or it is lost with the modal")
}
// An early `return` inside a branch bypasses keyAutoClose entirely, so
// merely finding the call is not enough: every `return` that sits between
// entering the try block and the close call is a path that leaves the
// dialog open after a successful write.
tryStart := strings.Index(body, "try {")
seg := body[tryStart:iClose]
for _, line := range strings.Split(seg, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
"a successful save: " + trimmed)
}
}
}
// readFuncBody returns the source of a top-level function by name, using
// brace matching so nested blocks do not truncate it.
func readFuncBody(t *testing.T, src, name string) string {
t.Helper()
idx := strings.Index(src, "function "+name+"(")
if idx < 0 {
t.Fatalf("function %s not found", name)
}
start := strings.Index(src[idx:], "{")
if start < 0 {
t.Fatalf("function %s has no body", name)
}
i := idx + start
depth := 0
for j := i; j < len(src); j++ {
switch src[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[i : j+1]
}
}
}
t.Fatalf("function %s body is unterminated", name)
return ""
}
// The per-key dialog must not share the app-wide #modal-wrap id.
//
// Every dialog in this file uses that id, and more than one can be open at
// once (the "add slot" picker opens ON TOP of this one). Handlers resolve
// their own dialog via `.closest()` from their button, which works — but this
// dialog is also looked up from the CANVAS side (sortCanvasEl, keyAutoClose,
// keyAutoInherit), where there is no button to walk up from.
// getElementById("#modal-wrap") then returns whichever dialog comes first in
// the document, i.e. not the one being edited: Cancel closed the wrong box and
// the canvas could resolve to the picker's.
func TestUIKeyAutoModalHasOwnID(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `wrap.id = "key-auto-modal"`) {
t.Fatal("openKeyAutoModal must give its dialog its own id, not the shared " +
"#modal-wrap (another dialog may be open on top of it)")
}
close := readFuncBody(t, src, "keyAutoClose")
if strings.Contains(close, `"modal-wrap"`) {
t.Fatal("keyAutoClose resolves the shared #modal-wrap; it would close " +
"whichever dialog comes first in the document")
}
if !strings.Contains(close, `"key-auto-modal"`) {
t.Fatal("keyAutoClose must resolve the per-key dialog by its own id")
}
}
// Editing a chain inside the per-key dialog must NOT persist it. That dialog
// has Save and Cancel, so auto-writing on add/drag/delete meant the change was
// already committed before Save and Cancel could not undo it.
//
// The global page is the opposite: it has always applied edits immediately.
func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "afterChainEdit")
if !strings.Contains(body, "if (sortState.scope)") {
t.Fatal("afterChainEdit must branch on sortState.scope")
}
// The scoped branch must return before reaching persistAuto.
iScope := strings.Index(body, "if (sortState.scope)")
iReturn := strings.Index(body[iScope:], "return")
iPersist := strings.Index(body, "persistAuto()")
if iScope < 0 || iReturn < 0 || iPersist < 0 {
t.Fatal("afterChainEdit no longer has the expected shape")
}
if iScope+iReturn > iPersist {
t.Fatal("the scoped branch reaches persistAuto() — a per-key edit would " +
"be written before Save")
}
// And it must not call itself (a regex-driven rewrite did exactly that).
if strings.Contains(body, "afterChainEdit()") {
t.Fatal("afterChainEdit calls itself: infinite recursion")
}
// Every in-canvas edit goes through it, so none of them bypass the check.
for _, fn := range []string{"scrAddFromForm", "sortScopeSave", "scrDelSlot"} {
b := readFuncBody(t, src, fn)
if strings.Contains(b, "persistAuto()") {
t.Fatalf("%s calls persistAuto() directly, bypassing afterChainEdit", fn)
}
}
}
// The stats range line formats its end date from the LAST bucket label.
// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
// throws, and the assignment is lost. The symptom is an empty range line on
// exactly the view people look at most, with no error anywhere.
func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "paintStats")
// It must branch on the label shape instead of concatenating blindly.
if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
t.Fatal("paintStats must recognise the hourly bucket label shape " +
"(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
}
// And it must not contain the naive form that broke it.
if strings.Contains(el, `bucket + "T00:00:00Z"`) {
t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
"this throws on the day view and leaves the range line empty")
}
}
// A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not
// on an empty canvas. The realistic admin task is "this user gets the global chain
// minus the sources they must not hit"; starting blank forces retyping every
// tier against a 226-model picker. This is deliberately NOT the discovery
// fallback (which dumps every model of every source) — it is the same rules
// the global chain actually runs.
func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `api("/api/auto")`) {
t.Fatal("openKeyAutoModal must read the global chain to seed the editor")
}
if !strings.Contains(body, "seededFromGlobal") {
t.Fatal("the editor must remember whether its content came from the " +
"global chain, so the notice can say so")
}
// And it must not fall back to the discovery path that fills 226 models.
if strings.Contains(body, `buildLanes(idx, [], `) {
t.Fatal("seeding from an empty rule list triggers buildLanes discovery")
}
}
// The notice must distinguish "already this key's chain" from "a copy that
// only becomes its chain on Save" — those two look identical on the canvas and
// behave very differently if the admin hits Cancel.
func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoInheritNotice")
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if !strings.Contains(body, k) {
t.Fatalf("the notice never uses %s — a copied chain and a saved one "+
"would look the same", k)
}
}
// Both languages, or a non-Chinese admin sees raw keys.
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if n := strings.Count(src, k+`: "`); n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// A key whose model scope excludes AUTO cannot use AUTO at all — the scope
// filter runs before the chain — yet the editor happily lets an admin
// configure a per-key chain. That combination is easy to create by accident
// and produces a 403 with no visible cause, so the dialog must say so.
//
// We deliberately do NOT widen the scope automatically: silently granting a
// model the operator did not ask for is worse than a loud warning.
func TestUIKeyAutoWarnsOnScopeConflict(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function keyAutoScopeWarning(") {
t.Fatal("no scope-conflict check in the per-key editor")
}
body := readFuncBody(t, src, "keyAutoScopeWarning")
if !strings.Contains(body, "AUTO") {
t.Fatal("keyAutoScopeWarning must test whether the scope lists AUTO")
}
// It must not write anything back to the key: reporting only.
for _, forbidden := range []string{"PUT", "POST", "fetch(", "api("} {
if strings.Contains(body, forbidden) {
t.Fatalf("keyAutoScopeWarning calls %s — it must only report the "+
"conflict, never widen the scope itself", forbidden)
}
}
// Empty scope means unrestricted, so no warning is correct there.
if !strings.Contains(body, "if (!models.length)") {
t.Fatal("an unrestricted key (no models) must not be warned")
}
// Both languages.
if n := strings.Count(src, "kAutoChainScopeWarn"); n < 2 {
t.Fatalf("i18n key kAutoChainScopeWarn appears %d time(s), want zh+en", n)
}
}
// The scope conflict for AUTO must be reported by checkQuota, because that is
// the check an AUTO request actually reaches first (it runs before the chain is
// consulted). Putting the message on the later path made it dead code — the
// request was rejected one check earlier with the generic "not allowed for this
// key" wording, which sends an operator looking for a quota that isn't the
// problem.
func TestAutoScopeConflictMessageComesFromCheckQuota(t *testing.T) {
b, err := os.ReadFile("chat.go")
if err != nil {
t.Fatalf("read chat.go: %v", err)
}
src := string(b)
i := strings.Index(src, "func (g *Gateway) checkQuota(")
if i < 0 {
t.Fatal("checkQuota not found")
}
// Body = up to the next top-level func.
rest := src[i:]
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
rest = rest[:j+1]
}
if !strings.Contains(rest, "isAuto(model)") {
t.Fatal("checkQuota must special-case AUTO: a scope that omits AUTO is " +
"rejected there before anything else looks at the chain")
}
if !strings.Contains(rest, "not in this key's model scope") {
t.Fatal("checkQuota's AUTO branch must name the scope as the cause")
}
}
// renderSortEditor replaces its mount's innerHTML wholesale. The global
// priority page used to hand it the .card that also holds the h2 toolbar, so
// every open deleted the save/reset/add buttons: the lanes still rendered and
// the drag worked, but there was no way to persist a change. The per-key modal
// never showed this because its mount held only the canvas.
//
// The regression is invisible in the DOM when you only count lane elements, so
// this asserts on the structure: the toolbar lives in a sibling of the mount,
// not inside it.
func TestGlobalSortToolbarSurvivesCanvasRender(t *testing.T) {
src := uiSource(t)
// The global editor must mount into a dedicated host, not the .card.
i := strings.Index(src, `$("#tab-sort").innerHTML = `)
if i < 0 {
t.Fatal("global sort markup not found")
}
chunk := src[i:min(len(src), i+1400)]
if !strings.Contains(chunk, `id="sort-mount"`) {
t.Fatal("global sort page must provide a dedicated #sort-mount for the canvas")
}
// The mount handed to renderSortEditor must be that host.
if !strings.Contains(src, `renderSortEditor($("#sort-mount")`) {
t.Fatal("renderSortEditor must be given #sort-mount; passing the .card " +
"wipes the h2 toolbar that holds the save button")
}
// The toolbar (save in particular) must be OUTSIDE #sort-mount.
close := strings.Index(chunk, "</div>`;")
if close < 0 {
t.Fatal("malformed #tab-sort template")
}
head := chunk[:close]
mountAt := strings.Index(head, `id="sort-mount"`)
saveAt := strings.Index(head, `onclick="saveSort()"`)
if saveAt < 0 {
t.Fatal("the global sort page has no save button at all")
}
if mountAt >= 0 && saveAt > mountAt {
t.Fatal("the save button sits inside the canvas mount and will be " +
"erased the moment the canvas renders")
}
}

View File

@ -388,3 +388,26 @@ func TestPluginSidebarEntryCarriesATitle(t *testing.T) {
t.Errorf("plugin sidebar button renders the icon only; a label is required. "+
"got: %.200s", stmt)
}
// TestPluginPageHasAccessEditor: the plugin page must let an operator configure
// endpoint access. The APIs existed with zero UI entry point, which meant the
// only way to change who could reach a plugin endpoint was to hand-edit
// config.yaml — the thing the UI is meant to replace.
func TestPluginPageHasAccessEditor(t *testing.T) {
src := uiSource(t)
for _, needle := range []string{
`renderPluginAccess`, // called after rendering the plugin list
`savePluginAccess`, // wired to a button
`/access`, // hits the per-endpoint access API
`id="pl-access"`, // container exists
`http_access`, // reads the table from /api/plugins
`data-ep=`, // one input per endpoint
`data-plvis=`, // one visibility selector per plugin
`/http/*/access`, // whole-plugin visibility shares the access API
`plVisCell`, // renders the declared-vs-override distinction
} {
if !strings.Contains(src, needle) {
t.Errorf("plugin page lacks %q", needle)
}
}
}

View File

@ -2,12 +2,25 @@ package lua
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// pluginLoaded reports whether a plugin with this name is present, ignoring any
// LoadError: a plugin that is present but broken still counts as "loaded here",
// and the caller decides whether the error matters.
func pluginLoaded(ps *Plugins, name string) bool {
for _, p := range ps.List() {
if fmt.Sprint(p["name"]) == name {
return true
}
}
return false
}
// The billing plugin ships with the gateway, so its arithmetic is a contract:
// a wrong price silently produces wrong money. These tests drive it through the
// real hook path and check the NUMBERS, not merely that it loads.
@ -447,8 +460,16 @@ return plugin`)
}
// Order must be honoured so the sidebar is predictable.
if len(ui.Pages) != 4 {
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
//
// The page COUNT is not asserted: it used to be 4, and it grew every time a
// bundled plugin added a page. A hard count here reads as "the merge is
// broken" whenever the product gains a feature, which trains the reader to
// bump a number instead of reading the failure. What matters is that all
// three pages this test contributed merged, and that they came out sorted.
for _, want := range []string{"third-a", "third-b", "other-page"} {
if !got[want] {
t.Errorf("page %s did not merge: %v", want, got)
}
}
for i := 1; i < len(ui.Pages); i++ {
if ui.Pages[i].Order < ui.Pages[i-1].Order {
@ -462,8 +483,15 @@ return plugin`)
// configuration, since seeding only happens on a fresh plugin dir.
func TestBillingPluginLoadedByDefault(t *testing.T) {
ps, pdir := billingVM(t)
if ps.Count() != 1 {
t.Fatalf("expected 1 bundled plugin, got %d", ps.Count())
// billing must be among the bundled plugins, not the only one. The count
// grows with every shipped plugin and asserting it makes this test a
// changelog detector.
if !pluginLoaded(ps, "billing") {
var names []string
for _, p := range ps.List() {
names = append(names, fmt.Sprint(p["name"]))
}
t.Fatalf("billing is not loaded; bundled plugins are %v", names)
}
if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil {
t.Errorf("billing.lua was not written to the plugin dir: %v", err)
@ -966,3 +994,245 @@ func TestBillingCacheHitClampedInStats(t *testing.T) {
tot["cache_fresh_tokens"])
}
}
// TestUnpricedIsTrackedPerDay pins the day-keyed unpriced tally.
//
// The lifetime counters cannot answer the question an operator actually has —
// "is it STILL happening?" — because they keep the debt from before a rule was
// added. After pricing a mispriced source the lifetime number never moves, so a
// dashboard reading "unpriced: 6376" is indistinguishable from a system that is
// still billing nothing. Day buckets are what make a pricing fix observable: the
// offending day keeps its count and the next day starts at zero for it.
func TestUnpricedIsTrackedPerDay(t *testing.T) {
ps, _ := billingVM(t)
// Nothing is priced in the default config, so every request is unpriced.
// Two requests on two different UTC days.
for _, ms := range []int64{1790000000000, 1790100000000} {
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "mystery-model", "source": "nowhere", "key": "***aaaaaa",
"ok": true, "prompt_tokens": 10, "completion_tokens": 5,
"time": ms,
})
}
st := stateOf(t, ps)
byDay, ok := st["unpriced_by_day"].(map[string]interface{})
if !ok {
t.Fatalf("state has no unpriced_by_day map: %T", st["unpriced_by_day"])
}
if len(byDay) != 2 {
t.Fatalf("expected 2 day buckets, got %d: %v", len(byDay), byDay)
}
var sum float64
for day, bucket := range byDay {
b := bucket.(map[string]interface{})
sum += b["reqs"].(float64)
models := b["models"].(map[string]interface{})
if models["mystery-model"].(float64) != 1 {
t.Errorf("%s: model tally = %v, want 1", day, models["mystery-model"])
}
}
if sum != 2 {
t.Errorf("day buckets sum to %v, want 2 (the lifetime counter must not be double-counted)", sum)
}
// The lifetime counters still exist and still agree — this is additive, not
// a replacement.
if st["unpriced_reqs"].(float64) != 2 {
t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"])
}
}
// TestReasoningBilledAtCompletionRateByDefault is the property that keeps the
// standard formula matching a real invoice.
//
// reasoning_tokens is a subset of completion_tokens and every provider measured
// (OpenAI, DeepSeek, Anthropic) charges it at the ordinary output rate. A rule
// with no reasoning_price must therefore produce EXACTLY the number the
// pre-reasoning formula produced — the field exists for a provider that prices
// thinking differently, not as a new charge.
func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
// USD PER TOKEN (compile.go divides the per-million figure by 1e6).
// prompt 0.15/M, completion 0.60/M. No reasoning_price anywhere.
"m": map[string]interface{}{"prompt": 0.15e-6, "completion": 0.60e-6},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 1000, "completion_tokens": 1000,
"reasoning_tokens": 800, // all of it thinking; still billed as output
"time": 1750000000000,
})
st := stateOf(t, ps)
cost := st["total"].(map[string]interface{})["cost"].(float64)
approx(t, "reasoning at completion rate", cost, 0.00075)
}
// TestReasoningPriceOverridesOnlyWhenSet: with a rule that DOES price thinking
// differently, only the difference is applied. Charging the reasoning portion a
// second time in full is the obvious wrong implementation and it doubles the
// completion line.
func TestReasoningPriceOverridesOnlyWhenSet(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 0.15e-6, "completion": 0.60e-6,
// Thinking costs 3x ordinary output: 1.80/M.
"reasoning_price": 1.80e-6,
},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800,
"time": 1750000000000,
})
st := stateOf(t, ps)
cost := st["total"].(map[string]interface{})["cost"].(float64)
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
// + 800 * (1.80-0.60)e-6 = 0.00096 → 0.00171
approx(t, "reasoning repriced to 1.80/M", cost, 0.00171)
}
// TestReasoningClampedToCompletion: a provider claiming MORE reasoning than
// completion is malformed. Without a clamp the visible-output count goes
// negative and the delta exceeds the whole request, so the bill silently
// explodes on bad data instead of degrading.
func TestReasoningClampedToCompletion(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 0.15e-6, "completion": 0.60e-6, "reasoning_price": 1.80e-6,
},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 99999,
"time": 1750000000000,
})
st := stateOf(t, ps)
cost := st["total"].(map[string]interface{})["cost"].(float64)
// reasoning is clamped from 99999 down to completion (1000):
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
// delta = 1000 * (1.80-0.60)e-6 = 0.00120
// total = 0.00195
// Without the clamp this would be 0.18 — 92x the whole request, and a
// number plausible enough to survive a glance at a dashboard.
approx(t, "reasoning clamped to completion", cost, 0.00195)
}
// TestLongContextStepIsMarginal: the threshold reprices only the tokens PAST
// it, at a marginal rate.
//
// A flat prompt price under-bills exactly the traffic an agent workload
// produces — on this deployment 88% of requests exceed 128k with a median of
// 247k — and it under-bills silently, because the number stays plausible. The
// step must apply to the excess only; charging the WHOLE prompt at the higher
// rate is the other obvious implementation and it overcharges short requests by
// the same factor it was meant to fix long ones.
func TestLongContextStepIsMarginal(t *testing.T) {
// Short request: below the threshold, so the step must not touch it at all.
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 1e-6, "completion": 0,
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 100000, "completion_tokens": 0, "time": 1750000000000,
})
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
approx(t, "short request unaffected", got, 0.1)
// Long request: 200k prompt with a 128k threshold → 72k tokens at the step.
ps2, _ := billingVM(t)
if err := ps2.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 1e-6, "completion": 0,
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
},
},
},
}); err != nil {
t.Fatal(err)
}
ps2.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 200000, "completion_tokens": 0, "time": 1750000000000,
})
got2 := stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64)
// 128000*1e-6 + 72000*2e-6 = 0.128 + 0.144 = 0.272
approx(t, "long request marginal step", got2, 0.272)
}
// TestLongContextComposesWithCacheDiscount pins the interaction, which is the
// whole reason for computing the step as a marginal rate.
//
// On this deployment the cache hit rate is 88% and long prompts dominate, so
// "cached tokens beyond the threshold" is the common case, not an edge case. A
// cache read already bills at prompt*discount; the long-context rate applies on
// top of THAT, not on top of the full prompt price. Computing it any other way
// silently changes the price of the majority of traffic.
func TestLongContextComposesWithCacheDiscount(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD", "cache_discount": 0.1,
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 1e-6, "completion": 0,
"long_context": map[string]interface{}{"above": 100000, "prompt": 2e-6},
},
},
},
}); err != nil {
t.Fatal(err)
}
// 200k prompt, 150k of it cached, 50k fresh. Excess past 100k = 100k, split
// 25% fresh / 75% cached.
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 200000, "completion_tokens": 0,
"cache_hit_tokens": 150000, "cache_reported": true,
"time": 1750000000000,
})
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
// base: fresh 50000*1e-6 = 0.05
// cache 150000*1e-6*0.1 = 0.015
// step: fresh excess 25000*(2-1)e-6 = 0.025
// cache excess 75000*(2-0.1)e-6 = 0.1425
// total = 0.2325
approx(t, "step composes with cache discount", got, 0.2325)
}

View File

@ -414,3 +414,86 @@ func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
"control and must never be squeezed by the flexible inputs")
}
}
// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a
// settled pricing debt from a live leak. The lifetime unpriced counter answers
// neither — it keeps every request from before a rule was added, so it reads the
// same number in both situations. The day buckets exist to make the difference
// visible, and a bucket nothing renders is a bucket nobody benefits from.
func TestBillingUIShowsRecentUnpriced(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
"unpriced_by_day", // the tally is written
"recentUnpriced", // it is reduced to a window
"unpriced_recent", // it reaches the view
"unpriced7d", // the card labels it
"unpricedClean", // and says so when the window is clear
} {
if !strings.Contains(src, needle) {
t.Errorf("billing UI lacks %q", needle)
}
}
}
// TestBillingRulesUIExposesReasoningPrice: the thinking-token rate must be
// editable in the rules editor, not only settable by hand-editing config.yaml.
// A pricing capability that lives only in the config file is one the operator
// will assume does not exist.
func TestBillingRulesUIExposesReasoningPrice(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
`class='m-r'`, // the input, in both the existing rows and the "+ model" row
`models[n].reasoning`, // collected back out of the DOM on save
`reasoningHint`, // and it says what leaving it blank means
} {
if !strings.Contains(src, needle) {
t.Errorf("rules editor lacks %q", needle)
}
}
// Two occurrences: an existing model's row and the template for a new one.
// One would mean the "+ model" button produces a row that silently drops
// the rate on the next save.
if n := strings.Count(src, `class='m-r'`); n < 2 {
t.Errorf("m-r appears %d time(s); both the row template and the editor need it", n)
}
}
// TestBillingRulesUIExposesLongContextStep: the long-context step must be
// editable in the rules editor.
//
// This is not a niche control. On an agent workload 88% of requests carry more
// than 128k prompt tokens with a median of 247k, so a rule that omits the step
// under-bills the MAJORITY of traffic while every number on the dashboard stays
// plausible. Leaving the capability in config.yaml alone would mean the operator
// never discovers it needs it.
func TestBillingRulesUIExposesLongContextStep(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
`class='m-lc-a'`, // threshold
`class='m-lc-p'`, // absolute rate
`class='m-lc-m'`, // multiplier
`models[n].long_context`, // collected back out on save
`lcHint`, // explains that blank = no step
} {
if !strings.Contains(src, needle) {
t.Errorf("rules editor lacks %q", needle)
}
}
// Both the existing-row template and the "+ model" template need the three
// inputs; one would make a newly added model lose its step on the next save.
if n := strings.Count(src, `class='m-lc-a'`); n < 2 {
t.Errorf("long-context inputs appear %d time(s); both row templates need them", n)
}
}

View File

@ -0,0 +1,115 @@
package lua
import (
"encoding/json"
"os"
"sort"
)
// Access overrides are OPERATOR policy, not plugin data. They live in their own
// file rather than in each plugin's state because:
//
// - they describe the deployment, not the plugin, so a plugin upgrade that
// replaces its .state.json must not silently widen access again;
// - they must survive a restart. An in-memory map made "restrict billing to
// admin" look like it worked right up to the next deploy, at which point the
// restriction evaporated with nothing in the logs to say why;
// - the plugin_dir is the one place an operator already looks when asking
// "what does this plugin actually get to see", so the answer lives beside it.
//
// The file is a flat map of "name" (whole plugin) or "name#path" (one endpoint)
// to an access spec, which is the same shape the in-memory store uses, so load
// and save cannot disagree about what a key means.
const accessFileName = ".access.json"
func (ps *Plugins) accessFile() string {
if ps.dir == "" {
return ""
}
return ps.dir + string(os.PathSeparator) + accessFileName
}
// LoadAccessOverrides reads the persisted overrides into memory. A missing file
// is not an error: no operator has restricted anything yet, which is the
// default state of a fresh install.
//
// A corrupt file is also not fatal. Losing a restriction is bad, but refusing to
// start the gateway is worse, and an empty map plus a logged error leaves the
// operator with something to fix.
func (ps *Plugins) LoadAccessOverrides() error {
path := ps.accessFile()
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
m := map[string]string{}
if err := json.Unmarshal(b, &m); err != nil {
return err
}
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
for k, v := range m {
ps.visibility[k] = v
}
return nil
}
// saveAccessOverridesLocked flushes the whole override map. It is called on
// every change, and unlike plugin state the map is tiny and changes only when an
// operator clicks Save — so a synchronous write costs nothing that matters, and
// it means the change is durable the moment the API returns 200.
func (ps *Plugins) saveAccessOverridesLocked() {
path := ps.accessFile()
if path == "" {
return
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
if v != "" {
out[k] = v
}
}
if len(out) == 0 {
// Leave no empty file behind: its absence is the honest encoding of
// "nothing is overridden".
_ = os.Remove(path)
return
}
writeJSONAtomic(path, out)
}
// AccessOverrides returns a copy of every override in force, for the UI.
func (ps *Plugins) accessOverridesSnapshot() map[string]string {
ps.mu.RLock()
defer ps.mu.RUnlock()
if len(ps.visibility) == 0 {
return nil
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
out[k] = v
}
return out
}
// sortedOverrideKeys exists only so a debug dump is stable; map iteration order
// would otherwise make two identical states look different.
func (ps *Plugins) sortedOverrideKeys() []string {
m := ps.accessOverridesSnapshot()
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@ -0,0 +1,110 @@
package lua
import (
"os"
"path/filepath"
"testing"
)
// TestAccessOverrideSurvivesReload is the test that matters for an access
// restriction. An in-memory map makes "billing is admin-only" look like it
// worked right up to the next restart, and then the restriction is simply gone
// with nothing in the logs to explain it. Both the plugin-level audience and
// the per-endpoint spec must come back.
func TestAccessOverrideSurvivesReload(t *testing.T) {
dir := t.TempDir()
src := `local P = {}
P.name = "api"
P.version = "1.0.0"
P.http = {
{ path = "health", handler = "health", roles = { "admin" } },
}
function P.health(req) return { json = { ok = true } } end
return P
`
first := NewPlugins(NewVM(dir), dir)
if err := first.LoadSource("api", src); err != nil {
t.Fatal(err)
}
first.SetPluginVisibility("api", "admin, user")
if err := first.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, accessFileName)); err != nil {
t.Fatalf("override was not written: %v", err)
}
// A fresh Plugins over the same dir is what a restart looks like.
second := NewPlugins(NewVM(dir), dir)
// The plugin source is on disk too, so this second instance sees both the
// plugin and the overrides — the same pair a restart sees. Loading only the
// overrides would leave no endpoints to walk, and the assertion below would
// pass vacuously.
if err := second.LoadSource("api", src); err != nil {
t.Fatal(err)
}
if err := second.LoadAccessOverrides(); err != nil {
t.Fatalf("reload overrides: %v", err)
}
if got := second.VisibilityOverride("api"); got != "admin, user" {
t.Errorf("plugin visibility lost across reload: %q", got)
}
found := false
for _, e := range second.EndpointAccess() {
if e.Path == "health" && len(e.OverrideKeys) == 1 && e.OverrideKeys[0] == "homeagent" {
found = true
}
}
if !found {
t.Error("endpoint override lost across reload")
}
}
// TestAccessOverrideClearedRemovesFile keeps the "nothing overridden" state
// distinguishable from "overridden with something".
func TestAccessOverrideClearedRemovesFile(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
ps.SetPluginVisibility("api", "admin")
path := filepath.Join(dir, accessFileName)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected an access file: %v", err)
}
ps.SetPluginVisibility("api", "")
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("clearing the last override should remove the file, got %v", err)
}
}
// TestVisibilityOverridesExcludeEndpointKeys keeps endpoint policy out of the
// plugin-audience listing. Both live in one map, and reporting "probe#hello"
// under visibility is how the plugin page ends up offering to save an audience
// of that name — which nothing reads as an audience.
func TestVisibilityOverridesExcludeEndpointKeys(t *testing.T) {
dir := t.TempDir()
src := `local P = {}
P.name = "api"
P.version = "1.0.0"
P.http = { { path = "health", handler = "health", roles = { "admin" } } }
function P.health(req) return { json = { ok = true } } end
return P
`
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", src); err != nil {
t.Fatal(err)
}
ps.SetPluginVisibility("api", "admin, user")
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
t.Fatal(err)
}
got := ps.VisibilityOverrides()
if got["api"] != "admin, user" {
t.Errorf("plugin audience missing: %+v", got)
}
if _, leaked := got["api#health"]; leaked {
t.Errorf("endpoint override leaked into the audience map: %+v", got)
}
if len(got) != 1 {
t.Errorf("expected exactly the plugin audience, got %+v", got)
}
}

248
internal/lua/plugin_http.go Normal file
View File

@ -0,0 +1,248 @@
package lua
// Outbound HTTP for plugins.
//
// The billing plugin never needed this: everything it reports arrives on a hook
// payload that the gateway produced while forwarding a request. A quota plugin
// is the first thing that must REACH OUT on its own — Command Code exposes
// /alpha/billing/credits and friends, and there is no way to learn a plan's
// remaining balance without asking.
//
// That capability is also the sharpest edge in the plugin model. A plugin is a
// single .lua file the WebUI installs from a textarea: untrusted, browser-facing
// code that runs in the gateway process. Handing it a general-purpose HTTP
// client would make every install a standing SSRF primitive against whatever
// the gateway can reach — the cloud metadata endpoint, the cluster control
// plane, whatever else sits on 127.0.0.1 in this deployment. Reading the
// environment for credentials is a second door out.
//
// So the bridge is deliberately narrow:
//
// - Destination must match a host that a CONFIGURED SOURCE already talks to.
// Those are operator-declared, not plugin-declared: a plugin cannot widen
// its own reach by inventing a URL, and an existing source's host is
// exactly the set of upstreams this deployment already trusts with API keys.
// - http:// on a non-loopback host is refused. Credentials belong in HTTPS.
// - Loopback needs no explicit opt-in: it is the only way a plugin can read
// the gateway's own API (the billing rules UI does exactly that), and it
// cannot leave the machine.
// - Every request is bounded by a timeout and the response is size-capped, so
// a plugin cannot wedge a worker goroutine or exhaust memory.
// - Credentials are NEVER read from the environment here. A plugin that needs
// a key must be given it (state, or the caller's own config), which keeps
// the grant visible to the operator instead of silently ambient.
//
// Redirects are NOT followed: the allowlist is checked per hop, and a redirect
// to an unlisted host would otherwise walk straight past it. A 3xx is returned
// as-is so the plugin can see it.
import (
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
golua "github.com/aarzilli/golua/lua"
)
// httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body
// a plugin can pull into the gateway's heap.
const (
httpDefaultTimeout = 15 * time.Second
httpMaxTimeout = 60 * time.Second
httpMaxResponse = 4 << 20 // 4 MiB
)
// httpClient is shared by every plugin request. It deliberately has no
// ProxyFromEnvironment: an operator who proxies upstreams configures the proxy
// per source, and a plugin request should not silently inherit one.
var httpClient = &http.Client{
Timeout: httpDefaultTimeout,
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 16,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
},
// See above: a redirect is a hop the allowlist has not seen.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
// httpGlobal is the Lua name of the outbound bridge.
const httpGlobal = "__llmsproxy_http"
// httpAllowlist is the set of hosts plugins may contact: the host of every
// configured source's base_url.
type httpAllowlist struct {
hosts map[string]bool
}
func newHTTPAllowlist(hosts []string) *httpAllowlist {
m := make(map[string]bool, len(hosts))
for _, h := range hosts {
if h != "" {
m[strings.ToLower(h)] = true
}
}
return &httpAllowlist{hosts: m}
}
// allows reports whether host:port may be contacted. Ports 80/443 are implied
// by the bare host so a source configured as https://api.example.com/v1 does not
// have to spell the port.
func (a *httpAllowlist) allows(host string, port string) bool {
host = strings.ToLower(host)
if host == "" {
return false
}
if a.hosts[host] {
return true
}
switch port {
case "", "80", "443":
return a.hosts[host]
}
return a.hosts[net.JoinHostPort(host, port)]
}
// checkURL validates a plugin-supplied URL against the allowlist. It returns
// the parsed URL plus a reason when it is refused.
func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return nil, fmt.Errorf("bad url: %v", err)
}
switch strings.ToLower(u.Scheme) {
case "https":
case "http":
// Plain HTTP is only tolerated on loopback, where it stays on this
// machine and is how the gateway's own plain-HTTP dev port is reached.
if !isLoopbackHost(u.Hostname()) {
return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname())
}
default:
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
}
if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) {
// Loopback needs no entry: it cannot leave the machine, and the billing
// rules UI reads the gateway's own API over plain HTTP in dev setups.
return nil, fmt.Errorf("host %q is not an allowed outbound destination "+
"(allowed: the hosts of configured sources, plus loopback)", u.Hostname())
}
return u, nil
}
func isLoopbackHost(h string) bool {
if h == "localhost" {
return true
}
ip := net.ParseIP(strings.Trim(h, "[]"))
return ip != nil && ip.IsLoopback()
}
// httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http.
type httpRequestSpec struct {
URL string
Method string
Headers map[string]string
Body string
Timeout time.Duration
}
// luaHTTPFn builds the golua closure. It takes one Lua table argument and
// returns (responseTable, nil) or (nil, errorMessage) — never raises, so a
// plugin mistake cannot take the gateway with it.
func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction {
return func(L *golua.State) int {
spec, err := readHTTPRequestSpec(L)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
u, err := allow.checkURL(spec.URL)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
resp, err := performHTTP(u, spec)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
pushHTTPResponse(L, resp)
L.PushNil()
return 2
}
}
func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) {
method := strings.ToUpper(spec.Method)
if method == "" {
method = http.MethodGet
}
timeout := spec.Timeout
if timeout <= 0 || timeout > httpMaxTimeout {
timeout = httpDefaultTimeout
}
var body io.Reader
if spec.Body != "" {
body = strings.NewReader(spec.Body)
}
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return nil, fmt.Errorf("build request: %v", err)
}
for k, v := range spec.Headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "ModelRouter-plugin")
}
client := *httpClient
client.Timeout = timeout
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1))
if err != nil {
return nil, fmt.Errorf("read body: %v", err)
}
if len(data) > httpMaxResponse {
return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse)
}
return &httpResult{
Status: resp.StatusCode,
Headers: flattenHeaders(resp.Header),
Body: string(data),
}, nil
}
type httpResult struct {
Status int
Headers map[string]string
Body string
}
func flattenHeaders(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k := range h {
out[strings.ToLower(k)] = h.Get(k)
}
return out
}

View File

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

View File

@ -0,0 +1,530 @@
package lua
// Plugin-registered HTTP endpoints.
//
// The gateway mounts what a plugin declares in plugin.http and calls back into
// the plugin's Lua state to produce each response. This is the piece billing
// never needed: it only receives hooks, so it has nothing to expose. A plugin
// that computes something an operator wants to query from outside the browser —
// a health probe, a CI check, another tool — needs a route, and hard-coding one
// per plugin would mean a gateway change for every such plugin.
//
// The request and response are both plain tables crossing the Go/Lua boundary:
//
// request { method, path, query{}, params{}, headers{}, body, role, key }
// response { status, headers{}, body } or { status, json = <any table> }
//
// A handler returns (response, nil) or (nil, errorMessage). Errors never raise:
// an escaping Lua error inside a hook has taken the whole gateway down before
// (see hookGuardSrc), so the same discipline applies here.
//
// Two properties are enforced on the Go side rather than trusted to the plugin:
//
// - Method and status are checked against an allowlist before the response is
// written. A plugin returning status 0 or a redirect with an injected
// Location must not be able to steer the gateway's own routing.
// - Path traversal cannot escape the mount: params are substituted into a
// fixed segment count, and the handler's own Path is rejected at
// registration if it contains "..".
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// allowedMethods is deliberately short. An arbitrary verb would need a case in
// the router for no benefit, and a plugin that wants every verb can register the
// five that exist.
var allowedMethods = map[string]bool{
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true,
http.MethodDelete: true, http.MethodPatch: true,
}
// normalizeHandlerMethod validates and defaults a declared method.
func normalizeHandlerMethod(m string) (string, error) {
m = strings.ToUpper(strings.TrimSpace(m))
if m == "" {
return http.MethodGet, nil
}
if !allowedMethods[m] {
return "", fmt.Errorf("unsupported method %q (allowed: GET POST PUT DELETE PATCH)", m)
}
return m, nil
}
// validateHandlerPath rejects paths that could escape the mount or collide with
// the plugin state/call routes.
func validateHandlerPath(p string) error {
p = strings.TrimSpace(p)
if p == "" {
return fmt.Errorf("empty path")
}
if strings.Contains(p, "..") {
return fmt.Errorf("path %q contains .. ", p)
}
if strings.Contains(p, "//") {
return fmt.Errorf("path %q contains an empty segment", p)
}
for _, seg := range strings.Split(strings.Trim(p, "/"), "/") {
if seg == "" {
continue
}
if strings.HasPrefix(seg, ":") {
if len(seg) == 1 {
return fmt.Errorf("path %q has a nameless param segment", p)
}
continue
}
// A literal segment must not look like a wildcard, or the route table
// gets two patterns that match the same URL.
if strings.ContainsAny(seg, "*?") {
return fmt.Errorf("path %q uses a wildcard; use :name for a param", p)
}
}
return nil
}
// handlerRequest is what a Lua handler sees.
type handlerRequest struct {
Method string
Path string
Query map[string]string
Params map[string]string
Headers map[string]string
Body string
Role string
Key string
}
// handlerResponse is what a Lua handler returns.
type handlerResponse struct {
Status int
Headers map[string]string
Body string
// JSON is set when the handler returned {json = <table>}; it is encoded by
// the kernel so a plugin does not hand-roll escaping.
JSON interface{}
}
// writeHTTPHandlerResponse renders a handler's reply onto the gateway's writer.
//
// The status is validated even though it came from a plugin: a handler
// returning 0 or 999 would make net/http panic, and one returning 3xx with a
// Location the plugin chose could be used to bounce a caller at a URL of the
// plugin's picking. Anything outside 100..599 that is not a redirect becomes 500
// with an error body rather than a panic.
func writeHTTPHandlerResponse(w http.ResponseWriter, r *handlerResponse) {
status := r.Status
if status == 0 {
status = http.StatusOK
}
if status < 100 || status > 599 {
status = http.StatusInternalServerError
}
for k, v := range r.Headers {
// A plugin cannot set Content-Length (the body is written by the kernel)
// or hop-by-hop headers (Connection, Transfer-Encoding) which would
// corrupt the gateway's own response framing.
switch http.CanonicalHeaderKey(k) {
case "Content-Length", "Connection", "Transfer-Encoding", "Upgrade":
continue
}
w.Header().Set(k, v)
}
if r.JSON != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
// Encoded here rather than by the gateway layer: this file is in the lua
// package and importing the gateway would be a cycle.
if enc, err := json.Marshal(r.JSON); err == nil {
_, _ = w.Write(enc)
} else {
_, _ = w.Write([]byte(`{"error":"handler json could not be encoded"}`))
}
return
}
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
w.WriteHeader(status)
if r.Body != "" {
_, _ = w.Write([]byte(r.Body))
}
}
// parseHandlerResponse reads a Lua handler's return value.
//
// It accepts three shapes, because a plugin should not have to think about it:
//
// { status = 200, body = "text" }
// { status = 201, json = { any = "table" } }
// { status = 204 } -- no body
//
// Anything else — a string, a number, a table without status — is a handler bug
// and becomes an error rather than an empty 200, so the plugin author sees it.
func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
m, ok := v.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("handler must return a table, got %T", v)
}
out := &handlerResponse{Status: http.StatusOK}
if s, ok := m["status"]; ok {
switch n := s.(type) {
case float64:
out.Status = int(n)
case int:
out.Status = n
default:
return nil, fmt.Errorf("status must be a number, got %T", s)
}
}
if b, ok := m["body"].(string); ok {
out.Body = b
}
if h, ok := m["headers"].(map[string]interface{}); ok {
out.Headers = make(map[string]string, len(h))
for k, hv := range h {
out.Headers[k] = asString(hv)
}
}
if j, ok := m["json"]; ok && j != nil {
out.JSON = j
}
// An empty table is a handler bug — it means the author forgot to say what to
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
// way to answer with no body, and treating it as an error would make the
// correct spelling unusable.
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
return nil, fmt.Errorf("handler returned an empty table; return " +
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
}
return out, nil
}
// InvokeHandler calls a plugin's HTTP handler function with a request table and
// returns the decoded response.
//
// It reuses the same guard-in-Lua contract as the hooks: the function is called
// through __llmsproxy_call_hook, so a plugin error comes back as (nil, message)
// and never as a C-level abort.
func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*handlerResponse, error) {
p := ps.find(plugin)
if p == nil {
return nil, fmt.Errorf("plugin %s not found", plugin)
}
p.mu.Lock()
w := p.state
p.mu.Unlock()
if w == nil {
return nil, fmt.Errorf("plugin %s has no state", plugin)
}
// The whole call happens under the plugin lock, network waits included —
// same property as Call, and for the same reason: one Lua state.
p.mu.Lock()
defer p.mu.Unlock()
L := w.L
L.SetTop(0)
defer L.SetTop(0)
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
return nil, fmt.Errorf("plugin table missing")
}
plug := L.GetTop()
L.GetGlobal(hookGuardName)
if L.IsNil(-1) {
L.Pop(1)
}
L.GetField(plug, fnName)
if !L.IsFunction(-1) {
return nil, fmt.Errorf("handler %s not found in plugin %s", fnName, plugin)
}
pushGoValue(L, plainForLua(map[string]interface{}{
"method": req.Method,
"path": req.Path,
"query": stringMapToAny(req.Query),
"params": stringMapToAny(req.Params),
"headers": stringMapToAny(req.Headers),
"body": req.Body,
"role": req.Role,
"key": req.Key,
}))
if err := L.Call(2, 2); err != nil {
return nil, err
}
errMsg := ""
if L.GetTop() >= 2 {
if !L.IsNil(-1) {
errMsg = L.ToString(-1)
}
L.Pop(1)
}
if errMsg != "" {
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
}
if L.GetTop() < 1 || L.IsNil(-1) {
return nil, fmt.Errorf("handler %s returned nothing", fnName)
}
var raw interface{}
if err := luaToJSON(L, -1, &raw); err != nil {
return nil, err
}
return parseHandlerResponse(raw)
}
func stringMapToAny(m map[string]string) map[string]interface{} {
if len(m) == 0 {
return map[string]interface{}{}
}
out := make(map[string]interface{}, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// HandlerRequest is the request table handed to a plugin HTTP handler.
// Exported because the gateway package constructs it.
type HandlerRequest = handlerRequest
// HandlerResponse is what a handler returns, after decoding.
type HandlerResponse = handlerResponse
// WriteHandlerResponse renders a handler response onto the gateway's writer.
func WriteHandlerResponse(w http.ResponseWriter, r *HandlerResponse) {
writeHTTPHandlerResponse(w, r)
}
// HTTPHandlers returns the registered endpoints across all plugins, each tagged
// with its owner so the dispatcher knows which Lua state to enter.
func (ps *Plugins) HTTPHandlers() []HTTPHandler {
ps.mu.RLock()
defer ps.mu.RUnlock()
var out []HTTPHandler
for _, p := range ps.plugins {
if p.http == nil || p.LoadError != "" {
continue
}
for _, declared := range p.http {
// effectiveAccess applies the operator's per-endpoint override, so the
// dispatcher enforces what the WebUI shows rather than what the .lua
// declared.
h := ps.effectiveAccess(p, declared)
h.Plugin = p.Info.Name
out = append(out, h)
}
}
return out
}
// Allows is the exported form of the access rule, so the gateway dispatcher does
// not need a duplicate copy of it in its own package.
func (h HTTPHandler) Allows(role, keyName string) bool { return h.allows(role, keyName) }
// HTTPEndpointAccess is the operator's view of one endpoint's permissions,
// returned by /api/plugins so the WebUI can render what is exposed without
// parsing .lua files.
type HTTPEndpointAccess struct {
Plugin string `json:"plugin"`
Path string `json:"path"`
FullPath string `json:"full_path"`
Method string `json:"method"`
Handler string `json:"handler"`
Roles []string `json:"roles,omitempty"`
Keys []string `json:"keys,omitempty"`
Public bool `json:"public"`
Description string `json:"description,omitempty"`
// OverrideRoles / OverrideKeys carry the operator's own restriction, which
// REPLACES whatever the plugin declared. Empty means "use the plugin's".
//
// This is the mechanism the admin uses to narrow (or widen) an endpoint's
// reachable keys without editing the plugin: a bundled .lua is overwritten on
// the next upgrade, and a hand-installed one is not something the operator
// should have to maintain.
OverrideRoles []string `json:"override_roles,omitempty"`
OverrideKeys []string `json:"override_keys,omitempty"`
}
// endpointAccess returns the access table for every registered endpoint, with
// operator overrides folded in.
func (ps *Plugins) endpointAccess() []HTTPEndpointAccess {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.endpointAccessLocked()
}
// endpointAccessLocked is the body of endpointAccess for callers that already
// hold ps.mu — VisibilityOverrides walks the same map and must not re-lock.
func (ps *Plugins) endpointAccessLocked() []HTTPEndpointAccess {
var out []HTTPEndpointAccess
for _, p := range ps.plugins {
if p.http == nil || p.LoadError != "" {
continue
}
for _, h := range p.http {
// The override key is per ENDPOINT (plugin#path), not per plugin.
// Reading the plugin-level key here reported the plugin's own
// audience override as if it governed every endpoint, and reported
// no endpoint override at all — so the UI drew the DECLARED roles
// after an operator had saved a change, and re-saving from that
// display silently reverted the restriction.
ovRoles, ovKeys := splitAccessSpec(ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)])
out = append(out, HTTPEndpointAccess{
Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(),
Method: h.Method, Handler: h.Handler,
Roles: h.Roles, Keys: h.Keys, Public: h.Public,
Description: h.Description,
OverrideRoles: ovRoles, OverrideKeys: ovKeys,
})
}
}
return out
}
// EndpointAccess exposes the table to the gateway package.
func (ps *Plugins) EndpointAccess() []HTTPEndpointAccess { return ps.endpointAccess() }
// SetEndpointAccess narrows or widens one endpoint's reachable keys/roles.
//
// It is an OVERRIDE, applied on top of what the plugin declared, and stored in
// the same per-plugin visibility map the UI audience uses. An empty value clears
// it. Overriding per endpoint rather than per plugin is what the operator
// actually wants: /health is fine for every key while /balance is not, and
// those two live in the same plugin.
func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error {
p := ps.find(plugin)
if p == nil {
return fmt.Errorf("plugin %s not found", plugin)
}
found := false
for _, h := range p.http {
if h.Path == path {
found = true
break
}
}
if !found {
return fmt.Errorf("plugin %s has no endpoint %q", plugin, path)
}
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
key := endpointOverrideKey(plugin, path)
if strings.TrimSpace(spec) == "" {
delete(ps.visibility, key)
} else {
ps.visibility[key] = spec
}
ps.saveAccessOverridesLocked()
return nil
}
// endpointOverrideKey namespaces a per-endpoint override so it cannot collide
// with a whole-plugin audience override of the same plugin name.
func endpointOverrideKey(plugin, path string) string {
return plugin + "#" + path
}
// effectiveAccess applies an endpoint override to the declared handler.
func (ps *Plugins) effectiveAccess(p *Plugin, h HTTPHandler) HTTPHandler {
ps.mu.RLock()
ov := ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)]
ps.mu.RUnlock()
if ov == "" {
return h
}
out := h
out.Roles, out.Keys = nil, nil
out.Public = false
for _, part := range strings.Split(ov, ",") {
part = strings.TrimSpace(part)
switch {
case part == "":
case part == "public":
out.Public = true
case strings.HasPrefix(part, "@"):
out.Keys = append(out.Keys, strings.TrimPrefix(part, "@"))
default:
out.Roles = append(out.Roles, part)
}
}
return out
}
// knownKeyNames is set by the gateway during wiring so ValidateAccessSpec can
// refuse an allowlist naming a key that does not exist.
//
// Without the check, an allowlist naming a typo ("homeagent" vs "home_agent")
// is stored, saved, shown in the WebUI as configured — and refuses every caller,
// including the one the operator meant. The failure looks like the endpoint being
// broken rather than the name being wrong.
var knownKeyNames func() []string
// SetKeyNameLookup registers the source of configured gateway key names.
func SetKeyNameLookup(fn func() []string) { knownKeyNames = fn }
// ValidateAccessSpec checks an access spec against the configured keys and roles.
//
// Roles are checked against the two that exist (admin, user) rather than left
// free-form: a role that does not exist produces an endpoint nobody can reach,
// which is the same silent failure as a bad key name.
func (ps *Plugins) ValidateAccessSpec(spec string) error {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
known := map[string]bool{"public": true, "admin": true, "user": true}
if knownKeyNames != nil {
for _, n := range knownKeyNames() {
if n != "" {
known[strings.ToLower(n)] = true
}
}
}
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name := part
if strings.HasPrefix(name, "@") {
name = strings.TrimPrefix(name, "@")
}
if name == "" {
return fmt.Errorf("access entry %q names no key", part)
}
if !known[strings.ToLower(name)] {
return fmt.Errorf("access entry %q is neither a role (admin/user/public) "+
"nor a configured key name", part)
}
}
return nil
}
// splitAccessSpec turns an override string into role and key lists. "@name" is a
// key; a bare word is a role. Shared by the reporting path and the enforcement
// path so the two can never disagree about what an override means.
func splitAccessSpec(spec string) (roles, keys []string) {
if strings.TrimSpace(spec) == "" {
return nil, nil
}
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
switch {
case part == "", part == "public":
// "public" is an enforcement concern, not a listing; it is reported
// via the Public field.
case strings.HasPrefix(part, "@"):
keys = append(keys, strings.TrimPrefix(part, "@"))
default:
roles = append(roles, part)
}
}
return roles, keys
}

View File

@ -0,0 +1,430 @@
package lua
// Plugin-registered HTTP endpoints.
//
// Three properties have to hold, and none of them is visible from the plugin's
// own point of view:
//
// 1. Auth and roles are enforced by the GATEWAY. A plugin that forgets to
// declare roles gets admin-only, not open — the failure mode of "public by
// omission" is an internet-scannable endpoint inside the gateway process.
// 2. A handler error is a value, never a panic. The pcall guard is the same one
// the hooks use, for the same reason.
// 3. The plugin cannot steer the gateway's own response framing: a nonsense
// status becomes a 500, and Content-Length / hop-by-hop headers are dropped.
//
// These tests drive the declaration layer and the response writer directly; the
// router-level dispatch is covered in the gateway package.
import (
"net/http"
"net/http/httptest"
"testing"
)
const httpPlugin = `
local P = {}
P.name = "api"
P.version = "1.0.0"
P.exports = {}
P.http = {
{
path = "health",
handler = "health",
roles = { "admin" },
description = "liveness",
},
{
path = "echo/:what",
method = "POST",
handler = "echo",
roles = { "admin", "user" },
},
}
function P.health(req)
return { status = 200, json = { ok = true, role = req.role, key = req.key } }
end
function P.echo(req)
return { status = 201, json = { what = req.params.what, body = req.body } }
end
return P
`
func TestPluginRegistersHTTPHandlers(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
hs := ps.HTTPHandlers()
if len(hs) != 2 {
t.Fatalf("expected 2 registered endpoints, got %d: %+v", len(hs), hs)
}
for _, h := range hs {
if h.Plugin != "api" {
t.Errorf("handler %q has no owner; the dispatcher could not route it", h.Path)
}
if h.Method == "" {
t.Errorf("handler %q has no method; the gateway would answer 405 forever", h.Path)
}
}
}
func TestHandlerRoleRules(t *testing.T) {
// A handler with no roles and no public opt-out is ADMIN-ONLY. This is the
// decision the whole feature turns on: "I forgot to declare roles" must not
// mean "anyone on the internet".
open := HTTPHandler{Path: "x", Handler: "h"}
if open.allows("user", "") {
t.Error("an undeclared handler must default to admin-only")
}
if !open.allows("admin", "") {
t.Error("an undeclared handler must still serve admin")
}
// The explicit opt-out is how a plugin says "this one really is public".
pub := HTTPHandler{Path: "x", Handler: "h", Public: true}
if !pub.allows("user", "") || !pub.allows("", "") {
t.Error("public=true must admit any role")
}
// Declared roles.
scoped := HTTPHandler{Path: "x", Roles: []string{"admin", "user"}}
if !scoped.allows("user", "homeagent") || !scoped.allows("admin", "admin") {
t.Error("a listed role must be admitted")
}
if (HTTPHandler{Path: "x", Roles: []string{"admin"}}).allows("user", "homeagent") {
t.Error("an unlisted role must be refused")
}
}
// Naming keys is how the operator grants one collaborator access without opening
// the endpoint to every user key. Two properties matter and neither is obvious:
// a named key wins even when the role rule would have refused it, and the named
// list REPLACES the role rule rather than adding to it.
func TestHandlerKeyRules(t *testing.T) {
h := HTTPHandler{Path: "x", Roles: []string{"admin"}, Keys: []string{"homeagent"}}
if !h.allows("user", "homeagent") {
t.Error("a named key must be admitted even though its role is not listed")
}
// The replacement semantics: listing one key must NOT also admit every user
// key through the role rule, which is what an OR would have done.
if h.allows("user", "club") {
t.Error("naming keys must replace the role rule, not add to it")
}
if h.allows("user", "") {
t.Error("an unnamed key must not match a named-key allowlist")
}
// Admin is always admitted, so a typo in the list never locks the operator
// out of the endpoint they are trying to tighten.
if !h.allows("admin", "admin") {
t.Error("admin must always reach an endpoint")
}
// Name matching is case-insensitive; a config written by hand varies.
if !(HTTPHandler{Path: "x", Keys: []string{"HomeAgent"}}).allows("user", "homeagent") {
t.Error("key names should match case-insensitively")
}
}
// The stored override replaces whatever the plugin declared — this is the admin
// editing access without touching a .lua that an upgrade would overwrite.
func TestEndpointAccessOverrideReplacesDeclaration(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
// health is declared admin-only; widen it to one named key.
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
t.Fatal(err)
}
var found *HTTPHandler
for _, h := range ps.HTTPHandlers() {
if h.Path == "health" {
hh := h
found = &hh
}
}
if found == nil {
t.Fatal("health endpoint disappeared after an access override")
}
if len(found.Roles) != 0 {
t.Errorf("the declared roles should be replaced, got %v", found.Roles)
}
if len(found.Keys) != 1 || found.Keys[0] != "homeagent" {
t.Errorf("override keys = %v, want [homeagent]", found.Keys)
}
if !found.allows("user", "homeagent") || found.allows("user", "club") {
t.Error("the override is not in force")
}
// Clearing restores the plugin's own declaration.
if err := ps.SetEndpointAccess("api", "health", ""); err != nil {
t.Fatal(err)
}
for _, h := range ps.HTTPHandlers() {
if h.Path == "health" {
if len(h.Keys) != 0 {
t.Errorf("clearing the override should restore the declaration, got %v", h.Keys)
}
return
}
}
t.Error("health endpoint vanished")
}
func TestSetEndpointAccessRejectsUnknownTarget(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
if err := ps.SetEndpointAccess("api", "nope", "@homeagent"); err == nil {
t.Error("setting access on a path the plugin does not expose must fail loudly")
}
if err := ps.SetEndpointAccess("nosuchplugin", "health", "@homeagent"); err == nil {
t.Error("setting access for an unknown plugin must fail loudly")
}
}
// A stored allowlist naming a key that does not exist refuses every caller while
// looking perfectly configured. ValidateAccessSpec exists to catch that at write
// time instead.
func TestValidateAccessSpec(t *testing.T) {
SetKeyNameLookup(func() []string { return []string{"homeagent", "club"} })
t.Cleanup(func() { SetKeyNameLookup(nil) })
for _, ok := range []string{"admin", "user", "public", "@homeagent",
"admin,@homeagent,@club", " admin , @club "} {
if err := (&Plugins{}).ValidateAccessSpec(ok); err != nil {
t.Errorf("spec %q should be accepted: %v", ok, err)
}
}
for _, bad := range []string{"@nosuchkey", "superuser", "@", "admin,@typo_key"} {
if err := (&Plugins{}).ValidateAccessSpec(bad); err == nil {
t.Errorf("spec %q must be refused: it is neither a role nor a configured key", bad)
}
}
if err := (&Plugins{}).ValidateAccessSpec(""); err != nil {
t.Errorf("an empty spec clears the override and must be accepted: %v", err)
}
}
func TestHandlerPathValidationRejectsEscapes(t *testing.T) {
// ":name" with empty segments around it is legal — the trims below reduce it
// to ":name". What must be refused is an empty PARAM NAME, which cannot be
// captured into request.params.
for _, bad := range []string{"", " ", "a/../b", "a//b", "wild*card", "q?x", "/:/"} {
if err := validateHandlerPath(bad); err == nil {
t.Errorf("path %q must be rejected: it can escape the mount or make the "+
"route table ambiguous", bad)
}
}
for _, ok := range []string{"health", "a/b", "echo/:what", "v1/things/:id", "/:name/"} {
if err := validateHandlerPath(ok); err != nil {
t.Errorf("path %q should be accepted: %v", ok, err)
}
}
}
func TestHandlerMethodValidation(t *testing.T) {
if m, err := normalizeHandlerMethod(""); err != nil || m != http.MethodGet {
t.Errorf("an empty method should default to GET, got %q %v", m, err)
}
if _, err := normalizeHandlerMethod("TRACE"); err == nil {
t.Error("TRACE must be refused")
}
if m, err := normalizeHandlerMethod("post"); err != nil || m != http.MethodPost {
t.Errorf("method should normalise to upper case, got %q %v", m, err)
}
}
func TestInvokeHandlerReturnsStructuredData(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
resp, err := ps.InvokeHandler("api", "health", &HandlerRequest{
Method: http.MethodGet, Path: "health", Role: "admin", Key: "sk-test",
})
if err != nil {
t.Fatalf("invoke: %v", err)
}
if resp.Status != 200 {
t.Errorf("status = %d, want 200", resp.Status)
}
j, ok := resp.JSON.(map[string]interface{})
if !ok {
t.Fatalf("json = %T, want a table (the handler's nested value must survive)", resp.JSON)
}
if j["ok"] != true || j["role"] != "admin" {
t.Errorf("handler data crossed wrong: %v", j)
}
}
func TestInvokeHandlerParamsAndBodyReachLua(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
resp, err := ps.InvokeHandler("api", "echo", &HandlerRequest{
Method: http.MethodPost, Path: "echo/thing",
Params: map[string]string{"what": "thing"},
Body: `{"n":1}`, Role: "user",
})
if err != nil {
t.Fatalf("invoke: %v", err)
}
j, _ := resp.JSON.(map[string]interface{})
if resp.Status != 201 {
t.Errorf("status = %d, want 201 (the handler chose it)", resp.Status)
}
if j["what"] != "thing" {
t.Errorf("path param did not reach the handler: %v", j)
}
if j["body"] != `{"n":1}` {
t.Errorf("body did not reach the handler: %v", j)
}
}
// A handler that raises must produce an error, never a process abort — the same
// SIGSEGV class of bug the hook guard exists for.
func TestInvokeHandlerErrorIsValueNotPanic(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const boom = `
local P = {}
P.name = "boom"
P.version = "1.0.0"
P.exports = {}
P.http = { { path = "blow", handler = "blow", roles = { "admin" } } }
function P.blow(req)
error("handler exploded")
end
return P
`
if err := ps.LoadSource("boom", boom); err != nil {
t.Fatal(err)
}
if _, err := ps.InvokeHandler("boom", "blow", &HandlerRequest{Role: "admin"}); err == nil {
t.Fatal("a raising handler must surface as an error")
}
}
// The plugin does not get to decide how the gateway frames its response.
func TestHandlerResponseFramingIsKernelOwned(t *testing.T) {
cases := []struct {
name string
in *HandlerResponse
want int
}{
{"status 0 becomes 200", &HandlerResponse{Status: 0, Body: "x"}, 200},
{"status 9999 becomes 500", &HandlerResponse{Status: 9999, Body: "x"}, 500},
{"status -1 becomes 500", &HandlerResponse{Status: -1, Body: "x"}, 500},
{"explicit 404 is kept", &HandlerResponse{Status: 404, Body: "x"}, 404},
}
for _, c := range cases {
w := httptest.NewRecorder()
writeHTTPHandlerResponse(w, c.in)
if w.Code != c.want {
t.Errorf("%s: status = %d, want %d", c.name, w.Code, c.want)
}
}
// A handler must not be able to desync the gateway's own framing.
w := httptest.NewRecorder()
writeHTTPHandlerResponse(w, &HandlerResponse{
Status: 200, Body: "hello",
Headers: map[string]string{
"Content-Length": "99999",
"Connection": "close",
"Transfer-Encoding": "chunked",
"X-Custom": "kept",
},
})
if got := w.Header().Get("Content-Length"); got != "" {
t.Errorf("a plugin set Content-Length (%s); the body length is the kernel's", got)
}
if got := w.Header().Get("X-Custom"); got != "kept" {
t.Errorf("a normal header should survive, got %q", got)
}
}
// A malformed declaration must not take the whole plugin down.
func TestBadHTTPRouteDoesNotBreakPluginLoad(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const mixed = `
local P = {}
P.name = "mixed"
P.version = "1.0.0"
P.exports = {}
P.state = { seen = true }
P.ui = { page = { page_id = "m", title = "M", mount = "<div/>" } }
P.http = {
{ path = "../escape", handler = "h", roles = { "admin" } },
{ path = "fine", handler = "h", roles = { "admin" } },
}
function P.h(req) return { status = 200, body = "ok" } end
return P
`
if err := ps.LoadSource("mixed", mixed); err != nil {
t.Fatalf("a bad route must not fail the plugin load: %v", err)
}
// The good route survives; the bad one is simply absent.
hs := ps.HTTPHandlers()
if len(hs) != 1 || hs[0].Path != "fine" {
t.Fatalf("expected only the valid route, got %+v", hs)
}
// And the plugin's own page/state still work.
if ps.State("mixed") == nil {
t.Error("the plugin's state was lost because of a bad route")
}
}
func TestParseHandlerResponseShapes(t *testing.T) {
if _, err := parseHandlerResponse("just a string"); err == nil {
t.Error("a non-table return must be an error, not an empty 200")
}
if _, err := parseHandlerResponse(map[string]interface{}{}); err == nil {
t.Error("an empty table should say so rather than returning a blank 200")
}
r, err := parseHandlerResponse(map[string]interface{}{
"status": float64(204), "headers": map[string]interface{}{"X-A": "b"},
})
if err != nil {
t.Fatal(err)
}
if r.Status != 204 || r.Headers["X-A"] != "b" {
t.Errorf("decoded %+v", r)
}
}
// TestEndpointAccessReportsPerEndpointOverride guards the reporting side of the
// override store.
//
// SetEndpointAccess already keyed overrides by plugin#path, but endpointAccess
// read the plugin-level key. After an operator saved a restriction on ONE
// endpoint, /api/plugins kept reporting the plugin's DECLARED roles, so the
// plugin page drew the pre-change value and a later save from that screen
// reverted the restriction. Authorization kept working — only the display and
// the round trip through the UI were broken, which is why it went unnoticed.
func TestEndpointAccessReportsPerEndpointOverride(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
if err := ps.LoadSource("api", httpPlugin); err != nil {
t.Fatal(err)
}
// httpPlugin registers "health" and "echo/:what".
if err := ps.SetEndpointAccess("api", "health", "user"); err != nil {
t.Fatalf("SetEndpointAccess: %v", err)
}
got := map[string][]string{}
for _, e := range ps.EndpointAccess() {
got[e.Path] = e.OverrideRoles
}
if len(got["health"]) != 1 || got["health"][0] != "user" {
t.Errorf("health override not reported to the UI: %+v", got["health"])
}
if len(got["echo/:what"]) != 0 {
t.Errorf("echo inherited an override it never had: %+v", got["echo/:what"])
}
}

View File

@ -0,0 +1,217 @@
package lua
// The plugin HTTP bridge is the one capability that lets a .lua file — installed
// from a textarea in the WebUI — reach out on the gateway's own network. That
// makes its DENY rules the security boundary, and these tests exist to pin the
// refusals rather than the happy path.
//
// The failure this guards against is concrete: an operator installs a quota
// plugin that reads a vendor's balance, and a later plugin (or a compromised
// one) posts that gateway key material to an attacker, or reads the cloud
// metadata endpoint, because __llmsproxy_http happened to be a general HTTP
// client.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
golua "github.com/aarzilli/golua/lua"
)
// newTestState returns a bare Lua state for the argument-decoding tests.
func newTestState(t *testing.T) *golua.State {
t.Helper()
L := golua.NewState()
t.Cleanup(L.Close)
L.OpenLibs()
return L
}
func TestPluginHTTPAllowlistRejectsUnlistedHost(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
for _, raw := range []string{
"https://evil.test/steal",
"https://169.254.169.254/latest/meta-data/",
"https://api.example.com.evil.test/x", // suffix trick
"https://user@evil.test/",
} {
if _, err := allow.checkURL(raw); err == nil {
t.Fatalf("allowlist permitted %q — a plugin must not reach hosts "+
"the operator never configured as a source", raw)
}
}
}
func TestPluginHTTPAllowlistAcceptsConfiguredHost(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
for _, raw := range []string{
"https://api.example.com/alpha/credits",
"https://API.EXAMPLE.COM/x", // host compare must be case-insensitive
"https://api.example.com:443/x",
} {
if _, err := allow.checkURL(raw); err != nil {
t.Fatalf("allowlist refused configured host %q: %v", raw, err)
}
}
}
func TestPluginHTTPRefusesPlainHTTPOffLoopback(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
// Allowed host, but plain HTTP would put the API key on the wire in clear.
if _, err := allow.checkURL("http://api.example.com/alpha"); err == nil {
t.Fatal("plain http to an allowed non-loopback host must be refused")
}
// Loopback stays allowed: it cannot leave the machine, and the billing rules
// UI reads the gateway's own API over plain HTTP in dev setups.
for _, raw := range []string{"http://127.0.0.1:8081/api/x", "http://localhost:8081/x"} {
if _, err := allow.checkURL(raw); err != nil {
t.Fatalf("loopback http must stay usable, got %v for %q", err, raw)
}
}
}
func TestPluginHTTPRejectsBadSchemesAndMissingURL(t *testing.T) {
allow := newHTTPAllowlist([]string{"api.example.com"})
for _, raw := range []string{"file:///etc/passwd", "ftp://api.example.com/x", "gopher://x"} {
if _, err := allow.checkURL(raw); err == nil {
t.Fatalf("scheme in %q must be refused", raw)
}
}
// An empty allowlist must deny, not fall open.
if _, err := newHTTPAllowlist(nil).checkURL("https://api.example.com/x"); err == nil {
t.Fatal("an unconfigured gateway must deny outbound HTTP entirely")
}
}
// A redirect is a hop the allowlist never saw. Following it would let an
// allowed host bounce a plugin's request (and its headers) anywhere.
func TestPluginHTTPDoesNotFollowRedirects(t *testing.T) {
if httpClient.CheckRedirect == nil {
t.Fatal("CheckRedirect must be set: following a redirect escapes the allowlist")
}
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil)
err := httpClient.CheckRedirect(req, []*http.Request{req})
if err != http.ErrUseLastResponse {
t.Fatalf("CheckRedirect must return ErrUseLastResponse so the 3xx is "+
"handed back unfollowed, got %v", err)
}
}
func TestPluginHTTPTimeoutAndBodyAreClamped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
allow := newHTTPAllowlist([]string{"127.0.0.1"})
u, err := allow.checkURL(srv.URL)
if err != nil {
t.Fatalf("loopback test server must be allowed: %v", err)
}
// An absurd timeout is clamped instead of honoured.
res, err := performHTTP(u, httpRequestSpec{Timeout: 24 * time.Hour})
if err != nil {
t.Fatalf("clamped request failed: %v", err)
}
if res.Status != 200 || !strings.Contains(res.Body, `"ok"`) {
t.Fatalf("unexpected result: status=%d body=%q", res.Status, res.Body)
}
}
func TestPluginHTTPOversizedBodyIsRefused(t *testing.T) {
big := strings.Repeat("x", httpMaxResponse+64)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(big))
}))
defer srv.Close()
allow := newHTTPAllowlist([]string{"127.0.0.1"})
u, _ := allow.checkURL(srv.URL)
// Refusing beats truncating: a truncated body parses as corrupt JSON and the
// plugin would report a parse error instead of the real cause.
if _, err := performHTTP(u, httpRequestSpec{}); err == nil {
t.Fatal("an oversized response must be refused, not silently truncated")
}
}
func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) {
// Lua-side contract: the bridge returns (nil, message) on every failure so a
// plugin mistake never raises through golua (see the SIGSEGV note on
// hookGuardSrc — an escaping error can take the gateway down).
allow := newHTTPAllowlist([]string{"api.example.com"})
if _, err := allow.checkURL("https://evil.test/"); err == nil {
t.Fatal("expected refusal")
}
// readHTTPRequestSpec must reject a non-table without touching the stack.
L := newTestState(t)
if _, err := readHTTPRequestSpec(L); err == nil {
t.Fatal("a missing argument table must be an error, not a panic")
}
}
// The bridge must be reachable under the name plugins actually call.
//
// This exists because the registration once used the CONSTANT'S IDENTIFIER as
// the Lua global name (`registerFn(L, "httpGlobal", …)`) rather than its VALUE,
// so the bridge was published as `httpGlobal` while every plugin called
// `__llmsproxy_http`. The failure only appeared at runtime, as
// "attempt to call global '__llmsproxy_http' (a nil value)" on the first real
// request — after the unit tests were green, because they drove the Go-side
// helpers directly and never went through Lua name resolution.
//
// So this test boots a real plugin and calls the bridge the way a plugin does.
func TestPluginHTTPBridgeIsReachableUnderItsLuaName(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
ps.SetOutboundHosts([]string{"127.0.0.1"})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":1.5}}`))
}))
defer srv.Close()
// The plugin asks for a loopback URL, which is allowed without being
// configured as a source. It type-checks both the table and arg.url, so a
// malformed call is reported by the plugin rather than as a bewildering URL
// parse error from the bridge.
src := `
local P = {}
P.name = "httpprobe"
P.version = "1.0.0"
P.exports = { "probe" }
-- Call passes the WHOLE argument table as a single Lua argument, which is the
-- same convention the hooks use and what a plugin page's pluginAPI.call ends up
-- producing. Taking the table (not a bare string) is the correct shape.
function P.probe(arg)
if type(arg) ~= "table" then return { error = "probe takes the args table, got " .. type(arg) } end
if type(arg.url) ~= "string" then return { error = "arg.url must be a string, got " .. type(arg.url) } end
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
if res == nil then return { error = tostring(err) } end
return { status = res.status, body = res.body }
end
return P
`
if err := ps.LoadSource("httpprobe", src); err != nil {
t.Fatalf("load: %v", err)
}
got, err := ps.Call("httpprobe", "probe", map[string]interface{}{"url": srv.URL})
if err != nil {
t.Fatalf("call: %v", err)
}
m, _ := got.(map[string]interface{})
if m == nil {
t.Fatalf("probe returned %T, want a table", got)
}
// A nil global surfaces as {error = "attempt to call global ... (a nil value)"} —
// which is a table too, so the error field is what distinguishes it.
if e, ok := m["error"]; ok {
t.Fatalf("the bridge is not reachable as %s: %v", httpGlobal, e)
}
if s, _ := m["status"].(float64); int(s) != 200 {
t.Fatalf("status=%v body=%v, want 200 from the loopback test server", m["status"], m["body"])
}
if body, _ := m["body"].(string); !strings.Contains(body, "monthlyCredits") {
t.Fatalf("body=%q, want the server's JSON", body)
}
}

View File

@ -0,0 +1,118 @@
package lua
// Per-caller state scoping.
//
// Role visibility answers "may this role see the plugin at all". It does not
// answer "which PART of its data", and for the billing plugin those are
// different questions: the global figures (total spend, every source, every
// model) are deployment data, while the per-key buckets describe one caller.
//
// So a plugin may declare a split, and the gateway then hands a non-admin
// caller only the buckets that are theirs. The plugin declares it because only
// the plugin author knows which of its buckets are global.
import "strings"
// StateScope is a plugin's declaration of which state buckets are global.
type StateScope struct {
// Global names the buckets that describe the whole deployment. Anything not
// listed is treated as per-caller and filtered.
Global []string `json:"global,omitempty"`
// Depth gives the nesting level of each PER-CALLER bucket: 1 for
// {maskedKey: bucket}, 2 for {day: {maskedKey: bucket}}. It is declared
// because both shapes are maps and sniffing would confuse an empty bucket
// with a nested one.
Depth map[string]int `json:"depth,omitempty"`
}
// IsGlobal reports whether a top-level state key is deployment-wide.
func (s *StateScope) IsGlobal(key string) bool {
if s == nil {
return true
}
for _, g := range s.Global {
if g == key {
return true
}
}
return false
}
// StateScopeFor returns a plugin's declared split, or nil when it declares none.
func (ps *Plugins) StateScopeFor(name string) *StateScope {
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Info.Name != name || p.UI == nil {
continue
}
return p.UI.StateScope
}
return nil
}
// compileDepthMap normalizes the declared depth table, defaulting to 1 for a
// per-caller bucket the author did not annotate.
func compileDepthMap(in map[string]int) map[string]int {
if len(in) == 0 {
return nil
}
out := make(map[string]int, len(in))
for k, v := range in {
if v < 1 {
v = 1
}
out[strings.TrimSpace(k)] = v
}
return out
}
// DepthOf returns the declared nesting level for a per-caller bucket, 1 by
// default. A bucket the author did not annotate is assumed flat, which is the
// common shape ({maskedKey: row}).
func (s *StateScope) DepthOf(key string) int {
if s == nil || s.Depth == nil {
return 1
}
if d, ok := s.Depth[key]; ok {
return d
}
return 1
}
// VisibilityOverrides returns every operator override in force, keyed by plugin
// name. An absent name means the plugin's own declaration applies.
//
// The WebUI needs this to show which visibility is IMPOSED, not merely which is
// in effect: an operator comparing two deployments should not have to open a
// .lua to learn whether a page is admin-only by the plugin's choice or by their
// own override.
func (ps *Plugins) VisibilityOverrides() map[string]string {
ps.mu.RLock()
defer ps.mu.RUnlock()
if len(ps.visibility) == 0 {
return nil
}
epKeys := make(map[string]struct{})
for _, e := range ps.endpointAccessLocked() {
epKeys[endpointOverrideKey(e.Plugin, e.Path)] = struct{}{}
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
// Per-endpoint overrides live in the SAME map under a "name#path" key.
// They are endpoint policy, not a plugin audience, so leaking them here
// both mislabels them and lets the plugin page write an audience of the
// form "probe#hello" that no code ever reads as one.
// Per-endpoint overrides share this map under a "name#path" key. They
// are endpoint policy, not a plugin audience: reporting them here both
// mislabels them and invites the plugin page to save an audience of the
// form "probe#hello" that nothing reads as an audience. The set of
// endpoint keys is already computed, so ask it rather than re-deriving
// the shape from the key.
if _, isEndpoint := epKeys[k]; isEndpoint {
continue
}
out[k] = v
}
return out
}

View File

@ -0,0 +1,193 @@
package lua
// Plugin visibility: a plugin page is not automatically public data.
//
// The billing page renders GLOBAL cost across every source and the quota page
// renders upstream balances. Both were served to any authenticated key because
// /api/ui-inject applied no role filter — measured, not theorised: a user-role
// key pulled the 18KB billing state and read the source names and the $8.86
// total straight out of it.
//
// These tests pin the DENIALS. A visibility feature whose tests only assert that
// an admin still sees everything passes while the leak is wide open.
import (
"testing"
)
func TestAudienceAllows(t *testing.T) {
cases := []struct {
want, role string
ok bool
why string
}{
{"", "user", true, "unset means everyone"},
{"all", "user", true, "explicit all"},
{"admin", "admin", true, "exact match"},
{"admin", "user", false, "admin-only excludes user"},
{"user", "user", true, "user-only admits user"},
{"user", "admin", false, "user-only excludes admin"},
{"admin,user", "user", true, "list admits a listed role"},
{" admin , user ", "user", true, "whitespace is not significant"},
{"ADMIN", "user", false, "case is normalised"},
{"*", "user", false, `"*" must NOT be a synonym for all`},
}
for _, c := range cases {
if got := audienceAllows(c.want, c.role); got != c.ok {
t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)",
c.want, c.role, got, c.ok, c.why)
}
}
}
func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
// One admin-only plugin, one open plugin: the filter must drop exactly one
// and leave the other intact, or a plugin author has no way to ship a mixed
// set.
const adminOnly = `
local P = {}
P.name = "adminonly"
P.version = "1.0.0"
P.exports = {}
P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "<div>secret</div>" } }
return P
`
const open = `
local P = {}
P.name = "openpage"
P.version = "1.0.0"
P.exports = {}
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
return P
`
for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} {
if err := ps.LoadSource(name, src); err != nil {
t.Fatalf("load %s: %v", name, err)
}
}
admin := ps.UIForRole("admin")
if len(admin.Pages) != 2 {
t.Errorf("admin should see both pages, got %d", len(admin.Pages))
}
user := ps.UIForRole("user")
for _, pg := range user.Pages {
if pg.PageID == "secret" {
t.Fatal("a user key can see the admin-only page — this is the leak")
}
}
if len(user.Pages) != 1 || user.Pages[0].PageID != "open" {
t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages))
}
}
// The operator must be able to impose visibility the plugin did not choose —
// that is the whole point of the override, since a bundled .lua cannot be edited
// without being overwritten on the next upgrade.
func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const open = `
local P = {}
P.name = "openpage"
P.version = "1.0.0"
P.exports = {}
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
return P
`
if err := ps.LoadSource("openpage", open); err != nil {
t.Fatal(err)
}
if len(ps.UIForRole("user").Pages) != 1 {
t.Fatal("precondition: the page should start visible to user")
}
ps.SetPluginVisibility("openpage", "admin")
if got := ps.UIForRole("user").Pages; len(got) != 0 {
t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got))
}
if ps.VisibilityOverride("openpage") != "admin" {
t.Error("the override should be reportable so /api/plugins can show it")
}
// Clearing restores the plugin's own declaration.
ps.SetPluginVisibility("openpage", "")
if got := ps.UIForRole("user").Pages; len(got) != 1 {
t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got))
}
}
// A page may narrow visibility below the plugin default, so one plugin can ship
// a public summary beside an admin-only detail page.
func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const mixed = `
local P = {}
P.name = "mixed"
P.version = "1.0.0"
P.exports = {}
P.ui = {
pages = {
{ page_id = "summary", title = "Summary", mount = "<div>s</div>" },
{ page_id = "details", title = "Details", mount = "<div>d</div>", audience = "admin" },
},
}
return P
`
if err := ps.LoadSource("mixed", mixed); err != nil {
t.Fatal(err)
}
user := ps.UIForRole("user")
if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" {
t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages))
}
if len(ps.UIForRole("admin").Pages) != 2 {
t.Error("admin should see both pages")
}
}
func TestPluginStateVisibleTo(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
const adminOnly = `
local P = {}
P.name = "adminonly"
P.version = "1.0.0"
P.exports = {}
P.state = { secret = "x" }
P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "<div/>" } }
return P
`
if err := ps.LoadSource("adminonly", adminOnly); err != nil {
t.Fatal(err)
}
// The data has to follow the page: a hidden UI with an open state endpoint is
// the same leak with one more step.
if ps.StateVisibleTo("adminonly", "user") {
t.Error("user must not read an admin-only plugin's state")
}
if !ps.StateVisibleTo("adminonly", "admin") {
t.Error("admin must still read it")
}
// Unknown plugin: unchanged behaviour, so a widget that is not installed does
// not start answering 403.
if !ps.StateVisibleTo("nosuchplugin", "user") {
t.Error("an unknown plugin name must not become forbidden; that would change " +
"behaviour for plugins that simply are not installed")
}
}
func pageIDs(pages []*UIPage) []string {
out := make([]string, 0, len(pages))
for _, p := range pages {
if p != nil {
out = append(out, p.PageID)
}
}
return out
}

View File

@ -32,6 +32,7 @@ package lua
// less featureful, so the split is explicit and documented.
import (
"log"
"encoding/json"
"fmt"
"os"
@ -126,6 +127,13 @@ type UIExtension struct {
// Elements are snippets injected into EXISTING pages, keyed by target page
// id (e.g. "status", "keys"). Order within a target is plugin load order.
Elements []UIElement `json:"elements,omitempty"`
// Visibility is the plugin-wide default audience, applied to every page and
// element this plugin contributes. A per-page Audience narrows it further.
Visibility string `json:"visibility,omitempty"`
// StateScope splits the plugin's published state into global and per-caller
// buckets, for a plugin that is readable by more than one role but must not
// hand one caller another's data.
StateScope *StateScope `json:"state_scope,omitempty"`
}
// UIPage is a plugin-provided page.
@ -134,6 +142,20 @@ type UIPage struct {
Title string `json:"title"` // sidebar label
Icon string `json:"icon"` // optional inline SVG or short glyph
Order int `json:"order"` // sidebar sort key (default 100)
// Audience limits which API-key roles may see this page. "" and "all" mean
// every authenticated role; "admin" hides it from user keys; a comma list
// ("admin,user") names the roles allowed.
//
// This exists because a plugin page is not automatically public data. The
// billing page shows GLOBAL cost across every source, and the quota page
// shows upstream balances — neither belongs to whoever happens to hold a
// user key, yet both were served to them because /api/ui-inject applied no
// role filter at all.
//
// The plugin declares its own default and the gateway may override it (see
// SetPluginVisibility), because the plugin author cannot know the operator's
// policy but the operator must be able to impose one.
Audience string `json:"audience,omitempty"`
// Mount is the page body. It may contain <script> and <style>; the kernel
// executes scripts AFTER injecting the HTML so the DOM exists, and exposes
// `pluginAPI` to them (see docs/plugins.md).
@ -145,6 +167,10 @@ type UIElement struct {
// Target is the id of the pane to inject into: "status", "chat", "keys",
// "sort", "sources" or "adapters".
Target string `json:"target"`
// Audience is the same role filter as UIPage.Audience. An injected tile can
// leak exactly what a whole page would — the billing status tile shows total
// cost — so the field exists here too rather than only on pages.
Audience string `json:"audience,omitempty"`
// Anchor is where in the target pane: "top", "bottom" or "before:<sel>" /
// "after:<sel>" for a CSS selector. Empty = "bottom".
Anchor string `json:"anchor,omitempty"`
@ -179,6 +205,14 @@ type Plugin struct {
// reasoning as a systemd unit being masked rather than removed.
Disabled bool
dir string
// http lists endpoints this plugin mounts under /api/plugins/http/. Read at
// load time, never mutated afterwards.
http []HTTPHandler
// exports lists the functions this plugin allows the WebUI to call through
// POST /api/plugins/{name}/call/{fn}. It is read once at load from
// plugin.exports and never changes, so Call can check it without taking a
// Lua read on the hot path.
exports []string
// script is the plugin's source, kept so a reload can rebuild its state.
script string
// Builtin marks a plugin that shipped with the gateway. The UI shows it as
@ -228,9 +262,16 @@ type Plugins struct {
// direct log call so the plugin package stays free of a logging dependency
// and tests can capture the warnings.
logf func(format string, args ...interface{})
// visibility holds the operator's per-plugin audience overrides, keyed by
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
visibility visibilityOverrides
// stateDirDisabled turns persistence off. Used by tests that assert state
// starts empty, and by an embedder that has no writable plugin dir.
stateDirDisabled bool
// allowHTTP bounds plugin outbound requests to operator-declared hosts.
// nil means deny-all: an unconfigured gateway must not silently grant a
// plugin unrestricted network access.
allowHTTP *httpAllowlist
// save coalesces state writes into one background goroutine.
//
// A hook must NOT write synchronously: the billing plugin mutates state on
@ -412,6 +453,10 @@ func NewPlugins(vm *VM, dir string) *Plugins {
stageFuncs: map[Stage][]hookCall{},
hookErr: newHookErrors(),
dir: dir,
// Deny-by-default. NewPlugins is called during wiring, before any
// source list is known; SetOutboundHosts replaces this with the real
// set. A plugin loaded in that window simply cannot make requests.
allowHTTP: newHTTPAllowlist(nil),
}
// The saver is started even with no dir: a gateway can be given a plugin dir
// later, and a saver that only exists when dir != "" would silently never
@ -429,6 +474,27 @@ func NewPlugins(vm *VM, dir string) *Plugins {
// DisableStatePersistence turns persistence off (tests/benchmarks only).
func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true }
// SetOutboundHosts declares which hosts plugins may contact via
// __llmsproxy_http. The gateway passes the host of every configured source:
// those are operator-declared upstreams, and reusing that set keeps a plugin's
// reach no wider than the deployment already trusts. An empty list means no
// outbound host is allowed at all — plugins then still load, they just cannot
// make requests.
func (ps *Plugins) SetOutboundHosts(hosts []string) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.allowHTTP = newHTTPAllowlist(hosts)
}
// outboundAllowlist returns the current allowlist. bootPluginState reads it
// before any concurrent reload can swap it, and a nil result is treated as
// "deny everything" rather than "allow everything".
func (ps *Plugins) outboundAllowlist() *httpAllowlist {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.allowHTTP
}
func (ps *Plugins) Close() {
if ps == nil {
return
@ -446,6 +512,12 @@ func (ps *Plugins) LoadDir() error {
if ps.dir == "" {
return nil
}
// Overrides first: they are keyed by plugin name and must be in place before
// anything reads them, and a reload must not blank a restriction the
// operator set earlier in this same process.
if err := ps.LoadAccessOverrides(); err != nil {
log.Printf("[core] plugin access overrides not loaded: %v", err)
}
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
return nil
}
@ -496,10 +568,17 @@ func (ps *Plugins) SeedBundled() error {
// bootPluginState compiles one plugin into a fresh Lua state and stores its
// returned table under pluginGlobal. It is the plugin counterpart of
// adapterPool.boot, minus the pooling: a plugin keeps exactly one state.
func bootPluginState(code, name string) (*worker, error) {
func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) {
L := golua.NewState()
L.OpenLibs()
setupGlobals(L)
// Only plugins get __llmsproxy_http. Adapters are operator-supplied config
// and go through setupGlobals too, so installing the bridge here — not in
// setupGlobals — is what keeps the capability scoped to installed .lua
// files.
if allow != nil {
registerFn(L, httpGlobal, luaHTTPFn(L, allow))
}
if err := L.DoString(code); err != nil {
L.Close()
return nil, fmt.Errorf("compile plugin %s: %w", name, err)
@ -537,7 +616,7 @@ func (ps *Plugins) LoadSource(name, code string) error {
// One state, held for the plugin's lifetime (see Plugin.state): a hook that
// accumulates into plugin.state would otherwise split its totals across
// whichever worker it happened to run on.
w, err := bootPluginState(code, name)
w, err := bootPluginState(code, name, ps.outboundAllowlist())
if err != nil {
p.LoadError = err.Error()
ps.append(p)
@ -592,6 +671,22 @@ func (ps *Plugins) LoadSource(name, code string) error {
p.UI = ui
}
// ---- http endpoints ----
p.http = readPluginHTTP(w.L)
if ui, ok := readPluginUI(w.L); ok {
p.UI = ui
if ui.StateScope != nil {
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
}
}
// ---- exports ----
// Read here, while the state is locked and before restore() runs: the list is
// static for the life of the plugin, so Call never has to re-enter Lua just
// to decide whether a call is allowed.
p.exports = readPluginExports(w.L)
// Restore persisted state AFTER the plugin compiled and registered its
// hooks, so the restore overwrites the compiled-in defaults instead of being
// overwritten by them. Doing it earlier would mean a restart resets the
@ -675,9 +770,47 @@ func pluginHookName(L *golua.State, stage string) (string, bool) {
}
// readPluginUI reads the optional ui extension block.
// readPluginExports collects plugin.exports, the list of functions the WebUI is
// allowed to invoke through POST /api/plugins/{name}/call/{fn}.
//
// It accepts either an array of names or a map whose KEYS are the names — both
// shapes read naturally in Lua and authors reach for either. A missing or
// non-conforming table yields nil, which Call treats as "nothing is callable":
// a plugin that never declared an export simply has no callable surface.
func readPluginExports(L *golua.State) []string {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil
}
L.GetField(-1, "exports")
if L.Type(-1) != golua.LUA_TTABLE {
L.SetTop(0)
return nil
}
// luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering,
// before the json global is installed, and luaToJSON needs it. Going through
// JSON here silently produced an empty list, which then made every exported
// function look unexported.
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
arr, ok := raw.([]interface{})
if !ok {
return nil
}
list := make([]string, 0, len(arr))
for _, v := range arr {
if s := asString(v); s != "" {
list = append(list, s)
}
}
return list
}
func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, "ui")
@ -685,12 +818,67 @@ func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.SetTop(0)
return nil, false
}
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
// registering, BEFORE setupGlobals installs the json global that luaToJSON
// depends on — so the conversion fails and every field except the ones on
// Page/Pages (read separately) comes back empty. That is why visibility and
// state_scope silently read as unset even though the .lua declares them:
// the third field in this file to be lost to the same cause, after
// readPluginExports and readPluginHTTP.
var ui UIExtension
if err := luaToJSON(L, -1, &ui); err != nil {
L.SetTop(0)
return nil, false
}
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
if m, ok := raw.(map[string]interface{}); ok {
ui.Visibility = asString(m["visibility"])
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
sc := &StateScope{}
if gs, ok := scope["global"].([]interface{}); ok {
for _, g := range gs {
if s := asString(g); s != "" {
sc.Global = append(sc.Global, s)
}
}
}
if ds, ok := scope["depth"].(map[string]interface{}); ok {
sc.Depth = map[string]int{}
for k, v := range ds {
if n, ok := asFloat(v); ok {
sc.Depth[k] = int(n)
}
}
sc.Depth = compileDepthMap(sc.Depth)
}
ui.StateScope = sc
}
if pg, ok := m["page"].(map[string]interface{}); ok {
ui.Page = uipageFromValue(pg)
}
if pgs, ok := m["pages"].([]interface{}); ok {
for _, it := range pgs {
if pm, ok := it.(map[string]interface{}); ok {
ui.Pages = append(ui.Pages, uipageFromValue(pm))
}
}
}
if els, ok := m["elements"].([]interface{}); ok {
for _, it := range els {
em, ok := it.(map[string]interface{})
if !ok {
continue
}
el := UIElement{
Target: asString(em["target"]),
Anchor: asString(em["anchor"]),
Mount: asString(em["mount"]),
Audience: asString(em["audience"]),
}
if n, ok := asFloat(em["order"]); ok {
el.Order = int(n)
}
ui.Elements = append(ui.Elements, el)
}
}
}
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
@ -779,6 +967,34 @@ type DiskEntry struct {
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Hooks int `json:"hooks"`
// Pages lists the UI page ids the plugin contributes. The desktop panel
// shows it so an operator can tell at a glance that a plugin actually
// renders something, without opening its source.
Pages []string `json:"pages,omitempty"`
}
// pageNames returns the plugin's contributed UI page ids, de-duplicated.
// A plugin may declare `page` or `pages` or both, and the same id can appear
// twice; the loader's merge keeps the first, so this mirrors that order.
func pageNames(p *Plugin) []string {
if p == nil || p.UI == nil {
return nil
}
declared := make([]*UIPage, 0, len(p.UI.Pages)+1)
if p.UI.Page != nil {
declared = append(declared, p.UI.Page)
}
declared = append(declared, p.UI.Pages...)
var out []string
seen := map[string]bool{}
for _, pg := range declared {
if pg == nil || seen[pg.PageID] {
continue
}
seen[pg.PageID] = true
out = append(out, pg.PageID)
}
return out
}
// OnDisk lists every .lua file in the plugin directory with its load state.
@ -810,6 +1026,10 @@ func (ps *Plugins) OnDisk() []DiskEntry {
de.Version = p.Info.Version
de.Description = p.Info.Description
de.Hooks = len(p.Hooks)
// A plugin may declare a single page or several; both forms end
// up in Pages (see the merge at load time), so reporting that one
// field covers either.
de.Pages = pageNames(p)
} else {
// On disk but not in the running set: either it failed so badly
// that LoadSource never produced a record, or the dir was written
@ -906,6 +1126,140 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
}
// UI returns the merged UI extensions to inject into the WebUI.
// audienceAllows reports whether a role is permitted by an audience string.
//
// The grammar is deliberately small, because it is authored in two places (a
// .lua file and config.yaml) and a richer language would only give someone a way
// to express a rule nobody audits:
//
// "" or "all" everyone
// "admin" admin role only
// "user" user role only
// "admin,user" either
//
// "*" is deliberately NOT a synonym for "all": an audience that looks
// restrictive in a config file while meaning everything is exactly the kind of
// setting that fails silently.
func audienceAllows(want, role string) bool {
want = strings.ToLower(strings.TrimSpace(want))
if want == "" || want == "all" {
return true
}
role = strings.ToLower(strings.TrimSpace(role))
for _, part := range strings.Split(want, ",") {
if strings.TrimSpace(part) == role {
return true
}
}
return false
}
// visibilityOverrides holds the operator's per-plugin audience overrides.
//
// A plugin declares its own default and the operator may force one. Both sides
// need to be able to: the plugin author does not know the deployment's policy,
// and the operator must not have to edit a bundled .lua — which the next
// upgrade would overwrite anyway.
type visibilityOverrides map[string]string
// 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) {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
plugin = strings.TrimSpace(plugin)
audience = strings.TrimSpace(audience)
if audience == "" {
delete(ps.visibility, plugin)
} else {
ps.visibility[plugin] = audience
}
ps.saveAccessOverridesLocked()
}
// VisibilityOverride returns the operator's audience override for a plugin,
// or "" when none is set. Exposed so /api/plugins can show which visibility is
// imposed rather than only the one in effect.
func (ps *Plugins) VisibilityOverride(plugin string) string {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.visibility[plugin]
}
// uiAudience resolves the audience in force for a plugin: the operator's
// override if set, else the plugin's declared default.
func (ps *Plugins) uiAudience(p *Plugin, declared string) string {
ps.mu.RLock()
override := ps.visibility[p.Info.Name]
ps.mu.RUnlock()
if override != "" {
return override
}
return declared
}
// UIForRole returns the UI a given role may see.
//
// Filtering happens HERE, not in the HTTP layer, so every consumer sees the same
// view — the WebUI boot payload, the Electron shell and the tests. Filtering at
// one call site means the others quietly keep serving everything.
//
// A plugin's page/element can narrow visibility further than its own default
// (a public summary page beside an admin-only one), so the role check runs on
// the resolved plugin audience AND on each item's own declaration.
func (ps *Plugins) UIForRole(role string) *UIExtension {
full := ps.UI()
if full == nil {
return &UIExtension{}
}
// An admin sees everything, including anything a plugin hides from itself
// by mistake: anything else makes an override unverifiable from the browser.
if role == "admin" {
return full
}
out := &UIExtension{}
// The plugin-level default is per plugin, so pages are filtered by walking
// plugins rather than pages: a page id alone does not say who owns it.
for _, p := range ps.plugins {
if p.UI == nil || p.LoadError != "" {
continue
}
aud := ps.uiAudience(p, p.UI.Visibility)
if !audienceAllows(aud, role) {
continue
}
for _, pg := range p.UI.Pages {
if pg == nil || !audienceAllows(pg.Audience, role) {
continue
}
out.Pages = append(out.Pages, pg)
}
for _, el := range p.UI.Elements {
if !audienceAllows(el.Audience, role) {
continue
}
out.Elements = append(out.Elements, el)
}
// Page (singular) is the other half of the same declaration and both
// merge in UI(). Handling only Pages made every plugin that used the
// single-page form disappear for a user key — silently, with no error,
// because "this plugin contributes nothing to you" is indistinguishable
// from "this plugin is not installed".
if p.UI.Page != nil && audienceAllows(p.UI.Page.Audience, role) {
if out.Page == nil {
out.Page = p.UI.Page
}
if !containsPage(out.Pages, p.UI.Page.PageID) {
out.Pages = append(out.Pages, p.UI.Page)
}
}
}
return out
}
func (ps *Plugins) UI() *UIExtension {
if u := ps.ui.Load(); u != nil {
return u
@ -1582,3 +1936,319 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{})
ps.markDirtyLocked(p)
return out, nil
}
// Call invokes a plugin function that the plugin declared in plugin.exports.
//
// The gate is the declaration, not the caller: a plugin lists the handful of
// entry points its own UI is allowed to reach, and anything absent is refused.
// Without that list this endpoint would be a remote Lua evaluator — it could
// call the plugin's private helpers, hand them crafted arguments, and read
// whatever the plugin can see, which for a plugin holding an API key means
// handing that key to anyone who can POST to the gateway.
//
// The return value is JSON-shaped rather than restricted to a map, because a
// quota report is a nested structure with numbers and booleans that must not be
// flattened. Hook results still go through invoke, whose map-typed contract the
// state merge depends on.
func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface{}, error) {
ps.mu.RLock()
var p *Plugin
for _, c := range ps.plugins {
if c.Info.Name == name {
p = c
break
}
}
ps.mu.RUnlock()
if p == nil {
return nil, fmt.Errorf("plugin %s not found", name)
}
if !pluginExportAllowed(p, fn) {
return nil, fmt.Errorf("plugin %s does not export %q", name, fn)
}
p.mu.Lock()
w := p.state
if w == nil {
p.mu.Unlock()
return nil, fmt.Errorf("no state for plugin %s", name)
}
defer p.mu.Unlock()
L := w.L
L.SetTop(0)
defer L.SetTop(0)
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
return nil, fmt.Errorf("plugin table missing")
}
plug := L.GetTop()
L.GetGlobal(hookGuardName)
if L.IsNil(-1) {
L.Pop(1)
}
L.GetField(plug, fn)
if !L.IsFunction(-1) {
return nil, fmt.Errorf("export %s missing", fn)
}
if args == nil {
args = map[string]interface{}{}
}
pushGoValue(L, plainForLua(args))
if err := L.Call(2, 2); err != nil {
return nil, err
}
errMsg := ""
if L.GetTop() >= 2 {
if !L.IsNil(-1) {
errMsg = L.ToString(-1)
}
L.Pop(1)
}
if errMsg != "" {
return nil, fmt.Errorf("plugin %s: %s", name, errMsg)
}
if L.GetTop() < 1 || L.IsNil(-1) {
return nil, nil
}
var out interface{}
if err := luaToJSON(L, -1, &out); err != nil {
return nil, nil
}
return out, nil
}
// pluginExportAllowed reports whether fn is in the plugin's exports list.
// An absent list means nothing is callable — the safe reading, since silently
// allowing all would turn an undeclared plugin into a remote evaluator.
func pluginExportAllowed(p *Plugin, fn string) bool {
for _, e := range p.exports {
if e == fn {
return true
}
}
return false
}
// StateVisibleTo reports whether a role may read a plugin's published state.
//
// The audience is resolved exactly as for the UI: operator override first, then
// the plugin's declaration. A plugin with neither is readable by any
// authenticated role, which is the pre-existing behaviour and the reason a
// newly installed widget keeps working without an edit.
// HTTPHandler is one endpoint a plugin registers on the gateway's router.
//
// A plugin that only receives hooks (billing) has nothing to expose. A plugin
// that computes something the operator may want to query from OUTSIDE the
// browser — a health probe, a CI check, another tool — needs a route, and
// hard-coding one in the gateway would mean every such plugin needs a gateway
// change. So the plugin declares the path, the method and the role it requires,
// and the gateway mounts it.
//
// The handler is a Lua function taking (request) and returning (response). Both
// are plain tables: request carries method/path/query/headers/body, response
// carries status/headers/body/json. Nothing here can reach the Go side except
// through those two values, so the surface a plugin can express is bounded by
// what a table can say.
type HTTPHandler struct {
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
// segments, captured into request.params.
Path string `json:"path"`
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
// accepted — an arbitrary verb would need a switch in the router for no gain.
Method string `json:"method,omitempty"`
// Roles are the API-key roles allowed to call it. EMPTY MEANS EVERYONE, which
// is the wrong default for an endpoint a plugin invented: an unauthenticated
// internet scan should not be able to ask it questions. RequireRole exists
// so a plugin that really wants public access has to say so.
Roles []string `json:"roles,omitempty"`
// Keys names SPECIFIC gateway keys allowed to call it, by their configured
// name (the `name:` on a gateway_keys entry), never by the secret.
//
// Names rather than secrets for three reasons: a secret is rotated without
// notice and a rule written against it would silently stop matching; putting
// secrets in a plugin file scatters credentials across the install; and the
// operator reads names. A key with no name cannot be named here.
//
// When Keys is non-empty it is the WHOLE rule, not an addition to Roles. An
// OR would mean an operator writing a two-key allowlist had also granted
// every user key, which is the opposite of what they meant.
Keys []string `json:"keys,omitempty"`
// RequireAuth defaults to true in practice: the gateway refuses a handler
// with neither Roles nor an explicit opt-out.
Public bool `json:"public,omitempty"`
// Handler is the name of the plugin function to invoke.
Handler string `json:"handler"`
// Description is shown in /api/plugins so an operator can see what a plugin
// has exposed without reading the .lua.
Description string `json:"description,omitempty"`
// Plugin is filled in by HTTPHandlers: which plugin registered this route.
// The gateway dispatcher needs it to know which Lua state to enter, and
// deriving it from the path would mean trusting the plugin's own prefix.
Plugin string `json:"-"`
}
// routePath renders the handler's full mount path.
func (h HTTPHandler) routePath() string {
return "/api/plugins/http/" + strings.Trim(h.Path, "/")
}
// allows reports whether a caller may invoke this handler.
//
// keyName is the caller key's configured name ("" when it has none), not the
// secret.
func (h HTTPHandler) allows(role, keyName string) bool {
// Admin is always admitted. An operator must be able to inspect what a plugin
// exposes even while locked out of it, or a typo in a key list would leave
// nobody able to diagnose the endpoint they were trying to tighten.
if role == "admin" {
return true
}
// Named keys REPLACE the role rule rather than adding to it.
if len(h.Keys) > 0 {
if keyName == "" {
return false
}
for _, k := range h.Keys {
if strings.EqualFold(strings.TrimSpace(k), keyName) {
return true
}
}
return false
}
if h.Public {
return true
}
if len(h.Roles) == 0 {
// No declared roles and no public opt-out: treat as admin-only. A plugin
// author who forgets this gets a 403 rather than an open endpoint, and
// the error says which field to set.
return role == "admin"
}
for _, r := range h.Roles {
if strings.EqualFold(strings.TrimSpace(r), role) {
return true
}
}
return false
}
func (ps *Plugins) StateVisibleTo(name, role string) bool {
if role == "admin" {
return true
}
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Info.Name != name {
continue
}
override := ps.visibility[name]
if override != "" {
return audienceAllows(override, role)
}
declared := ""
if p.UI != nil {
declared = p.UI.Visibility
}
return audienceAllows(declared, role)
}
// An unknown plugin name: no visibility can be established, and this endpoint
// answers with an empty state rather than an error, so refusing here would
// change behaviour for a name that may simply not be installed.
return true
}
func containsPage(pages []*UIPage, id string) bool {
for _, p := range pages {
if p != nil && p.PageID == id {
return true
}
}
return false
}
// readPluginHTTP collects plugin.http, the endpoints the plugin asks the gateway
// to mount.
//
// A malformed entry is DROPPED rather than failing the plugin: a plugin with one
// bad route should still load its hooks and pages, and the bad route simply does
// not exist. Failing the whole plugin would take a working billing widget down
// over a typo in an experimental endpoint.
func readPluginHTTP(L *golua.State) []HTTPHandler {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil
}
L.GetField(-1, "http")
if L.Type(-1) != golua.LUA_TTABLE {
L.SetTop(0)
return nil
}
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
//
// luaToJSON needs the json global, which is not installed yet while a plugin
// is registering. And golua's GetField does NOT convert a numeric key: with
// a string index "1" it returns nil, so an index loop silently produced an
// empty list and every plugin appeared to declare no endpoints at all.
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
items, ok := raw.([]interface{})
if !ok {
return nil
}
var list []HTTPHandler
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
h := HTTPHandler{
Path: asString(m["path"]),
Method: asString(m["method"]),
Handler: asString(m["handler"]),
Description: asString(m["description"]),
Public: m["public"] == true,
}
if roles, ok := m["roles"].([]interface{}); ok {
for _, r := range roles {
if s := asString(r); s != "" {
h.Roles = append(h.Roles, s)
}
}
}
if keys, ok := m["keys"].([]interface{}); ok {
for _, k := range keys {
if s := asString(k); s != "" {
h.Keys = append(h.Keys, s)
}
}
}
// A malformed entry is dropped rather than fatal: one bad route must not
// cost a plugin its hooks and pages. That is also why the mounted count
// can be lower than the declared count — /api/plugins lists what mounted.
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
h.Method = method
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
list = append(list, h)
}
}
}
return list
}
// uipageFromValue builds a UIPage from a decoded Lua table.
func uipageFromValue(m map[string]interface{}) *UIPage {
pg := &UIPage{
PageID: asString(m["page_id"]),
Title: asString(m["title"]),
Icon: asString(m["icon"]),
Mount: asString(m["mount"]),
Audience: asString(m["audience"]),
}
if n, ok := asFloat(m["order"]); ok {
pg.Order = int(n)
}
return pg
}

View File

@ -192,6 +192,11 @@ local function priceFor(payload)
out = {
prompt = d.prompt or 0, completion = d.completion or 0,
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
-- nil = bill reasoning at the completion rate, which is what every
-- provider measured actually charges. Set per-rule only for a provider
-- that publishes a distinct rate for thinking tokens.
reasoning_price = d.reasoning_price,
long_context = d.long_context,
}
-- model dimension (a token price overrides the default's token prices)
@ -202,6 +207,8 @@ local function priceFor(payload)
if mp.completion ~= nil then out.completion = mp.completion end
if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end
if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end
if mp.reasoning_price ~= nil then out.reasoning_price = mp.reasoning_price end
if mp.long_context ~= nil then out.long_context = mp.long_context end
if mp.peak ~= nil then out.peak = mp.peak end
end
@ -213,6 +220,8 @@ local function priceFor(payload)
if sp.completion ~= nil then out.completion = sp.completion end
if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end
if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end
if sp.reasoning_price ~= nil then out.reasoning_price = sp.reasoning_price end
if sp.long_context ~= nil then out.long_context = sp.long_context end
if sp.peak ~= nil then out.peak = sp.peak end
end
@ -346,6 +355,60 @@ local function costFor(payload, price)
+ cacheHit * price.prompt * discount
+ completion * price.completion * mult
-- Long-context step: the tokens PAST a threshold cost more.
--
-- A marginal rate on the excess, not a blended average — that is how
-- providers publish it (DeepSeek doubles past 128k) and what keeps it
-- composable: a cache read beyond the threshold gets BOTH the cache discount
-- AND the long-context rate, as it is billed. A blended average would have to
-- choose one, and choosing wrong under-bills exactly the cache-heavy long
-- requests the cache exists to make cheap.
--
-- The excess splits between cached and fresh in the same proportion as the
-- whole prompt, so no token is charged twice and none is missed.
local lc = price.long_context
if lc then
local totalPrompt = tonumber(payload.prompt_tokens) or 0
local threshold = tonumber(lc.above) or 0
local lcRate = tonumber(lc.prompt)
if totalPrompt > threshold and threshold > 0 and lcRate and lcRate > 0 then
local excess = totalPrompt - threshold
-- Only the FRESH share of the excess carries the step: a cache read is
-- already billed at the discounted rate, and re-pricing it at the
-- long-context rate would charge the same tokens twice.
local freshShare = totalPrompt > 0 and (fresh / totalPrompt) or 0
local excessFresh = excess * freshShare
local excessCached = excess - excessFresh
cost = cost
+ excessFresh * (lcRate - price.prompt) * mult
+ excessCached * (lcRate - price.prompt * discount) * mult
end
end
-- Reasoning tokens, when the rule prices them separately.
--
-- reasoning_tokens is a SUBSET of completion_tokens and is already inside
-- the term above. This block only matters when a provider charges thinking
-- at a DIFFERENT rate — and every provider measured does not: OpenAI,
-- DeepSeek and Anthropic all bill reasoning at the ordinary output rate. So
-- reasoning_price is nil by default and this is skipped entirely, which is
-- what makes the standard formula match the invoice.
--
-- When it IS set, only the DIFFERENCE is applied: the reasoning portion is
-- re-priced from the normal rate to the reasoning rate. Charging it a
-- second time in full would double-bill every thinking token.
local reasoningPrice = tonumber(price.reasoning_price)
if reasoningPrice and reasoningPrice ~= price.completion then
local reasoning = tonumber(payload.reasoning_tokens) or 0
if reasoning > 0 then
-- Defensive clamp: a provider reporting more reasoning than completion
-- would otherwise produce a negative visible-output count and a cost
-- delta larger than the whole request.
if reasoning > completion then reasoning = completion end
cost = cost + reasoning * (reasoningPrice - price.completion) * mult
end
end
local flat = price.per_request
if not payload.ok and not plugin.count_failures then
flat = 0
@ -442,6 +505,21 @@ function plugin.on_request_end(payload)
s.unpriced_reqs = s.unpriced_reqs + 1
local m = payload.model or "?"
s.unpriced_models[m] = (s.unpriced_models[m] or 0) + 1
-- Day-keyed unpriced tally. The lifetime counters above cannot answer the
-- question an operator actually has — "is it STILL happening?" — because they
-- keep the debt from before a pricing rule was added. After pricing a source
-- that was mispriced, the lifetime number never moves, so "unpriced: 6376" on
-- a dashboard is indistinguishable from a system that is still billing
-- nothing. Counting by day makes the fix observable: the offending day keeps
-- its number, new days start at zero.
if s.unpriced_by_day == nil then s.unpriced_by_day = {} end
-- dayKey wants epoch SECONDS; payload.time is milliseconds.
local uday = dayKey(math.floor((tonumber(payload.time) or (os.time() * 1000)) / 1000))
local ubucket = s.unpriced_by_day[uday]
if ubucket == nil then ubucket = { reqs = 0, models = {} }; s.unpriced_by_day[uday] = ubucket end
ubucket.reqs = ubucket.reqs + 1
ubucket.models[m] = (ubucket.models[m] or 0) + 1
end
if s.degraded_reqs == nil then s.degraded_reqs = 0 end
-- Degradation is counted here rather than in the chain_step hook because
@ -503,6 +581,24 @@ end
-- A whole page. The kernel injects this HTML and evaluates the <script> after
-- the DOM exists, and exposes `pluginAPI` for talking to the gateway.
plugin.ui = {
-- Readable by any role, because the state is SPLIT below: a user key sees
-- its own spend and nothing else. Marking the page admin-only would make the
-- split pointless; marking it "all" without the split is exactly the leak this
-- closes — a user key pulled the whole 18KB ledger (16871 requests, $8.86,
-- every source name) straight out of GET /state. The gateway's audience
-- override can tighten this per plugin without editing this file.
visibility = "all",
-- Split the state: global figures are deployment data and are withheld from
-- non-admin callers, while the per-key buckets describe ONE caller.
-- depth: by_key is {maskedKey: bucket}; by_day_key is {day: {maskedKey: …}}.
state_scope = {
global = {
"total", "by_source", "by_model", "by_day", "by_day_src",
"by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs",
"unpriced_models", "unpriced_reqs", "unpriced_by_day", "started",
},
depth = { by_key = 1, by_day_key = 2 },
},
page = {
page_id = "billing",
title = "Billing",
@ -575,6 +671,7 @@ plugin.ui = {
en: {
total: "Total", requests: "Requests", degraded: "Degraded",
unpriced: "Unpriced", prompt: "Prompt tokens",
unpriced7d: "Unpriced (7d)", unpricedClean: "none in last 7 days",
completion: "Completion tokens", failures: "Failures",
cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens",
perSource: "Per source", perModel: "Per model",
@ -596,6 +693,7 @@ plugin.ui = {
zh: {
total: "总开销", requests: "请求数", degraded: "降级",
unpriced: "未定价", prompt: "输入 tokens",
unpriced7d: "未定价(近 7 天)", unpricedClean: "近 7 天无",
completion: "输出 tokens", failures: "失败",
cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens",
perSource: "按源", perModel: "按模型",
@ -716,13 +814,35 @@ plugin.ui = {
});
return out;
}
// recentUnpriced counts unpriced requests over the last n days. When the
// result is zero THAT is the useful signal: every pricing rule added since
// then is holding. Null when there is no day data at all (a state file
// written before the buckets existed) so the UI can say "unknown" rather than
// claim a clean record it cannot support.
function recentUnpriced(st, n) {
var byDay = st && st.unpriced_by_day;
if (!byDay) return null;
var cutoff = Date.now() - n * 86400000;
var sum = 0, seen = false;
Object.keys(byDay).forEach(function (d) {
var t = Date.parse(d + "T00:00:00Z");
if (isNaN(t)) return;
seen = true;
if (t >= cutoff) sum += (byDay[d].reqs || 0);
});
return seen ? sum : null;
}
// rescale returns the state as the selected period presents it. Degraded and
// unpriced counters are lifetime-only (there is no day-keyed tally for them)
// so they are returned as null and the UI hides them instead of printing a
// lifetime number beside a per-window cost — the exact mismatch the period
// view exists to remove.
function rescale(st) {
if (PERIOD === "all" || !st.by_day) return st;
if (PERIOD === "all" || !st.by_day) {
if (st) st.unpriced_recent = recentUnpriced(st, 7);
return st;
}
var days = daysInWindow(PERIOD, utcToday());
var total = zeroBucket();
var byDay = {};
@ -740,6 +860,10 @@ plugin.ui = {
by_day: byDay,
degraded_reqs: null,
unpriced_reqs: null,
// The lifetime unpriced total is null here for the same reason, but the
// day buckets are not — they are what lets the card report "0 in the last
// 7 days" instead of leaving a settled debt indistinguishable from a leak.
unpriced_recent: recentUnpriced(st, 7),
};
return out;
}
@ -820,6 +944,15 @@ plugin.ui = {
// print a lifetime number next to a per-window cost.
view.degraded_reqs != null ? [T.degraded, fmtInt(view.degraded_reqs)] : null,
view.unpriced_reqs != null ? [T.unpriced, fmtInt(view.unpriced_reqs)] : null,
// "Unpriced (7d)" answers what the lifetime number cannot: is it STILL
// happening? A settled debt and a live leak print the same lifetime total.
view.unpriced_recent != null
? [
T.unpriced7d,
fmtInt(view.unpriced_recent),
view.unpriced_recent === 0 ? T.unpricedClean : null,
]
: null,
[T.prompt, fmtInt(t.prompt_tokens || 0)],
[T.completion, fmtInt(t.completion_tokens || 0)],
[T.failures, t.failures || 0],
@ -931,7 +1064,11 @@ plugin.ui = {
monthly: "Monthly fee", peakMult: "Peak multiplier",
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
models: "Per-model prices", addModel: "+ model",
mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
reasoning: "think $/M",
lcAbove: "over n tok", lcPrompt: "then $/M", lcMult: "or x",
lcHint: "Long-context step: tokens PAST the threshold cost more. Leave all three blank for one flat rate.", reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
noProfile: "No profile yet — create one to start pricing.",
noRules: "No rules in this profile",
warnNoSource: "matches no configured source",
@ -953,7 +1090,11 @@ plugin.ui = {
monthly: "月费", peakMult: "峰段倍数",
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
models: "按模型价格", addModel: "+ 模型",
mFree: "free 免费", mToken: "token 按量",
reasoning: "思考 $/M",
lcAbove: "超过 n tok", lcPrompt: "此后 $/M", lcMult: "或 ×",
lcHint: "长上下文阶梯:超过阈值的部分按更高价计。三项都留空 = 单一价格。", reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量",
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
noProfile: "还没有方案 —— 新建一个开始定价。",
noRules: "该方案暂无规则",
@ -1051,6 +1192,25 @@ plugin.ui = {
"<input class='m-name' value='" + esc(m) + "' style='flex:1 1 90px;min-width:0'>" +
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
"' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" +
// Left BLANK by default on purpose: empty means "bill thinking at the
// completion rate", which is what OpenAI, DeepSeek and Anthropic do.
// A 0 here would mean "thinking is free" and would under-bill quietly.
"<input class='m-r' value='" + esc(t.reasoning == null ? "" : t.reasoning) +
"' placeholder='" + esc(T.reasoning) + "' title='" + esc(T.reasoningHint) +
"' style='flex:0 1 62px;min-width:0'>" +
// Long-context step: threshold + (absolute rate OR multiplier). All
// three blank means NO step, deliberately distinct from a step whose
// rate is 0 — three inputs rather than inferring the step from a zero.
// On an agent workload 88% of requests exceed 128k prompt tokens, so
// omitting this under-bills the MAJORITY while every number stays
// plausible. The step is MARGINAL: it reprices the excess only.
"<input class='m-lc-a' value='" + esc(t.long_context == null ? "" : t.long_context.above) +
"' placeholder='" + esc(T.lcAbove) + "' title='" + esc(T.lcHint) +
"' style='flex:0 1 52px;min-width:0'>" +
"<input class='m-lc-p' value='" + esc(t.long_context == null ? "" : (t.long_context.prompt || "")) +
"' placeholder='" + esc(T.lcPrompt) + "' style='flex:0 1 52px;min-width:0'>" +
"<input class='m-lc-m' value='" + esc(t.long_context == null ? "" : (t.long_context.multiplier || "")) +
"' placeholder='" + esc(T.lcMult) + "' style='flex:0 1 46px;min-width:0'>" +
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
"' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>&times;</button></div>";
@ -1127,6 +1287,39 @@ plugin.ui = {
return;
}
models[n] = { prompt: pv, completion: cv };
// Optional thinking-token rate, read from the same row. Blank means
// "bill reasoning at the completion rate" — what OpenAI, DeepSeek and
// Anthropic all do. A 0 would mean "thinking is free", so an empty
// field must stay ABSENT from the saved rule rather than becoming 0.
var rel = r.querySelector(".m-r");
var rtxt = rel ? String(rel.value || "").trim() : "";
if (rtxt !== "") {
if (isNaN(parseFloat(rtxt))) {
bad = n + " (" + T.reasoning + ")";
return;
}
models[n].reasoning = rtxt;
}
// Long-context step. All three blank means NO step — the key must be
// ABSENT from the saved rule rather than present-and-zero, which would
// be a step priced at nothing.
var lca = r.querySelector(".m-lc-a");
var lcp = r.querySelector(".m-lc-p");
var lcm = r.querySelector(".m-lc-m");
var lcaT = lca ? String(lca.value || "").trim() : "";
var lcpT = lcp ? String(lcp.value || "").trim() : "";
var lcmT = lcm ? String(lcm.value || "").trim() : "";
if (lcaT !== "" || lcpT !== "" || lcmT !== "") {
if (lcaT === "" || isNaN(parseFloat(lcaT))) { bad = n + " (" + T.lcAbove + ")"; return; }
if (lcpT !== "" && isNaN(parseFloat(lcpT))) { bad = n + " (" + T.lcPrompt + ")"; return; }
if (lcmT !== "" && isNaN(parseFloat(lcmT))) { bad = n + " (" + T.lcMult + ")"; return; }
if (lcpT === "" && lcmT === "") { bad = n + " (" + T.lcPrompt + "/" + T.lcMult + ")"; return; }
var lc = { above: String(parseFloat(lcaT)) };
if (lcpT !== "") lc.prompt = lcpT;
if (lcmT !== "") lc.multiplier = parseFloat(lcmT);
models[n].long_context = lc;
}
});
if (bad) throw new Error(T.needPrices + ": " + bad);
if (Object.keys(models).length) out.models = models;
@ -1317,6 +1510,11 @@ plugin.ui = {
"<input class='m-name' placeholder='model' style='flex:1;min-width:80px'>" +
"<input class='m-p' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"<input class='m-c' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<input class='m-r' placeholder='" + esc(T.reasoning) + "' title='" + esc(T.reasoningHint) +
"' style='width:96px'>" +
"<input class='m-lc-a' placeholder='" + esc(T.lcAbove) + "' title='" + esc(T.lcHint) + "' style='width:78px'>" +
"<input class='m-lc-p' placeholder='" + esc(T.lcPrompt) + "' style='width:78px'>" +
"<input class='m-lc-m' placeholder='" + esc(T.lcMult) + "' style='width:70px'>" +
"<button class='ghost small m-del'>&times;</button>";
box.appendChild(row);
};

View File

@ -0,0 +1,794 @@
-- Provider quota — plan balance across upstreams, one card per source.
--
-- This started as a Command Code plugin and grew, because only one vendor was
-- being asked. The shape that works across providers is a two-part contract:
--
-- 1. A small set of PROBE ADAPTERS, each naming an endpoint, how to call it
-- and which JSON paths hold the numbers. Adapters are tried in order and
-- the first one that returns a usable answer wins.
-- 2. A DECLARATIVE override, so a provider nobody anticipated can be added
-- from the plugin's own state (endpoints + path mapping) without touching
-- code.
--
-- What the adapters share, and why it is worth generalizing:
--
-- - The DeepSeek family (deepseek, and the many re-sells that copied its
-- dashboard) answers GET /user/balance with
-- {is_available, balance_infos:[{currency,total_balance,granted,topped_up}]}.
-- Granted vs topped-up matters: gifted credit expiring on a schedule is a
-- different operational fact from prepaid money.
-- - The OpenAI-compatible dashboard shape answers
-- /v1/dashboard/billing/subscription plus /v1/dashboard/billing/usage,
-- which is what most re-sells of an OpenAI console expose. Note these
-- report LIMITS and SPEND, not a balance: the provider here has a hard limit
-- of 1e8 and 660 spent, so "remaining" must not be computed as
-- limit - spend and presented as money left — it is a ceiling nobody is
-- near. Reporting that honestly matters more than a green bar.
-- - Command Code exposes rolling 5-hour / weekly / monthly windows plus a
-- billing period, which no other provider here does.
--
-- A provider with no working adapter is SKIPPED, silently. Half these upstreams
-- are small re-sells whose dashboard endpoints return an HTML page, not JSON; a
-- list full of "unknown" rows trains the operator to ignore the page entirely,
-- which is worse than showing only what is real. Failures that are NOT "no such
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
-- ones worth fixing.
--
-- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces
-- that), so adding a probe never widens what the gateway can talk to.
local PLUGIN = {}
PLUGIN.name = "quota"
PLUGIN.version = "1.0.0"
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
PLUGIN.exports = { "collect_source", "collect_all" }
-- 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
-- no saved state still has an empty table to write into, rather than nil.
--
-- It is initialised at the TOP on purpose. Every collect path below touches it
-- (to restore the probe memo on first use, to persist it afterwards), and a
-- declaration further down would be nil for the reads that run first.
PLUGIN.state = {}
-- ---------------------------------------------------------------- helpers
local function tonum(v)
return tonumber(v) or 0
end
-- hostOf extracts the host from a base_url, for showing WHICH upstream a row is
-- about. Two sources on the same vendor (two Command Code keys) are
-- indistinguishable without it.
local function hostOf(u)
return (string.gmatch(u, "^https?://([^/]+)")()) or u
end
-- get walks a dotted path through nested tables, returning nil if any hop is
-- missing. A missing field must stay nil rather than becoming 0: "this provider
-- did not report it" and "this provider reported zero" are different, and
-- collapsing them shows a healthy $0.00 for a provider that never answered.
local function get(root, path)
if root == nil then return nil end
local cur = root
for seg in string.gmatch(path, "[^%.]+") do
if type(cur) ~= "table" then return nil end
cur = cur[seg]
if cur == nil then return nil end
end
return cur
end
local function getnum(root, path)
local v = get(root, path)
if v == nil then return nil end
if type(v) == "boolean" then return v and 1 or 0 end
local n = tonumber(v)
return n
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.
local function request(path, key, headers, tries)
tries = tries or 2
local hdrs = {}
for k, v in pairs(headers or {}) do hdrs[k] = v end
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
local lastErr = "no attempt"
for _ = 1, tries do
local res, err = __llmsproxy_http({
-- 4s, not 10s: probing is network-bound and the whole page shares ONE
-- Lua state under a write lock, so a slow upstream is not just a slow card
-- — it stalls every request_end hook (i.e. billing) behind it.
url = path, method = "GET", headers = hdrs, timeout_ms = 4000,
})
if res ~= nil then
if res.status >= 200 and res.status < 300 then
local ok, decoded = pcall(json.decode, res.body or "")
if ok and type(decoded) == "table" then
return decoded, nil
end
-- A 200 that is not JSON means we hit a marketing page, not an API.
return nil, "not json (HTTP " .. tostring(res.status) .. ")"
end
if res.status == 401 or res.status == 403 then
-- Retrying a rejected credential only wastes the operator's time.
return nil, "HTTP " .. tostring(res.status) .. " (key rejected)"
end
lastErr = "HTTP " .. tostring(res.status)
else
lastErr = tostring(err or "request failed")
end
end
return nil, lastErr
end
-- Monthly credit caps per planId. The credits endpoint never reports a cap, so
-- this is a lookup against the vendor's published pricing ($10 Go, $70
-- GOAT/Pro/Max), cross-checked against monthly_used + balance. Declared before
-- the adapters that read it — a Lua local is nil until its assignment runs, and
-- the Command Code probe hit that as a runtime nil-index.
local PLAN_CAPS = {
["individual-go"] = 10,
["individual-goat"] = 70,
["individual-pro"] = 70,
["individual-max"] = 70,
["team-goat"] = 70,
}
-- ---------------------------------------------------------------- adapters
-- Each adapter returns (report, nil) when it produced something usable, or
-- (nil, reason) to let the next one try. `skip` is the quiet reason — a 404 or
-- an HTML page means "this provider does not expose balance here", which is not
-- worth showing. Everything else becomes a visible failure.
local function deepseekBalance(base, key)
local r, err = request(base .. "/user/balance", key)
if r == nil then
if err and (err:match("not json") or err:match("HTTP 404")) then
return nil, "skip", err
end
return nil, "error", err
end
local infos = get(r, "balance_infos")
if type(infos) ~= "table" or #infos == 0 then return nil, "skip", "no balance_infos" end
local first = infos[1]
local rep = {
vendor = "deepseek-style",
currency = tostring(first.currency or "USD"),
balance = tonum(first.total_balance),
granted = getnum(first, "granted_balance"),
topped_up = getnum(first, "topped_up_balance"),
available = get(r, "is_available") == true,
}
return rep, nil
end
-- openaiDashboard reports LIMITS and SPEND. There is deliberately no
-- `remaining` here: hard_limit_usd is a ceiling (1e8 on the live provider), and
-- ceiling - spend would read as "$99,999,339 left", which is a number nobody
-- should ever believe.
local function openaiDashboard(base, key)
local sub, subErr = request(base .. "/v1/dashboard/billing/subscription", key, nil, 1)
if sub == nil then
if subErr and (subErr:match("not json") or subErr:match("HTTP 404")) then
return nil, "skip", subErr
end
end
local use, useErr = request(base .. "/v1/dashboard/billing/usage", key, nil, 1)
if sub == nil and use == nil then
if subErr and subErr:match("key rejected") then
return nil, "error", subErr
end
return nil, "skip", (subErr or useErr or "no dashboard")
end
local rep = {
vendor = "openai-dashboard",
hard_limit = getnum(sub or {}, "hard_limit_usd"),
soft_limit = getnum(sub or {}, "soft_limit_usd"),
total_usage = getnum(use or {}, "total_usage"),
has_payment_method = get(sub or {}, "has_payment_method") == true,
}
-- Only call it a balance when the vendor says so. A hard limit with no usage
-- endpoint is a ceiling with nothing spent against it; that still renders, but
-- as a limit rather than as money left.
rep.balance = nil
if rep.hard_limit and rep.hard_limit < 1000000 and rep.total_usage then
rep.balance = rep.hard_limit - rep.total_usage
rep.cap = rep.hard_limit
rep.used = rep.total_usage
end
return rep, nil
end
-- commandCode keeps its own shape: rolling windows plus a billing period.
local function commandCode(base, key)
local H = {
["x-command-code-version"] = "1.54.2",
["x-cli-environment"] = "production",
["User-Agent"] = "quota-plugin/1.0.0",
}
local origin = base:gsub("/provider/v1/?$", "")
local credits, err = request(origin .. "/alpha/billing/credits", key, H)
if credits == nil then
if err and err:match("key rejected") then return nil, "error", err end
return nil, "skip", err
end
local rep = {
vendor = "commandcode",
currency = "USD",
balance = tonum(get(credits, "credits.monthlyCredits"))
+ tonum(get(credits, "credits.purchasedCredits"))
+ tonum(get(credits, "credits.freeCredits")),
below_threshold = get(credits, "credits.belowThreshold") == true,
limited = get(credits, "windowLimits.limited") == true,
exceeded = get(credits, "windowLimits.exceeded"),
}
local fh = get(credits, "windowLimits.fiveHour")
if type(fh) == "table" then
rep.windows = rep.windows or {}
rep.windows[#rep.windows + 1] = {
label = "5 小时", used = tonum(fh.used), cap = tonum(fh.cap),
exceeded = fh.exceeded == true, reset_at = tonum(fh.resetAt),
}
end
local wk = get(credits, "windowLimits.weekly")
if type(wk) == "table" then
rep.windows = rep.windows or {}
rep.windows[#rep.windows + 1] = {
label = "每周", used = tonum(wk.used), cap = tonum(wk.cap),
exceeded = wk.exceeded == true, reset_at = tonum(wk.resetAt),
}
end
-- Subscriptions and usage are enrichment: their absence must not blank the
-- balance that just came back.
local subs = request(origin .. "/alpha/billing/subscriptions", key, H, 1)
if subs ~= nil then
rep.plan_id = get(subs, "data.planId")
rep.plan_status = get(subs, "data.status")
rep.period_end = get(subs, "data.currentPeriodEnd")
rep.plan_cap = PLAN_CAPS[tostring(rep.plan_id or "")]
rep.cancel_at_period_end = get(subs, "data.cancelAtPeriodEnd") == true
end
local usage = request(origin .. "/alpha/usage/summary", key, H, 1)
if usage ~= nil then
rep.monthly_used = getnum(usage, "totalMonthlyCredits")
rep.requests = getnum(usage, "totalCount")
rep.tokens = getnum(usage, "totalTokens")
rep.success_rate = getnum(usage, "successRate")
end
if rep.plan_cap and rep.monthly_used and rep.monthly_used > 0 then
local implied = rep.monthly_used + rep.balance
if math.abs(implied - rep.plan_cap) > (rep.plan_cap * 0.02 + 0.5) then
rep.cap_mismatch = true
rep.implied_cap = implied
end
end
return rep, nil
end
-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint,
-- then the OpenAI console shape, then Command Code's four.
local ADAPTERS = {
{ vendor = "commandcode", probe = commandCode },
{ vendor = "deepseek", probe = deepseekBalance },
{ vendor = "openai-dash", probe = openaiDashboard },
}
-- The probe memo remembers, per host, which adapter answered. Persisted in the
-- plugin's state so the expensive part — discovering that 10 of 21 upstreams
-- expose no balance endpoint at all — is paid once, not on every page open.
--
-- This matters more than it looks. The answers depend only on the host, never
-- on the key, and re-discovering them cost 21 sources x 3 adapters x 2 attempts
-- of timeouts on the first load.
-- HOST_MEMO maps host -> adapter vendor (or false when nothing answered). It
-- is restored from the plugin's own state on the first collect of a process, so
-- a restart does not re-pay the cost of discovering which upstreams are silent.
local HOST_MEMO = {}
-- HOST_MEMO_AT[host] = when that host last produced data. A negative memo older
-- than PROBE_MEMO_TTL is ignored, so a vendor that adds an endpoint is picked up
-- within the TTL without a restart.
local HOST_MEMO_AT = {}
local PROBE_MEMO_TTL = 1800 -- 30 minutes
-- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of
-- dead upstreams runs to the sum of every timeout — measured at 149s — and the
-- plugin's write lock is held the whole time.
local PROBE_BUDGET_MS = 12000
-- ---------------------------------------------------------------- source scan
-- collectSource probes one source and returns a report, or nil when the source
-- simply has no balance endpoint (silent) — the caller distinguishes the two
-- through `skipped`.
local function collectSource(src, budget)
local base = tostring(src.base_url or "")
if base == "" then return nil end
base = base:gsub("/+$", "")
local key = tostring(src.api_key or "")
local rep, kind, why
-- 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.
local host = hostOf(base)
local known = HOST_MEMO[host]
-- A negative memo is a TABLE ({at=timestamp}), distinguishable from a vendor
-- string. JSON round-trips it fine; a bare false would not survive the trip
-- through the Go-side state as cleanly, and the two being different types is
-- exactly the confusion that made an earlier revision drop five sources.
if type(known) == "table" then
local seen = known.at or 0
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
-- known == false means every adapter already came back "no endpoint here" for
-- this host during this process. Skip the probing entirely; one cheap request
-- confirms it is still the case, so a vendor that adds a dashboard later is
-- picked up without a restart.
-- A cheap liveness probe, NOT a skip: the hosts behind these adapters are
-- slow (12s+ to answer a root request), so treating a slow answer as "down"
-- lost five sources that do have real data. The probe only short-circuits the
-- genuinely unreachable case; a reachable host still goes through the
-- adapters.
if known == false then
local probe = __llmsproxy_http({
url = base .. "/", method = "GET", timeout_ms = 2500,
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
})
if probe == nil then
return { name = src.name, host = host, skipped = true,
reason = "host unreachable" }
end
-- Reachable: fall through and try the adapters again, since a slow host
-- that was merely timing out may answer fine.
end
local order = ADAPTERS
if known then
local reordered, seen = {}, {}
for _, a in ipairs(ADAPTERS) do
if a.vendor == known then
reordered[#reordered + 1] = a
seen[a.vendor] = true
end
end
for _, a in ipairs(ADAPTERS) do
if not seen[a.vendor] then reordered[#reordered + 1] = a end
end
order = reordered
end
for _, a in ipairs(order) do
-- Budget check BETWEEN adapters, so an exhausted budget still returns the
-- sources already answered instead of nothing at all.
if budget and budget.exhausted() then
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
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
-- attempts, 4s each) on every single page open — measured at 60s per source,
-- unchanged between runs, because "nothing answered" was never recorded.
--
-- The value is the adapter that came closest (an error rather than a skip), so
-- a host that later starts working is still retried first.
-- Remember that this host answered nothing, but only as an ORDERING hint, and
-- with a stamp so it expires. "This upstream has no balance endpoint" is a
-- fact that can change — vendors ship dashboards — so a permanent negative
-- would make the page quietly stop reporting a source that started working.
-- The stamp is what turns a 60s first load into a fast steady state without
-- freezing what the page can see.
if rep == nil and not HOST_MEMO[host] then
HOST_MEMO[host] = { at = os.time() }
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),
skipped = false, host = hostOf(base) }
end
return { name = src.name, skipped = true, host = hostOf(base) }
end
rep.name = src.name
rep.host = hostOf(base)
rep.skipped = false
rep.checked_at = os.time()
return rep
end
-- collect_all walks every source. Prefer it for a SMALL number of sources.
--
-- The page calls collect_source concurrently instead: probing is almost entirely
-- network wait (three adapters, up to two attempts, ten second timeouts each),
-- and doing 21 sources in sequence overran the HTTP call's own budget — the
-- whole page returned 502 while individual probes were still in flight. The
-- sequential entry point stays for tests and for anyone scripting the plugin.
PLUGIN.collect_all = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
local t1 = os.clock()
local lim1 = PROBE_BUDGET_MS / 1000
local function spent()
return (os.clock() - t1) > lim1
end
local budget = { exhausted = spent }
local out = {}
for _, s in ipairs(srcs) do
if type(s) == "table" then
local rep = collectSource(s, budget)
if rep ~= nil then out[#out + 1] = rep end
end
end
PLUGIN.state.probe_memo = HOST_MEMO
return out, nil
end
-- collect_source probes one named source.
PLUGIN.collect_source = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
-- A closure over the deadline, not a table method. `exhausted = function(self)`
-- reads correctly but breaks the moment the call site is `budget.exhausted()`
-- from inside a pcall-guarded hook where the upvalue chain differs; the
-- explicit capture below cannot go wrong that way.
local t0 = os.clock()
local limit = PROBE_BUDGET_MS / 1000
local function exhausted()
return (os.clock() - t0) > limit
end
local budget = { exhausted = exhausted }
for _, s in ipairs(srcs) do
if type(s) == "table" and s.name == payload.name then
local rep = collectSource(s, budget)
-- Persist the host→adapter map so the next PROCESS skips the dead ends,
-- not just the next page open.
PLUGIN.state.probe_memo = HOST_MEMO
return rep, nil
end
end
return nil, "source not found: " .. tostring(payload.name)
end
-- ---------------------------------------------------------------- page
PLUGIN.ui = {
-- Every row names an upstream host and what it has spent. That is deployment
-- information, not per-caller information, so it is admin-only by default; an
-- operator who wants a shared read-only view can widen it from config without
-- editing this file.
visibility = "admin",
page = {
page_id = "quota",
title = "Quota",
order = 45,
mount = [==[
<div id="qa-root" class="qa-root">
<div class="qa-head">
<div>
<div class="qa-title">套餐余量</div>
<div class="qa-sub" id="qa-sub">点击刷新</div>
</div>
<div class="grow"></div>
<button class="small" id="qa-refresh">刷新</button>
</div>
<div id="qa-list"><div class="qa-empty">尚未拉取</div></div>
</div>
<style>
#qa-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
.qa-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
.qa-title{font-size:15px;font-weight:600}
.qa-sub{font-size:12px;opacity:.66;margin-top:2px}
.qa-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 10px;min-width:0}
.qa-acc.err{border-color:rgba(220,80,80,.5)}
.qa-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
.qa-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
.qa-host{font-size:11.5px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.qa-vendor{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.8;white-space:nowrap}
.qa-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
.qa-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
.qa-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
.qa-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
.qa-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 8px;flex-wrap:wrap}
.qa-bal-n{font-size:19px;font-weight:600}
.qa-bal-l{font-size:12px;opacity:.66}
.qa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:9px 18px}
.qa-w{min-width:0}
.qa-w-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
.qa-w-n{font-variant-numeric:tabular-nums;font-weight:600}
.qa-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
.qa-fill{height:100%;background:#3b82f6;border-radius:3px}
.qa-fill.warn{background:#d97706}
.qa-fill.over{background:#dc2626}
.qa-w-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.qa-meta{display:flex;gap:13px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:9px}
.qa-fail{font-size:11.5px;color:#b91c1c;margin-top:7px;word-break:break-word}
.qa-warnl{font-size:11px;color:#b45309;margin-top:3px}
.qa-empty{opacity:.6;font-size:13px;padding:8px 0}
.qa-skip{font-size:11.5px;opacity:.5;margin-top:8px;line-height:1.6}
</style>
<script>
(function(){
var MONTHLY_CAP_NOTE = "月度上限按套餐表推导";
function escH(s){return String(s==null?"":s).replace(/[&<>"]/g,function(c){
return c==="&"?"&amp;":c==="<"?"&lt;":c===">"?"&gt;":"&quot;";});}
// Money is formatted with the vendor's own currency; CNY and USD differ only
// in symbol here, which is deliberate — guessing an exchange rate would be a
// far worse lie than showing the raw number with its unit.
function money(v, cur){
cur = cur || "USD";
var sym = cur === "CNY" ? "¥" : (cur === "USD" ? "$" : cur + " ");
v = Number(v)||0;
// A negative credit is a real debit, not "zero" — deepseek reports
// topped_up_balance -0.02 against a grant, and flattening that to 0.00
// hides the overdraft.
if (v===0) return sym+"0.00";
if (v<0) return "-"+sym+Math.abs(v).toFixed(2);
return sym+(v<0.01?v.toFixed(4):v.toFixed(2));
}
function pct(u,c){ c=Number(c)||0; u=Number(u)||0; if(!(c>0)) return null;
return Math.floor(u/c*100+0.5); }
function resetTxt(ms){
if(!(Number(ms)>0)) return "";
var s=Math.floor(Number(ms)/1000-Date.now()/1000); if(s<=0) return "";
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
if(d>0) return d+"d "+h+"h"; if(h>0) return h+"h "+m+"m"; return m+"m";
}
// windowRow renders one used/cap bar. A window with no cap renders as a plain
// "used" line rather than a bar of unknown width.
function win(w){
if(!w) return "";
var cap=Number(w.cap)||0, used=Number(w.used)||0;
var h='<div class="qa-w"><div class="qa-w-h"><span>'+escH(w.label);
var r=resetTxt(w.reset_at); if(r) h+=' <span style="opacity:.6">↻'+escH(r)+'</span>';
var p = pct(used,cap);
if(p===null){
h+='</span><span class="qa-w-n">'+used.toLocaleString()+'</span></div>'+
'<div class="qa-w-d">已用(无上限数据)</div></div>';
return h;
}
if(w.exceeded===true) p=100;
var cls="qa-fill"+((w.exceeded===true||p>=100)?" over":(p>=80?" warn":""));
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
'<div class="qa-bar"><div class="'+cls+'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
return h;
}
// monthRow is Command Code's monthly allowance, which has no vendor cap.
function month(r){
var cap=Number(r.plan_cap)||0, used=Number(r.monthly_used)||0;
if(!(used>0) && !(cap>0)) return "";
var h='<div class="qa-w"><div class="qa-w-h"><span>每月';
var pe=r.period_end;
if(pe){ var d=Math.floor((new Date(pe)-Date.now())/86400000);
if(d>0) h+=' <span style="opacity:.6">↻'+d+'d</span>'; }
var p=pct(used,cap);
if(p===null){
h+='</span><span class="qa-w-n">$'+used.toFixed(2)+'</span></div>'+
'<div class="qa-w-d" title="'+escH(MONTHLY_CAP_NOTE)+'">已用 · 套餐上限未知</div></div>';
return h;
}
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
'<div class="qa-bar"><div class="qa-fill'+(p>=100?" over":(p>=80?" warn":""))+
'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+'</div>';
if(r.cap_mismatch){
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">⚠ 额度口径可能有变</div>';
}
return h+"</div>";
}
// balanceLine renders the headline number. Three cases, kept visually distinct
// because they mean different things: money left, a spend against a ceiling,
// or no number at all.
function balLine(r){
if(r.error) return "";
if(r.balance!==undefined && r.balance!==null){
var extra="";
// granted / topped_up may be NEGATIVE (deepseek reports topped_up -0.02
// against a positive grant), so the zero test is on the ABSOLUTE value.
// `!== 0` on a string-typed value would have been true anyway, but on a
// coerced number it hid a real debit behind a flat 0.00.
var g=Number(r.granted), t2=Number(r.topped_up);
if((!isNaN(g)&&g!==0)||(!isNaN(t2)&&t2!==0)){
extra=" <span class='qa-bal-l'>(赠送 "+
money(g,r.currency)+" · 充值 "+money(t2,r.currency)+")</span>";
}
var pill="";
if(r.available===false) pill=' <span class="qa-pill out">不可用</span>';
else if(r.exceeded) pill=' <span class="qa-pill out">已超额</span>';
else if(r.below_threshold) pill=' <span class="qa-pill low">低于阈值</span>';
return '<div class="qa-bal"><span class="qa-bal-n">'+money(r.balance,r.currency)+
'</span><span class="qa-bal-l">余额</span>'+pill+extra+'</div>';
}
if(r.hard_limit!==undefined && r.hard_limit!==null){
// No balance is derivable: a 1e8 ceiling is a policy number, not money.
var s='<div class="qa-bal"><span class="qa-bal-n">'+money(r.total_usage||0,r.currency)+
'</span><span class="qa-bal-l">累计花费(限额 '+
(r.hard_limit>=1000000?'∞':money(r.hard_limit,r.currency))+')</span></div>';
return s;
}
return "";
}
function state(r){
if(r.error) return {cls:"err", pill:'<span class="qa-pill out">读取失败</span>'};
if(r.available===false) return {cls:"out", pill:'<span class="qa-pill out">不可用</span>'};
if(r.exceeded) return {cls:"out", pill:'<span class="qa-pill out">已超额</span>'};
if(r.below_threshold) return {cls:"low", pill:'<span class="qa-pill low">低于阈值</span>'};
if(r.balance!==undefined && r.balance!==null){
var b=Number(r.balance);
if(b<=0) return {cls:"out", pill:'<span class="qa-pill out">无余额</span>'};
if(b<5) return {cls:"low", pill:'<span class="qa-pill low">余额偏低</span>'};
return {cls:"ok", pill:'<span class="qa-pill ok">'+(r.plan_status||"正常")+'</span>'};
}
if(r.hard_limit!==undefined && r.hard_limit!==null)
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
}
function card(r){
var st=state(r);
var h='<div class="qa-acc'+(r.error?" err":"")+'"><div class="qa-h">'+
'<span class="qa-n">'+escH(r.name)+'</span>'+
'<span class="qa-host">'+escH(r.host||"")+'</span>'+
'<span class="qa-vendor">'+escH(r.vendor||"?")+'</span>'+
'<span class="grow"></span>'+st.pill+'</div>';
if(r.error){
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
return h;
}
h+=balLine(r);
var rows="";
rows+=month(r);
if(r.windows) r.windows.forEach(function(w){ rows+=win(w); });
if(rows) h+='<div class="qa-grid">'+rows+'</div>';
var meta=[];
if(r.plan_id) meta.push("套餐 "+String(r.plan_id).replace(/^individual-/,""));
if(r.period_end) meta.push("周期至 "+String(r.period_end).slice(0,10));
if(r.cancel_at_period_end) meta.push("到期不续费");
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
if(r.has_payment_method===false) meta.push("未绑定支付方式");
if(meta.length) h+='<div class="qa-meta">'+meta.map(escH).join(" · ")+'</div>';
return h+"</div>";
}
// paintSnapshot renders the persisted snapshot WITHOUT touching the network.
// Called on load so the page has content immediately.
async function showSnapshot(){
try{
var st = await pluginAPI.fetchState("quota");
var rs = (st && st.reports) || [];
if(!rs.length) return false;
var real = rs.filter(function(r){ return r && !r.skipped; });
var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
render(real, skipped);
var sub=document.getElementById("qa-sub");
if(sub && st.updated_at){
sub.textContent = real.length+" 个源 · 快照 "+new Date(st.updated_at).toLocaleTimeString()+"(点刷新取实时)";
}
return true;
}catch(e){ return false; }
}
function render(reports, skipped){
var host=document.getElementById("qa-list"); if(!host) return;
var real=(reports||[]).filter(function(r){return !r.skipped;});
var bad=real.filter(function(r){return r.error;});
if(!real.length){
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
} else {
host.innerHTML=real.map(card).join("");
if(skipped && skipped.length){
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
}
}
var sub=document.getElementById("qa-sub");
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
}
async function refresh(){
var srcs;
try{
var r=await fetch("/api/sources",{credentials:"same-origin"});
srcs=(await r.json()).sources||[];
}catch(e){
var s0=document.getElementById("qa-sub"); if(s0) s0.textContent="拉取源列表失败: "+e.message;
return;
}
if(!window.pluginAPI || typeof pluginAPI.call!=="function"){
var sb=document.getElementById("qa-sub"); if(sb) sb.textContent="pluginAPI.call 不可用(内核版本过旧)";
return;
}
var btn=document.getElementById("qa-refresh"); if(btn) btn.disabled=true;
var sub=document.getElementById("qa-sub"); if(sub) sub.textContent="探测中…";
var out;
try{
// One call per source, fired together. Probing is network-bound, so a
// sequential collect_all over 21 sources blows past the call's time budget
// and every card comes back as one 502 — the probes themselves were fine.
// Concurrency also makes the page honest about which sources are slow:
// each card appears as its own answer lands.
out = await Promise.all(srcs.map(function(s){
return pluginAPI.call("quota","collect_source",{sources:[s],name:s.name})
.catch(function(e){ return {name:s.name,skipped:true,error:String(e.message||e)}; });
}));
// Mirror the host→vendor map the plugin keeps: a source that answered is
// worth probing first next time, and the page has everything it needs to
// record that without another round trip.
var memo = {};
(out||[]).forEach(function(r){
if(r && !r.skipped && !r.error && r.host && r.vendor) memo[r.host] = r.vendor;
});
window.__qaMemo = memo;
}catch(e){
if(btn) btn.disabled=false;
if(sub) sub.textContent="探测失败: "+e.message;
return;
}
var all=out||[];
var real=all.filter(function(r){return !r.skipped;});
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
// real may already contain errors; collect_all keeps them for visibility.
render(real, skipped);
if(btn) btn.disabled=false;
// Persist both the snapshot and the probe map; the map is what lets the
// next page open skip the upstreams that expose nothing.
try{
await pluginAPI.postState("quota",
{reports:all, updated_at:Date.now(), probe_memo:(window.__qaMemo||{})});
}catch(e){}
}
function mount(){
var b=document.getElementById("qa-refresh");
if(b && !b.__qaWired){ b.__qaWired=true; b.addEventListener("click",refresh); }
}
mount();
showSnapshot();
// Do NOT auto-refresh on load.
//
// Probing is network-bound and every request takes the plugin's write lock, so
// a page open that fires one call per source serialises them: measured 19s to
// first paint and 22-100s to finish, with the whole WebUI held in its loading
// state meanwhile. A balance the operator did not ask for is not worth the
// first paint of the page they opened.
//
// What IS shown automatically is the last snapshot, so an operator who hits
// refresh once sees a card immediately and a fresh number behind it.
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount);
window.__qaRefresh=refresh; window.__qaRender=render;
})();
</script>
]==],
},
}
return PLUGIN

View File

@ -375,3 +375,73 @@ return p
t.Error("a plugin registered for chain_step is not reported as such")
}
}
// TestOnDiskReportsPages pins that the disk listing carries the plugin's UI
// page ids. The desktop plugin panel reads on_disk (not List) and shows the
// pages so an operator can see at a glance that a plugin renders something;
// without this field the GUI reads p.pages === undefined and the line simply
// never appears, which is the kind of omission nobody reports as a bug.
func TestOnDiskReportsPages(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "paged", version = "1.0.0" }
p.ui = { pages = {} }
table.insert(p.ui.pages, { page_id = "alpha", title = "Alpha", mount = "<b>a</b>" })
table.insert(p.ui.pages, { page_id = "beta", title = "Beta", mount = "<b>b</b>" })
return p
`
if err := loadPlugin(t, ps, "paged", code); err != nil {
t.Fatalf("load: %v", err)
}
var found *DiskEntry
for _, de := range ps.OnDisk() {
if de.Name == "paged" {
d := de
found = &d
}
}
if found == nil {
t.Fatal("paged plugin missing from OnDisk")
}
if len(found.Pages) != 2 {
t.Fatalf("Pages = %v, want [alpha beta]", found.Pages)
}
if found.Pages[0] != "alpha" || found.Pages[1] != "beta" {
t.Fatalf("Pages = %v, want [alpha beta] in declaration order", found.Pages)
}
// The field must survive JSON too — the desktop panel reads the HTTP body,
// not the Go struct.
b, err := json.Marshal(found)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pages":["alpha","beta"]`) {
t.Fatalf("json omits pages: %s", b)
}
}
// The single-page form (`ui.page`) must also surface, since a plugin using it
// is just as rendered as one using `ui.pages`.
func TestOnDiskReportsSinglePage(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "single", version = "1.0.0" }
p.ui = { page = { page_id = "solo", title = "Solo", mount = "<b>x</b>" } }
return p
`
if err := loadPlugin(t, ps, "single", code); err != nil {
t.Fatalf("load: %v", err)
}
for _, de := range ps.OnDisk() {
if de.Name == "single" {
if len(de.Pages) != 1 || de.Pages[0] != "solo" {
t.Fatalf("Pages = %v, want [solo]", de.Pages)
}
return
}
}
t.Fatal("single plugin missing from OnDisk")
}