Compare commits

..

17 Commits

Author SHA1 Message Date
b75b64a39a docs: 说明归档分支的用途与功能落点
冻结 v1.9 之前的一段插件重构历史。16 个提交未以等价形式进入主线,其中
3 个是远端完全没有的(ccquota 的月度额度 UI 与其生命周期内的修复,
随 ccquota 被通用 quota 插件取代而未进主线)。

明确标注不要从本分支继续开发,并给出每项能力现在的落点。
2026-10-05 08:33:11 +08:00
120ba9e957 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>
2026-10-04 11:49:47 +08:00
c17a851ec1 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>
2026-10-04 11:11:16 +08:00
a6c6e6e5c7 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 层过滤意味着其它消费者照旧全量泄漏。

## state 分区:让"看自己的账单"成为可能

角色可见性回答不了"哪一部分"。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 正是这次要堵的泄露。

## 注册式 HTTP 端点:Go 监听,Lua 处理

有些东西值得从浏览器**外面**查(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>
2026-10-04 10:51:07 +08:00
098a4a5eb4 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>
2026-10-04 09:36:26 +08:00
6bc3ff8bbd feat(plugins): ccquota 显示每月额度
The page showed a monthly BALANCE ($0.08 left) but no monthly ALLOWANCE, so
"how much do I get and how much of it is gone" had no answer — the most useful
number on a plan that is nearly exhausted.

The vendor's credits endpoint reports what is left and never the cap, so a
percentage is not derivable from it. Two things supply the missing half:

  - planId from subscriptions (here individual-goat), mapped to the caps the
    vendor publishes ($10 Go, $70 GOAT/Pro/Max).
  - totalMonthlyCredits from usage/summary, which counts over the BILLING PERIOD
    and so pairs with the monthly allowance rather than with the rolling 5h and
    weekly windows.

Because the cap is a table lookup and not an API fact, it is cross-checked
against monthly_used + balance: if the two disagree by more than 2% + $0.50,
the card says the figure may be off rather than confidently printing a wrong
percentage. A planId absent from the table renders the balance with no bar at
all — inventing a denominator would be worse than admitting there isn't one.

Also surfaces what the endpoints were already returning and the page was
dropping: period_end (with a countdown), cancel_at_period_end, plan name, and
the vendor's own belowThreshold flag, which is distinct from "balance > 0" —
an account can hold $3 and still be under their threshold.

Verified on production: both accounts show 每月 100% ($69.94/$70, red bar),
5 小时 0/14, 每周 0.803/35 on one; plan goat, 周期至 2026-10-17 / 2026-10-19.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 08:30:36 +08:00
b5f6cac0ef fix(plugins): ccquota 页面首次打开是空的
The plugin page rendered nothing until the operator clicked refresh, and even
the click handler was never wired — while every unit test was green and the
API returned correct numbers. Four separate mistakes stacked here:

1. The script waited on DOMContentLoaded, an event that had already been
   dispatched by the time the kernel injects a plugin mount. Replaced with
   pluginAPI.onTabShown (the kernel's own mechanism, fires with the DOM in
   place) PLUS an immediate call, because onTabShown alone leaves the page
   blank when the tab is already open at boot. The billing page does both; I had
   reduced it to one and broke first paint.

2. The re-entry guard flag lived in a closure variable. The tab's DOM is rebuilt
   on re-entry, so the handler was wired against a node that no longer existed.
   The flag now lives on the NODE (__ccqWired), which is what makes re-wiring
   correct.

3. refresh() swallowed its fetch failure and rendered "no sources", so a broken
   page and an empty account list looked identical. Failures now surface in the
   header line, including when pluginAPI.call is missing (an old kernel).

4. THE ACTUAL REASON the page stayed blank: there were TWO ccquota plugins. The
   embed (go:embed plugins/*.lua) version and the on-disk one both loaded, and
   the UI injected the EMBED copy — 5182 characters, predating every fix — while
   every edit I made and every hot reload went to the disk copy. The running
   binary was serving stale plugin code no amount of reloading could fix. Only a
   rebuild surfaced it, and only measuring the served <script> length (7053 vs
   5182) showed which copy the browser had.

Lesson worth keeping: "the file on disk is what runs" is false for anything
under go:embed. Compare what the SERVER sends before editing the same file
twice and concluding the edit did nothing.

Verified in the browser: 2 account cards, 4 window bars, real balances, no
overflow, no console errors, refresh button 48x29 and functional.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 08:23:55 +08:00
111dc6d53f 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>
2026-10-04 08:10:11 +08:00
0fbb8e5e88 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>
2026-10-04 07:43:56 +08:00
34fcc0b40f 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:11 +08:00
7b201cbea3 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 22:53:27 +08:00
d71d235a8f 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:16:56 +08:00
59802d7697 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:09:45 +08:00
ac58f9485b 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 12:48:17 +08:00
0938a45ced 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:08:03 +08:00
57abc25bb9 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 09:50:38 +08:00
95ac9db67a 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 09:21:02 +08:00
47 changed files with 318 additions and 9506 deletions

32
ARCHIVE.md Normal file
View File

@ -0,0 +1,32 @@
# archive/pre-v1.9-quota-refactor
**这是一个归档分支,不要从它继续开发。**
它冻结的是 v1.9 之前的一段插件重构历史,保存于 2026-10-05。
## 为什么保留
分支上有 16 个提交没有以等价形式进入主线。逐个 patch-id 核对后,其中 **3 个
是远端完全没有的**:
| commit | 内容 | 为什么没进主线 |
|---|---|---|
| `a6c6e6e` | 可见性、state 分区、注册式 HTTP 端点 | 后继版本 `35b9422` 是它的 rebase 等价版,但这个 hash 上还挂着另一些只存在于此分支的改动 |
| `6bc3ff8` | ccquota 显示每月额度 | ccquota 后来被通用 `quota` 插件取代,这段额度 UI 没进主线 |
| `b5f6cac` | ccquota 页面首次打开是空的 | 同上,是 ccquota 生命周期内的修复 |
其余 13 个已在主线以等价 patch 存在(`35b9422` / `cab3361` / `ba10f03` 等),
它们保留在这里是为了让这段历史可追溯,不是为了再次使用。
## 现在的功能落点
| 当时在这里 | 现在在哪 |
|---|---|
| ccquota 多账户额度插件 | `internal/lua/plugins/quota.lua`(通用套餐余量插件) |
| 可见性 / state 分区 / 按密钥授权 | `internal/lua/plugin_access.go`(`AccessRule`) |
| 注册式 HTTP 端点 | `internal/lua/plugin_http_handler.go` + `AccessRule` |
## 当前发布线
- `main` — 下一个未发布版本
- `release/v1.9.x` — v1.9.x 发布线(最新 tag `v1.9.5`)

View File

@ -150,47 +150,6 @@ sources:
timeout: 120s # 请求超时,默认 120s
```
### 自动探测模型列表
不想手抄模型 id 时,只填 URL 和 key,让网关去扫:
```bash
# 只探测,不写任何东西
curl -X POST http://127.0.0.1:8080/api/sources/discover \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…"}'
# → {"ok":true,"base_url":"…","endpoint":"","models":["gpt-x",…],
# "image_models":["gpt-image-2"],"models_path":"/models","latency_ms":1387,…}
# 探测后直接落库
curl -X POST http://127.0.0.1:8080/api/sources/discover/apply \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…","name":"vendor"}'
```
WebUI 源编辑框里也有「自动探测模型」按钮:填好 URL 和 key 点一下,列表自动
填好,仍需点保存才生效。
几点值得知道:
- **URL 形态不用猜。** 厂商对「`/v1` 放 base 还是放 path」没有共识,探测会
依次尝试若干候选并返回答对的那个,同时给出归一化后的 `base_url` +
`endpoint`(`endpoint` 平时留空,因为 openai adapter 自带 `/chat/completions`)。
- **`image_endpoint` 是另一套规则。** 网关的图像默认路径是**绝对**的
`/v1/images/generations`,而 chat 默认是相对的。所以 `base_url` 带 `/v1` 时
必须显式给 `image_endpoint: /images/generations`,否则图像请求 404。
探测会替你填好。
- **图像模型只看上游自己声明的 modality**,绝不从模型名猜。`gpt-image-2`
这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持
图像生成的模型,其后每一层都被它拖垮。
- **不会逐模型探活。** 34 个模型的目录跑一轮要花钱且耗时数分钟,`/models`
是足够便宜的信号;模型是否真能应答留给正常请求去发现。
- **对已存在的源,必须显式 `replace_models: true`** 才会覆盖模型列表。重新
探测顺手抹掉为 AUTO 链手工调过的 priority,是最难排查的一类数据丢失。
- **`api_key` 留空表示沿用已存值**,不会把凭据清空。
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。

View File

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

View File

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

View File

@ -195,36 +195,6 @@ 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)
}
@ -292,32 +262,3 @@ 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,129 +86,3 @@ 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

@ -453,19 +453,6 @@ 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.
@ -477,31 +464,6 @@ 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

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

View File

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

View File

@ -189,20 +189,6 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
{Method: "GET", Path: "/api/stats", Auth: "any", Summary: "usage aggregates"},
{Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"},
{Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"},
{Method: "POST", Path: "/api/sources/discover", Auth: "admin",
Summary: "probe a base_url+api_key for its model catalog",
// Probing is read-only with respect to configuration: it writes
// nothing, which is why it is safe to call repeatedly while the
// operator is still typing a key.
WriteEffect: "none",
},
{Method: "POST", Path: "/api/sources/discover/apply", Auth: "admin",
Summary: "probe and write the source in one step",
// Requires replace_models=true when the source already exists,
// so re-running discovery cannot silently discard a hand-tuned
// model list. An empty api_key keeps the stored credential.
WriteEffect: "creates or replaces a source and its model list",
},
{Method: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"},
{Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"},
},

View File

@ -256,17 +256,17 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
}
return nil
}
// 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)}
// 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
@ -789,18 +789,6 @@ 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)
}
@ -1056,12 +1044,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,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,
// chain_walk: the AUTO tier-by-tier trace, when the request went
// through the chain. Empty for a direct request and for a gateway with
// no plugins loaded. Absent rather than empty so a plugin can tell
@ -1215,7 +1200,20 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type
var tut *types.TokenUsage
if lastUsage != nil {
tut = lastUsage
applyStreamUsageDetails(rec, lastUsage)
// Write the upstream's cache accounting back onto the request record
// so the audit trail carries the hit/miss split for streaming too.
if d := tut.PromptTokensDetails; d != nil {
rec.CacheReported = true
if d.CachedTokens > 0 {
rec.CacheHit = int64(d.CachedTokens)
}
} else if tut.PromptCacheHit > 0 {
rec.CacheHit = int64(tut.PromptCacheHit)
rec.CacheReported = true
}
if tut.PromptCacheMiss > 0 {
rec.CacheMiss = int64(tut.PromptCacheMiss)
}
} else if rec.Prompt+rec.Compl > 0 {
u := types.TokenUsage{
Prompt: int(rec.Prompt),
@ -1446,39 +1444,3 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
Data: resp.ImageData,
})
}
// applyStreamUsageDetails mirrors the upstream's reasoning and cache numbers
// onto a STREAMED request's audit record.
//
// It exists as a named function because this was inline in the streaming handler,
// where it was untestable — and untested it stayed wrong. The non-streaming
// recorder read completion_tokens_details.reasoning_tokens; this one did not. So
// a source's streamed requests recorded completion and no reasoning at all while
// its non-streamed calls recorded both. Nothing failed and no test was red: the
// audit trail was simply quieter than it should have been.
//
// Found by reading the production audit file after wiring a reasoning-heavy
// source: completion=8166 with reasoning absent, on every streamed request.
//
// Reasoning is a SUBSET of completion, never an addition to it. See
// recordChatUsage for why that matters to billing.
func applyStreamUsageDetails(rec *Req, u *types.TokenUsage) {
if d := u.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
rec.Reasoning = int64(d.ReasoningTokens)
}
// Cache accounting is here for the same reason it is kept apart: "zero hits
// because nothing was cached" and "this provider never reports caching" are
// the same number and mean opposite things.
if d := u.PromptTokensDetails; d != nil {
rec.CacheReported = true
if d.CachedTokens > 0 {
rec.CacheHit = int64(d.CachedTokens)
}
} else if u.PromptCacheHit > 0 {
rec.CacheHit = int64(u.PromptCacheHit)
rec.CacheReported = true
}
if u.PromptCacheMiss > 0 {
rec.CacheMiss = int64(u.PromptCacheMiss)
}
}

View File

@ -1,160 +0,0 @@
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)
}
}
}
// TestStreamedReasoningIsRecordedOnTheRequest is the streaming counterpart.
//
// TestRecordChatUsageKeepsReasoningTokens covers the non-streaming path only,
// and for a long time the streamed path dropped the number: the non-streaming
// recorder read TokenUsage.CompletionTokensDetails, while the streaming one read
// only Prompt and Completion from the terminal usage chunk. (Cache accounting was
// mirrored; reasoning simply never was.)
//
// The symptom is invisible in the response — the client sees a correct answer
// either way. It only shows in the audit trail, where a reasoning-heavy source's
// streamed requests recorded completion=8166 and no reasoning at all. Found by
// reading the production audit file, not by a failing test, which is exactly why
// the property is pinned here.
func TestStreamedReasoningIsRecordedOnTheRequest(t *testing.T) {
rec := &Req{}
applyStreamUsageDetails(rec, &types.TokenUsage{
Prompt: 159579,
Completion: 751,
Total: 160330,
CompletionTokensDetails: &types.CompletionTokensDetails{
ReasoningTokens: 512,
},
})
if rec.Reasoning != 512 {
t.Errorf("streamed Reasoning = %d, want 512 — a streamed request records no "+
"reasoning, so a pricing rule that splits thinking from output silently "+
"bills it as ordinary output", rec.Reasoning)
}
}
// TestStreamedReasoningIsNeverAddedToCompletion pins the SUBSET relation in the
// streaming path specifically. A presence check would pass if someone wrote
// rec.Compl += reasoning, and billing would then charge every thinking token
// twice.
func TestStreamedReasoningIsNeverAddedToCompletion(t *testing.T) {
rec := &Req{}
rec.Compl = 751 // set upstream of the call, as the streaming handler does
applyStreamUsageDetails(rec, &types.TokenUsage{
Completion: 751,
CompletionTokensDetails: &types.CompletionTokensDetails{ReasoningTokens: 512},
})
if rec.Compl != 751 {
t.Errorf("Compl = %d, want 751 unchanged — reasoning is a subset, not an addition", rec.Compl)
}
if rec.Reasoning >= rec.Compl {
t.Errorf("Reasoning (%d) must be a SUBSET of Compl (%d)", rec.Reasoning, rec.Compl)
}
}
// TestStreamedUsageWithoutReasoningLeavesItUnset: an upstream that reports no
// reasoning must not be turned into a reported zero. In the audit trail 0 means
// "not reported", and a plugin cannot tell that from "no reasoning happened".
func TestStreamedUsageWithoutReasoningLeavesItUnset(t *testing.T) {
rec := &Req{}
applyStreamUsageDetails(rec, &types.TokenUsage{Prompt: 10, Completion: 4, Total: 14})
if rec.Reasoning != 0 {
t.Errorf("Reasoning = %d for an upstream that reported none", rec.Reasoning)
}
}
// TestStreamedCacheAccountingStillWorks is a regression guard for the block the
// reasoning lines were added to. They share a function, so a future edit that
// short-circuits one would break the other — silently, since a missing cache
// number reads as "nothing was cached".
func TestStreamedCacheAccountingStillWorks(t *testing.T) {
for _, c := range []struct {
name string
u types.TokenUsage
hit int64
miss int64
rep bool
}{
{"details object", types.TokenUsage{
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 4096},
}, 4096, 0, true},
{"legacy hit/miss", types.TokenUsage{
PromptCacheHit: 2048, PromptCacheMiss: 512,
}, 2048, 512, true},
{"nothing reported", types.TokenUsage{}, 0, 0, false},
} {
rec := &Req{}
applyStreamUsageDetails(rec, &c.u)
if rec.CacheHit != c.hit || rec.CacheMiss != c.miss || rec.CacheReported != c.rep {
t.Errorf("%s: hit=%d miss=%d reported=%v; want %d/%d/%v",
c.name, rec.CacheHit, rec.CacheMiss, rec.CacheReported, c.hit, c.miss, c.rep)
}
}
}

View File

@ -1,749 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"llmsproxy/internal/config"
)
// Model discovery: turn "here is a URL and a key" into a configured source.
//
// The hard part is not the GET /models call — it is guessing which URL the
// upstream actually serves. This gateway builds request paths as
// base_url + adapter endpoint (openai contributes "/chat/completions"), so a
// base_url that already ends in "/v1" yields "/v1/chat/completions" while a
// base_url of "/v1" plus our default image path yields the broken
// "/v1/v1/images/generations". Vendors disagree on whether the version segment
// belongs to the base or to the path, and there is no header that says which.
//
// So discovery does not trust the URL it is given. It tries a small, ordered
// set of candidates, records which one answered, and reports that back as the
// normalized base_url + adapter endpoint pair. A caller who typed the wrong
// form gets a working source anyway, plus the correction needed to make it
// permanent.
//
// What is deliberately NOT here:
//
// - No guessing of model capabilities from the name. "vision" in an id does
// not make a model accept images, and marking an image-only endpoint as
// kind:"chat" makes every AUTO request fail on it. Kind stays "chat" unless
// the upstream's own metadata says otherwise (see discoveredModelKind).
// - No live probe per model. A single-token completion per model would cost
// real money across a 34-model catalog and takes minutes. /models is the
// cheap, authoritative-enough signal for "this id is served here"; whether
// it actually answers stays for the normal request path to discover.
//
// The one thing that IS verified live is reachability, because a source that
// 404s is not worth writing to disk.
const (
// discoveryTimeout bounds the whole probe. Vendors are routinely slow on
// the first request to an uncached endpoint; 20s is enough for a healthy
// one and short enough that the UI is not left hanging.
discoveryTimeout = 20 * time.Second
// discoveryModelsPath is the OpenAI-compatible listing endpoint. Nearly
// every vendor proxies it verbatim, including the ones whose chat path
// differs.
discoveryModelsPath = "/v1/models"
// discoveryMaxBody caps the listing response. A catalog with thousands of
// models is still far under this; anything larger is a captive portal or
// an error page, not a model list.
discoveryMaxBody = 8 << 20
)
// discoveredModel is one entry of a parsed /models response, kept in the
// upstream's own terms. The upstream is the only authority on what it serves.
type discoveredModel struct {
ID string
Kind string // "chat" | "image"
Meta map[string]interface{}
}
// discoveryResult reports what a probe found, including the case where nothing
// worked — the failure text is what the operator needs, so it must name every
// URL that was tried rather than just the last one.
type discoveryResult struct {
BaseURL string `json:"base_url"`
Endpoint string `json:"endpoint"`
// ImageEndpoint is reported separately because the gateway composes it
// differently: Provider.ImageURL is BaseURL + ImageEndpoint(), and the
// default ImageEndpoint is the ABSOLUTE path "/v1/images/generations".
// Chat's default is the relative "/chat/completions". So a base_url that
// already ends in /v1 produces a working chat URL and a broken image URL
// ("/v1/v1/images/generations") — which is exactly the 404 seen on the
// first vela configuration, and which discovery has to correct.
ImageEndpoint string `json:"image_endpoint"`
Adapter string `json:"adapter"`
ModelsPath string `json:"models_path"`
Models []string `json:"models"`
ImageModels []string `json:"image_models"`
Tried []string `json:"tried"`
LatencyMS int64 `json:"latency_ms"`
Note string `json:"note,omitempty"`
}
// MarshalJSON guarantees the two list fields are always JSON arrays, never
// null. The WebUI concatenates them directly, and "null" renders as the string
// "undefined" in every row — a failure that only shows up in a browser and not
// in a JSON schema check.
//
// Relying on the constructor to initialize the slices is not enough: the zero
// value of this struct is a legitimate thing for a caller to build, and a
// future code path that forgets the initializer would ship a UI bug silently.
func (r discoveryResult) MarshalJSON() ([]byte, error) {
type alias discoveryResult // avoid recursion
out := alias(r)
if out.Models == nil {
out.Models = []string{}
}
if out.ImageModels == nil {
out.ImageModels = []string{}
}
if out.Tried == nil {
out.Tried = []string{}
}
return json.Marshal(out)
}
// discoveryCandidate is one (base_url, models-path) guess. Order matters: the
// first candidate is the one reported as normalized when several work, because
// it is the one that keeps base_url free of a version segment and therefore
// composes correctly with the adapter's chat path.
type discoveryCandidate struct {
baseURL string
modelsPath string
// prefer ranks candidates that are more likely to be the vendor's real
// path. A 200 from a lower-preference candidate is still accepted, but
// only after every higher-preference one failed — see DiscoverModels.
prefer int
}
// discoveryFailure carries the HTTP status the caller should answer with.
//
// The status is part of the error rather than left to the handler because the
// whole point of classifying a failure is that the operator must not have to
// guess whether the key, the endpoint, or the vendor is at fault.
type discoveryFailure struct {
msg string
status int
}
func (e *discoveryFailure) Error() string { return e.msg }
// StatusOrDefault reports the intended HTTP status.
func (e *discoveryFailure) StatusOrDefault() int {
if e.status == 0 {
return http.StatusBadGateway
}
return e.status
}
// discoveryCandidates enumerates the URL forms worth trying for a raw input.
//
// The ranking encodes a real observation rather than a guess: the amr-link
// endpoint serves its catalog at "/v1/models" (the form an SDK example shows)
// from a base the operator pasted WITH the "/v1", while several other vendors
// mount it at the host root. Both are probed; the one that answers is reported
// back as a normalized base/endpoint pair.
//
// What we are really deciding is the base_url to persist. The safest one keeps
// the version segment on the ENDPOINT, because openai's adapter endpoint is
// "/chat/completions" and the two are concatenated: base "https://h" +
// endpoint "/v1/chat/completions" is correct, while leaving "/v1" on the base
// doubles the default image path into "/v1/v1/images/generations" — which is
// exactly the 404 observed on the first vela configuration attempt.
func discoveryCandidates(raw string) []discoveryCandidate {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return nil
}
// Deduplicate on the URL that will actually be requested, not on the
// (base, path) pair. base "https://h/v1" + path "/models" and base "https://h"
// + path "/v1/models" both request the same address, and listing it twice in
// an error message makes the operator think two things were tried.
seenURL := map[string]bool{}
seenPair := map[string]bool{}
var out []discoveryCandidate
add := func(base, path string, prefer int) {
pair := base + "|" + path
if seenPair[pair] || seenURL[base+path] {
return
}
seenPair[pair] = true
seenURL[base+path] = true
out = append(out, discoveryCandidate{baseURL: base, modelsPath: path, prefer: prefer})
}
stripped := trimVersionSuffix(raw)
hasVersion := stripped != raw
// Both catalog paths are always tried, whichever form the operator typed.
//
// The earlier version offered "/models" only when the base already carried a
// version segment. That left a bare host ("https://api.vendor.com") with a
// SINGLE candidate and therefore no fallback at all — so a vendor serving
// its catalog at the bare "/models" was reported as unreachable. Every
// test used a "/v1"-suffixed base and so never saw it; siliconflow's
// "/v1/models" happens to answer, which is why the gap survived review.
//
// Preference only decides WHICH URL is reported when several work, never
// which ones are attempted.
//
// Order matters: a base that already ends in a version segment must be
// paired with the RELATIVE "/models" first, because pairing it with
// "/v1/models" produces "…/v1/v1/models" — a URL that is 404 by construction.
// Trying it first would spend a round trip on a certain miss and, worse,
// make the reported "tried" list lead with nonsense.
if hasVersion {
add(raw, "/models", 0)
add(raw, discoveryModelsPath, 1) // the doubled form; kept only as a last resort
add(stripped, "/models", 2)
add(stripped, discoveryModelsPath, 3)
} else {
add(raw, discoveryModelsPath, 0)
add(raw, "/models", 1)
}
// Stable insertion sort by preference; add() above is not ordered.
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j].prefer < out[j-1].prefer; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// trimVersionSuffix removes a trailing "/v1" (or /v2, /v1beta, ...) so the
// host root can be probed on its own.
func trimVersionSuffix(base string) string {
i := strings.LastIndex(base, "/")
if i < 0 {
return base
}
if !isVersionSegment(base[i+1:]) {
return base
}
return base[:i]
}
// versionSegment returns the leading path segment of a models path when that
// segment is a version marker ("/v1/models" → "/v1", "/models" → ""). It is the
// piece that must move between base_url and endpoint so the two compose.
func versionSegment(modelsPath string) string {
parts := strings.Split(strings.Trim(modelsPath, "/"), "/")
if len(parts) != 2 || !isVersionSegment(parts[0]) {
return ""
}
return "/" + parts[0]
}
// isVersionSegment reports whether a path segment looks like "v1", "v2",
// "v1beta" — and not like a real path name such as "vision" or a wildcard
// path such as "v1x".
//
// The rule is: a leading "v", then digits, then an OPTIONAL suffix drawn from
// the prerelease markers vendors actually use. A free-form alphabetic tail was
// tried and rejected — it classifies "v1x" as a version, and that is how a
// templated path segment would get stripped off a real base URL.
func isVersionSegment(seg string) bool {
if len(seg) < 2 || seg[0] != 'v' {
return false
}
i := 1
digits := 0
for ; i < len(seg); i++ {
if seg[i] < '0' || seg[i] > '9' {
break
}
digits++
}
if digits == 0 {
return false
}
suffix := strings.ToLower(seg[i:])
if suffix == "" {
return true
}
for _, marker := range []string{"alpha", "beta", "preview", "dev", "rc"} {
if suffix == marker || strings.HasPrefix(suffix, "-"+marker) {
return true
}
}
return false
}
// composeEndpoint derives the chat endpoint to persist, given the models path
// that actually answered, the base_url being persisted, and the base the
// operator typed.
//
// The rule is a single invariant: exactly one version segment must end up in
// base_url + endpoint. The adapter contributes "/chat/completions" and the
// default image path contributes "/v1/images/generations", so a version left on
// BOTH breaks one of them — and one left on NEITHER sends chat to the wrong
// path on vendors that require the segment.
func composeEndpoint(modelsPath, persistedBase, typedBase string) string {
// The version segment currently present on the persisted base.
onBase := ""
if b := trimVersionSuffix(persistedBase); b != persistedBase {
onBase = persistedBase[len(b):]
}
// The version segment present in the models path that answered.
inPath := versionSegment(modelsPath)
// The version segment actually WORKED: prefer the models path's, then the
// one the operator typed.
version := inPath
if version == "" && onBase == "" {
if b := trimVersionSuffix(typedBase); b != typedBase && typedBase != "" {
version = typedBase[len(b):]
}
}
if version == "" {
return "" // adapter default already composes
}
return version + "/chat/completions"
}
// composeImageEndpoint derives the image endpoint to persist.
//
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
// is the absolute path "/v1/images/generations" — unlike chat's relative
// "/chat/completions". So the two defaults do NOT agree about who owns the
// version segment, which is the trap this function exists to close:
//
// base "https://h" + "/v1/images/generations" → correct
// base "https://h/v1" + "/v1/images/generations" → "/v1/v1/…" 404
// base "https://h/v1" + "/images/generations" → "/v1/images/generations" correct
//
// The second row is not a theoretical guess. Measured against the real
// amr-link endpoint with base_url "https://amr-link.open-design.ai/v1":
//
// …/v1/images/generations → 200 (the image is then generated; that call
// takes minutes, which is the upstream's own
// latency and not a routing problem)
// …/v1/v1/images/generations → 404
// …/images/generations → 404
//
// Leaving the default in place when the base already ends in a version is
// therefore always wrong for such an upstream, and the version-relative form
// is the one that composes.
func composeImageEndpoint(persistedBase string) string {
if b := trimVersionSuffix(persistedBase); b != persistedBase {
return "/images/generations"
}
return ""
}
// DiscoverModels probes rawURL for a model catalog. apiKey is sent as a bearer
// token; some gateways accept an unauthenticated listing, so an empty key is
// allowed and simply omits the header.
//
// It returns on the first candidate that yields a parseable catalog, so the
// reported latency is that of the working path, not the sum of the failures.
func DiscoverModels(ctx context.Context, rawURL, apiKey string) (*discoveryResult, error) {
cands := discoveryCandidates(rawURL)
if len(cands) == 0 {
return nil, fmt.Errorf("base url required")
}
client := &http.Client{Timeout: discoveryTimeout}
var tried []string
var firstErr string
var authErr string
typedBase := strings.TrimRight(strings.TrimSpace(rawURL), "/")
var lastErr string
for _, c := range cands {
full := c.baseURL + c.modelsPath
tried = append(tried, full)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
firstErr = err.Error()
continue
}
req.Header.Set("Accept", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
lastErr = classifyTransport(err)
if lastErr == "" {
lastErr = err.Error()
}
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, discoveryMaxBody))
resp.Body.Close()
took := time.Since(start)
if readErr != nil {
firstErr = readErr.Error()
continue
}
if resp.StatusCode != http.StatusOK {
// An auth rejection is the operator's key being wrong, not the
// upstream being down. Keep it: it is the single most common
// failure and 502 would send them to the wrong place.
if af := classifyAuthStatus(resp.StatusCode, body); af != "" {
authErr = af
continue
}
firstErr = fmt.Sprintf("%s returned %d", full, resp.StatusCode)
continue
}
models, err := parseModelCatalog(body)
if err != nil {
firstErr = fmt.Sprintf("%s: %v", full, err)
continue
}
res := &discoveryResult{
BaseURL: c.baseURL,
ModelsPath: c.modelsPath,
Adapter: "openai",
Models: []string{},
ImageModels: []string{},
Tried: append([]string{}, tried...),
LatencyMS: took.Milliseconds(),
}
// Persist a base/endpoint pair that composes correctly. The adapter's
// own endpoint is "/chat/completions", so any version segment has to be
// carried on the endpoint rather than left on the base (where it doubles
// the default image path into "/v1/v1/images/generations" — the exact
// 404 seen on the first vela configuration attempt).
res.Endpoint = composeEndpoint(c.modelsPath, c.baseURL, typedBase)
res.ImageEndpoint = composeImageEndpoint(c.baseURL)
// If the operator typed a version segment and we are persisting a base
// that no longer has it, the endpoint above is what restores it. If the
// base kept the segment, the adapter default is already correct.
seen := map[string]bool{}
for _, m := range models {
if m.ID == "" || seen[m.ID] {
continue
}
seen[m.ID] = true
if m.Kind == "image" {
res.ImageModels = append(res.ImageModels, m.ID)
} else {
res.Models = append(res.Models, m.ID)
}
}
sort.Strings(res.Models)
sort.Strings(res.ImageModels)
if len(res.Models) == 0 && len(res.ImageModels) == 0 {
firstErr = fmt.Sprintf("%s: catalog contained no usable model ids", full)
continue
}
if c.baseURL != typedBase {
res.Note = fmt.Sprintf("base url normalized from %s to %s; endpoint set to %q so it composes with the adapter",
typedBase, c.baseURL, res.Endpoint)
}
return res, nil
}
return nil, buildDiscoveryError(tried, authErr, firstErr, lastErr)
}
// classifyAuthStatus turns a 401/403 from the catalog endpoint into a message
// that names the credential as the cause.
//
// This distinction is the whole point: a wrong key and an unreachable vendor
// are the two most common discovery failures, and they need opposite actions
// (re-type the key vs. go check the vendor). Reporting both as "upstream
// error" makes the operator guess.
func classifyAuthStatus(status int, body []byte) string {
if status != http.StatusUnauthorized && status != http.StatusForbidden {
return ""
}
snippet := strings.TrimSpace(string(body))
// Vendors put the reason in wildly different shapes; a compact quote is
// more useful than a parse attempt that fails on most of them.
snippet = strings.Join(strings.Fields(snippet), " ")
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
return fmt.Sprintf("upstream rejected the api key (HTTP %d): %s — "+
"check the key, not the endpoint", status, snippet)
}
// classifyTransport turns low-level network failures into something an
// operator can act on. An empty result means "no specific advice", and the
// caller keeps the raw error.
func classifyTransport(err error) string {
if err == nil {
return ""
}
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "no such host"):
return "hostname does not resolve: " + msg
case strings.Contains(lower, "certificate"), strings.Contains(lower, "tls"):
return "TLS verification failed: " + msg
case strings.Contains(lower, "connection refused"):
return "connection refused: " + msg
}
return ""
}
// buildDiscoveryError assembles the final error, preferring the most specific
// diagnosis available. Every URL tried is listed: an operator staring at a
// failed probe needs to know the search actually happened, not just the last
// attempt.
func buildDiscoveryError(tried []string, authErr, firstErr, lastErr string) error {
triedPart := "no model catalog found"
if len(tried) > 0 {
triedPart = "no model catalog found; tried " + strings.Join(tried, ", ")
}
if authErr != "" {
return &discoveryFailure{status: http.StatusUnauthorized, msg: authErr + " (" + triedPart + ")"}
}
detail := firstErr
if detail == "" {
detail = lastErr
}
if detail == "" {
return errors.New(triedPart)
}
if lastErr != "" && lastErr != firstErr {
detail += " (last: " + lastErr + ")"
}
return &discoveryFailure{status: http.StatusBadGateway, msg: triedPart + "; " + detail}
}
// upstreamModel is the subset of a /models entry this code reads. Vendors add
// fields freely and rename the nesting; only id is universal, and the modality
// hints below are read opportunistically.
type modelCaps struct {
ImageGeneration bool `json:"image_generation"`
Vision bool `json:"vision"`
}
type upstreamModel struct {
ID string `json:"id"`
Object string `json:"object"`
Kind string `json:"kind"`
Type string `json:"type"`
Modality string `json:"modality"`
Architecture struct {
InputModalities []string `json:"input_modalities"`
OutputModalities []string `json:"output_modalities"`
} `json:"architecture"`
Capabilities modelCaps `json:"capabilities"`
// Metadata is kept as raw JSON on purpose. The real amr-link catalog nests
// cost/capability/context_limit here, and a typed struct would silently
// discard every key this build does not know about — the operator would
// then lose the context limit their vendor published. Raw also lets
// input_modalities/output_modalities be read without a second type.
Metadata json.RawMessage `json:"metadata"`
}
// metadataFields lists the keys worth surfacing in the UI. They are copied
// through verbatim and nothing is inferred from them; an upstream that sends
// no context limit must leave that absent rather than have this code write 0.
var metadataFields = []string{
"context_length", "context_window", "max_context_length",
"context_limit", "context_budget", "cost", "capability",
}
// parseModelCatalog reads the OpenAI list shape {"data":[...]} and the bare
// array shape some gateways return. A response that is valid JSON but has
// neither shape is an error, not an empty catalog: reporting "0 models" for a
// login page or a rate-limit JSON would silently create an empty source.
func parseModelCatalog(body []byte) ([]discoveredModel, error) {
// "null" unmarshals into a nil slice without error, and an HTML login page
// fails to unmarshal at all. Both must be errors.
trimmed := strings.TrimSpace(string(body))
if trimmed == "" || trimmed == "null" {
return nil, fmt.Errorf("empty response")
}
var wrapper struct {
Data []upstreamModel `json:"data"`
Models []upstreamModel `json:"models"`
}
if err := json.Unmarshal(body, &wrapper); err == nil {
if len(wrapper.Data) > 0 {
return toDiscovered(wrapper.Data), nil
}
if len(wrapper.Models) > 0 {
return toDiscovered(wrapper.Models), nil
}
// A well-formed but empty catalog is a legitimate answer from a key
// that has no models provisioned. Distinguish it from "not a catalog"
// by checking the shape actually parsed.
var probe map[string]json.RawMessage
if json.Unmarshal(body, &probe) == nil {
if _, ok := probe["data"]; ok {
return nil, nil
}
if _, ok := probe["models"]; ok {
return nil, nil
}
}
}
var bare []upstreamModel
if err := json.Unmarshal(body, &bare); err == nil {
return toDiscovered(bare), nil
}
return nil, fmt.Errorf("response is not a model catalog")
}
func toDiscovered(in []upstreamModel) []discoveredModel {
out := make([]discoveredModel, 0, len(in))
for _, m := range in {
out = append(out, discoveredModel{
ID: m.ID,
Kind: discoveredModelKind(m),
Meta: modelMeta(m),
})
}
return out
}
// discoveredModelKind trusts the upstream's own modality fields and nothing
// else. It never infers from the model NAME: ids like "gpt-image-1" or
// "ideogram-v2" appear on catalogs that also serve chat, and a wrong kind
// poisons the AUTO chain (an image-only id selected for a chat request fails
// every tier behind it).
func discoveredModelKind(m upstreamModel) string {
modIn := append(append([]string{}, m.Architecture.InputModalities...),
modalityList(m.Metadata, "input_modalities")...)
modOut := append(append([]string{}, m.Architecture.OutputModalities...),
modalityList(m.Metadata, "output_modalities")...)
has := func(list []string, want string) bool {
for _, v := range list {
if strings.EqualFold(strings.TrimSpace(v), want) {
return true
}
}
return false
}
if m.Capabilities.ImageGeneration || has(modOut, "image") {
return "image"
}
if has(modIn, "image") || has(modIn, "file") || has(modIn, "audio") || m.Capabilities.Vision {
return "chat" // multimodal input still means a chat model
}
// Some catalogs encode modality as a top-level string instead of a list.
// Only the image-ish spellings count: "chat" here means the upstream said
// chat, and treating that as "unknown" would ignore its statement.
switch strings.ToLower(strings.TrimSpace(m.Kind + " " + m.Modality + " " + m.Type)) {
case "image", "text-to-image", "image_generation", "text_to_image":
return "image"
}
return "chat"
}
// modalityList reads one array-of-strings field out of a raw JSON object,
// tolerating both absence and a non-array value.
func modalityList(raw json.RawMessage, field string) []string {
if len(raw) == 0 {
return nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
v, ok := obj[field]
if !ok {
return nil
}
var out []string
if err := json.Unmarshal(v, &out); err != nil {
return nil
}
return out
}
// modelMeta carries through the fields an operator wants to see in the UI
// without this code inventing them. The upstream nests most of them under
// "metadata" (the real amr-link catalog does) while some put the same keys at
// the top level, so both are read and top level wins on a tie. Only keys the
// upstream actually sent are copied, so an absent context limit stays absent
// instead of becoming 0.
func modelMeta(m upstreamModel) map[string]interface{} {
meta := map[string]interface{}{}
copyFields := func(raw json.RawMessage) {
if len(raw) == 0 {
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return
}
for _, k := range metadataFields {
if v, ok := obj[k]; ok {
if dec := rawOrString(v); dec != nil {
meta[k] = dec
}
}
}
}
copyFields(m.Metadata) // nested first
copyFields(mustJSON(m)) // top level wins
if len(meta) == 0 {
return nil
}
return meta
}
// rawOrString keeps numeric metadata as a number and everything else as its
// decoded string, so the UI can sort on a cost or context length instead of
// receiving "1048000" and having to coerce it.
func rawOrString(raw json.RawMessage) interface{} {
var num json.Number
if err := json.Unmarshal(raw, &num); err == nil {
if f, err := num.Float64(); err == nil {
return f
}
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var any interface{}
if err := json.Unmarshal(raw, &any); err == nil {
return any
}
return nil
}
func mustJSON(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}
// modelsToConfig converts discovery output into config.Model entries, applying
// the same priority convention the UI uses: chat models the operator is
// likely to want first get a lower number, and image models sort last because
// they are unreachable from /v1/chat/completions.
func modelsToConfig(chat, image []string) []config.Model {
out := make([]config.Model, 0, len(chat)+len(image))
for _, id := range chat {
out = append(out, config.Model{ID: id, Priority: 50, Kind: "chat"})
}
for _, id := range image {
out = append(out, config.Model{ID: id, Priority: 70, Kind: "image"})
}
return out
}

View File

@ -1,403 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"llmsproxy/internal/config"
)
// HTTP surface for model discovery.
//
// Two endpoints, deliberately separate:
//
// POST /api/sources/discover — probe a URL+key, return the catalog.
// Nothing is written.
// POST /api/sources/discover/apply — probe, then write the source.
//
// The split exists because discovery mutates nothing and is therefore safe to
// call repeatedly while the operator is still typing a key, whereas apply
// rewrites credentials and the AUTO chain's inputs. Making them one endpoint
// with a flag would mean a "just check it" click can silently create a source.
//
// Both are admin-only, matching /api/sources: the request body carries an API
// key, and a model catalog reveals which upstreams an operator uses.
// discoverRequest is the body of both endpoints.
type discoverRequest struct {
// BaseURL is what the operator pasted. It may or may not include a
// version segment; discovery works that out rather than making the
// caller know this gateway's path-composition rules.
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
// Name is required for apply, ignored for discover. It defaults from the
// base URL's host when omitted, because "https://api.vendor.com/v1" is a
// better starting name than "new-source".
Name string `json:"name"`
// Adapter is optional. Discovery assumes "openai" because that is what a
// /v1/models catalog implies; a non-OpenAI upstream (Anthropic, Gemini)
// has no such endpoint, so discovery will legitimately fail for those and
// the operator configures them by hand.
Adapter string `json:"adapter"`
// Temperature and MaxConcurrent are carried into the written source.
Temperature float64 `json:"temperature"`
MaxConcurrent int `json:"max_concurrent"`
// ReplaceModels drops the source's existing model list before applying the
// discovered one. It defaults to true for a NEW source and is required to
// be spelled out for an existing one, so a re-discovery cannot silently
// delete hand-tuned priorities.
ReplaceModels *bool `json:"replace_models,omitempty"`
// Select narrows what gets written. Empty means everything discovered.
// Ids that are not in the catalog are an error rather than a silent skip:
// a typo here would produce a source that 404s on every request.
Include []string `json:"include,omitempty"`
Exclude []string `json:"exclude,omitempty"`
}
// discoverResponse is the JSON the WebUI binds to.
type discoverResponse struct {
OK bool `json:"ok"`
// Normalized source fields, ready to be echoed back to /api/sources.
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Models []string `json:"models"`
ImageModels []string `json:"image_models"`
// Endpoint is set only when discovery had to move a version segment.
Endpoint string `json:"endpoint"`
// ImageEndpoint is required whenever base_url carries a version segment:
// the gateway's default image path is the ABSOLUTE "/v1/images/generations"
// while chat's default is the relative "/chat/completions", so the same
// base_url that yields a working chat URL yields a 404 image URL.
ImageEndpoint string `json:"image_endpoint"`
// Diagnostics for the operator.
ModelsPath string `json:"models_path"`
Tried []string `json:"tried"`
LatencyMS int64 `json:"latency_ms"`
Note string `json:"note,omitempty"`
Warnings []string `json:"warnings,omitempty"`
// Set by apply only.
SourceName string `json:"source_name,omitempty"`
Applied bool `json:"applied,omitempty"`
}
func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
apply := strings.HasSuffix(r.URL.Path, "/apply")
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
"use POST with {\"base_url\":…,\"api_key\":…}")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot read body")
return
}
var req discoverRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
req.BaseURL = strings.TrimSpace(req.BaseURL)
if req.BaseURL == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "base_url required")
return
}
if req.Adapter == "" {
req.Adapter = "openai"
}
// Bounded so a hung upstream cannot pin the handler; discovery has its own
// per-request timeout on top.
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
res, err := DiscoverModels(ctx, req.BaseURL, req.APIKey)
if err != nil {
// A rejected key must not be reported as an upstream outage: the two
// need opposite actions from the operator, and 502 sends them to the
// wrong one.
status := http.StatusBadGateway
var f *discoveryFailure
if errors.As(err, &f) {
status = f.StatusOrDefault()
}
writeError(w, status, "discover_failed", err.Error())
return
}
chat, image, warns, err := selectModels(res.Models, res.ImageModels, req.Include, req.Exclude)
if err != nil {
// 400: the operator asked for a model the catalog does not have.
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
out := &discoverResponse{
OK: true,
BaseURL: res.BaseURL,
Adapter: req.Adapter,
Models: chat,
ImageModels: image,
Endpoint: res.Endpoint,
ImageEndpoint: res.ImageEndpoint,
ModelsPath: res.ModelsPath,
Tried: res.Tried,
LatencyMS: res.LatencyMS,
Note: res.Note,
Warnings: warns,
}
if len(chat) == 0 && len(image) == 0 {
out.Warnings = append(out.Warnings,
"every discovered model was excluded; applying this would create a source with no models")
}
if !apply {
writeJSON(w, http.StatusOK, out)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = defaultSourceName(req.BaseURL)
}
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_request",
"name required: cannot derive one from base_url")
return
}
if strings.ContainsAny(name, "/\\ \t") || name == "." || name == ".." {
writeError(w, http.StatusBadRequest, "invalid_request",
"source name must not contain slashes or whitespace")
return
}
exists := g.sourceExists(name)
if exists && req.ReplaceModels == nil {
// Refusing here is deliberate. Re-running discovery on an existing
// source and having it silently replace a hand-tuned model list —
// including priorities chosen for the AUTO chain — is the kind of
// "helpful" behaviour that loses an afternoon.
writeError(w, http.StatusConflict, "conflict",
"source "+name+" already exists; set replace_models=true to overwrite its model list, "+
"or choose a different name")
return
}
// Build the source directly rather than round-tripping through the
// /api/sources payload: resolveAPIKey carries the "an empty api_key means
// keep the stored one" rule, and going through a second decoder here would
// duplicate (and eventually diverge from) that logic.
src := config.Source{
Name: name,
BaseURL: res.BaseURL,
APIKey: g.resolveAPIKey(name, req.APIKey, exists),
Adapter: req.Adapter,
Endpoint: res.Endpoint,
ImageEndpoint: res.ImageEndpoint,
Models: modelsToConfig(chat, image),
Temperature: req.Temperature,
}
if req.MaxConcurrent > 0 {
src.MaxConcurrent = req.MaxConcurrent
}
// An existing source may carry fields discovery does not know about
// (proxy_url, headers, api_key_env, timeout). Rebuilding from scratch would
// drop them, so they are inherited unless the request overrides them.
if exists {
old := g.sourceByName(name)
if old != nil {
src.ProxyURL = old.ProxyURL
src.APIKeyEnv = old.APIKeyEnv
src.Timeout = old.Timeout
src.QueueTimeout = old.QueueTimeout
src.Headers = old.Headers
src.Meta = old.Meta
src.MaxTokens = old.MaxTokens
src.RPM = old.RPM
// image_endpoint is NOT inherited when discovery supplied one:
// the stored value was correct for the OLD base_url, and the base
// is exactly what may have changed. Inheriting it would keep a
// stale value that 404s against the new base.
if res.ImageEndpoint == "" {
src.ImageEndpoint = old.ImageEndpoint
}
if src.Adapter == "" {
src.Adapter = old.Adapter
}
if req.MaxConcurrent <= 0 {
src.MaxConcurrent = old.MaxConcurrent
}
// replace_models=false keeps the curated list. Discovery still
// refreshed base_url/endpoint/credential, which is the point of
// re-running it.
if req.ReplaceModels != nil && !*req.ReplaceModels {
src.Models = old.Models
out.Warnings = append(out.Warnings,
"model list left unchanged (replace_models=false)")
}
}
}
if err := g.core.AddSource(src); err != nil {
writeError(w, http.StatusBadRequest, "source_error", err.Error())
return
}
out.SourceName = name
out.Applied = true
writeJSON(w, http.StatusOK, out)
}
// selectModels applies the include/exclude filters.
//
// An include entry the catalog does not have is an error: silently dropping it
// produces a source that looks configured and 404s at request time, which is
// far harder to diagnose than a rejected apply. An exclude entry that is not
// present is fine — the operator is allowed to be defensive.
func selectModels(chat, image, include, exclude []string) ([]string, []string, []string, error) {
have := map[string]bool{}
for _, m := range chat {
have[m] = true
}
for _, m := range image {
have[m] = true
}
if len(include) > 0 {
var missing []string
for _, want := range include {
if !have[want] {
missing = append(missing, want)
}
}
if len(missing) > 0 {
return nil, nil, nil, &discoverError{
msg: "these models are not in the upstream catalog: " + strings.Join(missing, ", "),
}
}
keep := map[string]bool{}
for _, want := range include {
keep[want] = true
}
var c, i []string
for _, m := range chat {
if keep[m] {
c = append(c, m)
}
}
for _, m := range image {
if keep[m] {
i = append(i, m)
}
}
chat, image = c, i
}
if len(exclude) > 0 {
drop := map[string]bool{}
for _, m := range exclude {
drop[m] = true
}
var c, i []string
for _, m := range chat {
if !drop[m] {
c = append(c, m)
}
}
for _, m := range image {
if !drop[m] {
i = append(i, m)
}
}
chat, image = c, i
}
// chat and image must stay non-nil: the JSON contract forbids null here.
if chat == nil {
chat = []string{}
}
if image == nil {
image = []string{}
}
var warns []string
if len(chat) == 0 && len(image) == 0 {
// Caller can only get here by excluding everything, or from a catalog
// that was already empty. Either way "silence" would read as success.
warns = append(warns,
"no models selected; applying this would create a source that cannot serve any request")
return chat, image, warns, nil
}
if len(image) > 0 && len(chat) == 0 {
warns = append(warns,
"every model is an image model; AUTO chat requests have nothing to select")
}
if len(chat) > 0 && len(image) > 0 {
warns = append(warns,
"image models are only reachable through /v1/images/generations, not AUTO chat")
}
return chat, image, warns, nil
}
// discoverError is a client mistake rather than an upstream failure, which is
// why it gets its own type instead of fmt.Errorf.
type discoverError struct{ msg string }
func (e *discoverError) Error() string { return e.msg }
// defaultSourceName derives a source name from a base URL host, stripping the
// version segment and any "api." prefix. It returns "" for input that is not a
// URL at all — the caller must not fall back to echoing garbage into a source
// name, since the name becomes a config key and an API path segment.
func defaultSourceName(baseURL string) string {
s := strings.TrimSpace(baseURL)
if !strings.Contains(s, "://") {
return ""
}
s = s[strings.Index(s, "://")+3:]
if i := strings.IndexAny(s, "/?#"); i >= 0 {
s = s[:i]
}
if i := strings.LastIndex(s, ":"); i > 0 && !strings.Contains(s[i+1:], "]") {
s = s[:i] // strip port
}
// A host must still look like one after trimming the cosmetic prefixes.
s = strings.TrimPrefix(s, "api.")
s = strings.TrimPrefix(s, "www.")
if s == "" || strings.ContainsAny(s, " \t\"'<>") {
return ""
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '.' || r == '-' || r == '_':
default:
return ""
}
}
if !strings.Contains(s, ".") {
// A bare label is far more likely to be a mistake than a hostname.
return ""
}
return s
}

View File

@ -1,314 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"llmsproxy/internal/config"
)
// upstream that answers /v1/models with the real captured catalog
func catalogUpstream(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer sk-secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(realCatalogBody))
}))
t.Cleanup(srv.Close)
return srv
}
func TestDefaultSourceName(t *testing.T) {
cases := map[string]string{
"https://api.vendor.com/v1": "vendor.com",
"https://api.openai.com/v1": "openai.com",
"https://www.vendor.com/v1": "vendor.com",
"http://127.0.0.1:8080/v1": "127.0.0.1",
"https://amr-link.open-design.ai/v1": "amr-link.open-design.ai",
"not a url at all": "",
}
for in, want := range cases {
if got := defaultSourceName(in); got != want {
t.Errorf("defaultSourceName(%q) = %q, want %q", in, got, want)
}
}
}
func TestSelectModels_IncludeUnknownIsAnError(t *testing.T) {
chat := []string{"a", "b"}
image := []string{"img"}
// Silently dropping a typo'd id would create a source that looks
// configured and 404s on every request — much worse than a rejection.
_, _, _, err := selectModels(chat, image, []string{"a", "typo"}, nil)
if err == nil {
t.Fatal("expected an error for an include id that is not in the catalog")
}
if !strings.Contains(err.Error(), "typo") {
t.Errorf("error should name the offending id, got %q", err)
}
}
func TestSelectModels_IncludeNarrows(t *testing.T) {
chat, image, _, err := selectModels([]string{"a", "b", "c"}, []string{"img1", "img2"}, []string{"b", "img1"}, nil)
if err != nil {
t.Fatal(err)
}
if len(chat) != 1 || chat[0] != "b" {
t.Errorf("chat = %v, want [b]", chat)
}
if len(image) != 1 || image[0] != "img1" {
t.Errorf("image = %v, want [img1]", image)
}
}
func TestSelectModels_ExcludeUnknownIsFine(t *testing.T) {
chat, _, _, err := selectModels([]string{"a"}, nil, nil, []string{"never-existed"})
if err != nil {
t.Errorf("excluding a model that is not present should be allowed: %v", err)
}
if len(chat) != 1 {
t.Errorf("chat = %v, want [a] unchanged", chat)
}
}
func TestSelectModels_NeverReturnsNil(t *testing.T) {
chat, image, warns, err := selectModels(nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
if chat == nil || image == nil {
t.Fatal("nil slices would serialize as JSON null and break the UI")
}
if len(warns) == 0 {
t.Error("an empty catalog deserves a warning, not silence")
}
}
func TestSelectModels_WarnsAboutImageOnlyCatalog(t *testing.T) {
_, _, warns, err := selectModels(nil, []string{"gpt-image-2"}, nil, nil)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(warns, " ")
if !strings.Contains(joined, "AUTO") {
t.Errorf("warnings %v should say AUTO has nothing to select", warns)
}
}
func TestSelectModels_WarnsThatImagesAreNotInAUTO(t *testing.T) {
_, _, warns, err := selectModels([]string{"a"}, []string{"img"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(warns, " "), "images/generations") {
t.Errorf("warnings %v should point at the image endpoint", warns)
}
}
// TestDiscoveryResultAndConfigRoundTrip checks the fields the WebUI will echo
// back actually survive into a config.Source — a mismatch here means the
// operator previews one set of values and saves another.
func TestDiscoveryResultAndConfigRoundTrip(t *testing.T) {
res := &discoveryResult{
BaseURL: "https://h",
Endpoint: "/v1/chat/completions",
Models: []string{"m1", "m2"},
ImageModels: []string{"img1"},
}
src := config.Source{
Name: "x",
BaseURL: res.BaseURL,
Endpoint: res.Endpoint,
Models: modelsToConfig(res.Models, res.ImageModels),
}
if len(src.Models) != 3 {
t.Fatalf("models = %d, want 3", len(src.Models))
}
if src.Endpoint != "/v1/chat/completions" {
t.Errorf("endpoint lost: %q", src.Endpoint)
}
var img int
for _, m := range src.Models {
if m.Kind == "image" {
img++
}
}
if img != 1 {
t.Errorf("image models = %d, want 1", img)
}
}
// TestDiscoverModels_SendsAuthorizationHeader pins the only way the feature
// works against a real vendor: without the bearer token every paid /models
// endpoint returns 401.
func TestDiscoverModels_SendsAuthorizationHeader(t *testing.T) {
var seen string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
}))
defer srv.Close()
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret"); err != nil {
t.Fatal(err)
}
if seen != "Bearer sk-secret" {
t.Errorf("Authorization = %q, want %q", seen, "Bearer sk-secret")
}
}
// TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway pins the distinction
// that decides whether the operator knows what to do next.
//
// A rejected key and an unreachable vendor are the two most common discovery
// failures, and they need opposite actions: re-type the key, versus go check the
// vendor's status page. Both used to surface as one generic error, so the
// operator had to guess.
func TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"invalid api key","code":"invalid_api_key"}}`))
}))
defer srv.Close()
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-wrong")
if err == nil {
t.Fatal("expected a failure against a 401 endpoint")
}
var f *discoveryFailure
if !errors.As(err, &f) {
t.Fatalf("error should be classified, got %T: %v", err, err)
}
if got := f.StatusOrDefault(); got != http.StatusUnauthorized {
t.Errorf("status = %d, want %d — a wrong key must not read as an upstream outage",
got, http.StatusUnauthorized)
}
// The message has to name which credential is at fault, or the status code
// alone still sends the operator to the wrong place.
if !strings.Contains(strings.ToLower(f.Error()), "api key") {
t.Errorf("message %q should name the api key as the cause", f.Error())
}
if !strings.Contains(f.Error(), "/v1/models") {
t.Errorf("message %q should list what was tried", f.Error())
}
}
// TestDiscoverModels_UnreachableStaysBadGateway: the classification must not
// over-correct — an unresolvable host is still the vendor's or the network's
// problem, not the key's.
func TestDiscoverModels_UnreachableStaysBadGateway(t *testing.T) {
_, err := DiscoverModels(context.Background(), "https://no-such-host-xyz.invalid/v1", "k")
if err == nil {
t.Fatal("expected a failure for an unresolvable host")
}
var f *discoveryFailure
if !errors.As(err, &f) {
t.Fatalf("error should be classified, got %T", err)
}
if got := f.StatusOrDefault(); got != http.StatusBadGateway {
t.Errorf("status = %d, want %d", got, http.StatusBadGateway)
}
if !strings.Contains(strings.ToLower(f.Error()), "resolve") {
t.Errorf("message %q should say the hostname does not resolve", f.Error())
}
}
// TestDiscoveryCandidates_NoDuplicateRequests: base "https://h/v1" with path
// "/models" and base "https://h" with path "/v1/models" request the SAME URL.
// Listing it twice in a failure message reads as "two things were tried" when
// only one was.
func TestDiscoveryCandidates_NoDuplicateRequests(t *testing.T) {
seen := map[string]bool{}
for _, c := range discoveryCandidates("https://h/v1") {
url := c.baseURL + c.modelsPath
if seen[url] {
t.Errorf("candidate %q would be requested twice", url)
}
seen[url] = true
}
if len(seen) < 2 {
t.Errorf("only %d distinct URLs; the search collapsed", len(seen))
}
}
// TestDiscoverModels_FailureListsEachURLOnce is the end-to-end form of the same
// rule, on the error text an operator actually reads.
func TestDiscoverModels_FailureListsEachURLOnce(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err == nil {
t.Fatal("expected failure")
}
msg := err.Error()
needle := srv.URL + "/v1/models"
if n := strings.Count(msg, needle); n > 1 {
t.Errorf("URL %s appears %d times in %q", needle, n, msg)
}
}
func TestDiscoverModels_EmptyKeyOmitsHeader(t *testing.T) {
var present bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, present = r.Header["Authorization"]
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
}))
defer srv.Close()
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", ""); err != nil {
t.Fatal(err)
}
if present {
t.Error("an empty key should omit the header, not send an empty bearer")
}
}
// TestDiscoverModels_RealCatalogEndToEnd walks the actual captured response
// through the whole path and asserts the numbers an operator would see.
func TestDiscoverModels_RealCatalogEndToEnd(t *testing.T) {
srv := catalogUpstream(t)
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL+"/v1" {
t.Errorf("base = %q, want the typed URL unchanged", res.BaseURL)
}
if res.Endpoint != "" {
t.Errorf("endpoint = %q, want empty (base already carries /v1)", res.Endpoint)
}
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
t.Errorf("chat/image = %d/%d, want 2/2", len(res.Models), len(res.ImageModels))
}
if res.Adapter != "openai" {
t.Errorf("adapter = %q, want openai", res.Adapter)
}
// The response must be JSON the UI can consume directly.
b, _ := json.Marshal(res)
var round map[string]interface{}
if err := json.Unmarshal(b, &round); err != nil {
t.Fatal(err)
}
if round["ok"] != nil {
// discoverResponse adds ok; discoveryResult does not. Both marshal
// fine, so this is only a shape note.
_ = round
}
}

View File

@ -1,153 +0,0 @@
package gateway
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestComposeImageEndpoint_NoVersionBaseLeavesDefault is the counterpart of the
// /v1 case: when the base carries no version segment, discovery must emit "" so
// the gateway's own default "/v1/images/generations" composes.
//
// This is the shape every currently-working source uses — siliconflow, the only
// source in production with kind:image, has base_url
// "https://api.siliconflow.cn" — so writing a non-empty value here would break
// exactly the configuration that is known to work today.
func TestComposeImageEndpoint_NoVersionBaseLeavesDefault(t *testing.T) {
if got := composeImageEndpoint("https://api.siliconflow.cn"); got != "" {
t.Errorf("composeImageEndpoint = %q, want \"\" so the built-in default composes", got)
}
// And the composition it implies must be the vendor's real path.
base := "https://api.siliconflow.cn"
composed := base + "/v1/images/generations"
if !strings.HasSuffix(composed, "/v1/images/generations") {
t.Errorf("composed URL %q lost the image path", composed)
}
}
// TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned
// distinguishes two cases that look identical from the outside.
//
// bare host + catalog at "/models" → chat is "/chat/completions" (the
// adapter default, no endpoint written)
// bare host + catalog at "/v1/models" → chat is "/v1/chat/completions",
// so an endpoint MUST be written
//
// The catalog path is the only evidence of whether the upstream wants the
// version segment, and getting this backwards sends every chat request to a
// path the vendor does not serve.
func TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned(t *testing.T) {
cases := []struct {
name string
catalogPath string
wantEndpoint string
wantImage string
}{
{
name: "catalog at the bare /models needs no endpoint",
catalogPath: "/models",
wantEndpoint: "",
wantImage: "",
},
{
name: "catalog at /v1/models requires /v1 on the chat endpoint",
catalogPath: "/v1/models",
wantEndpoint: "/v1/chat/completions",
wantImage: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tc.catalogPath {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL, "k")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL {
t.Errorf("base = %q, want %q", res.BaseURL, srv.URL)
}
if res.Endpoint != tc.wantEndpoint {
t.Errorf("endpoint = %q, want %q", res.Endpoint, tc.wantEndpoint)
}
if res.ImageEndpoint != tc.wantImage {
t.Errorf("image_endpoint = %q, want %q", res.ImageEndpoint, tc.wantImage)
}
// Whatever it wrote, the chat URL must reach the same version
// prefix the catalog was served under.
ep := res.Endpoint
if ep == "" {
ep = "/chat/completions"
}
if got := res.BaseURL + ep; strings.Contains(got, "/v1/v1") {
t.Errorf("chat URL %q has a doubled version", got)
}
})
}
}
// TestComposeEndpoints_BothFormsProduceWorkingURLs is the end-to-end
// invariant across the two shapes, stated as the URLs the gateway will build.
//
// A base with a version and no image_endpoint produces "/v1/v1/…" — the exact
// 404 observed before composeImageEndpoint existed. A base without one and a
// non-empty image_endpoint produces a path missing the version. Both are
// silent, expensive mistakes, so they are asserted literally.
func TestComposeEndpoints_BothFormsProduceWorkingURLs(t *testing.T) {
cases := []struct {
name string
base string
wantChat string
wantImage string
}{
{
name: "bare host: adapter and image defaults compose",
base: "https://h",
wantChat: "https://h/chat/completions",
// openai's adapter endpoint is /chat/completions (verified in
// internal/lua/adapters/openai.lua: adapter.endpoint).
wantImage: "https://h/v1/images/generations",
},
{
name: "versioned base: the version moves onto both endpoints",
base: "https://h/v1",
wantChat: "https://h/v1/chat/completions",
wantImage: "https://h/v1/images/generations",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ep := composeEndpoint("/models", tc.base, tc.base)
if ep == "" {
ep = "/chat/completions" // the adapter default
}
iep := composeImageEndpoint(tc.base)
if iep == "" {
iep = "/v1/images/generations" // the gateway default
}
gotChat := tc.base + ep
gotImage := tc.base + iep
if gotChat != tc.wantChat {
t.Errorf("chat URL = %q, want %q", gotChat, tc.wantChat)
}
if gotImage != tc.wantImage {
t.Errorf("image URL = %q, want %q", gotImage, tc.wantImage)
}
for _, u := range []string{gotChat, gotImage} {
if strings.Contains(u, "/v1/v1") {
t.Errorf("%q has a doubled version segment", u)
}
}
})
}
}

View File

@ -1,342 +0,0 @@
package gateway
// Real mutation injection: rewrites the IMPLEMENTATION on disk, re-runs the
// suite, and requires it to go red.
//
// Why this exists and why it is a Go file rather than a shell script:
//
// The first version was a shell script. Its -run filter contained a regex that
// the shell mangled, so every mutant reported "SURVIVED" while go test printed
// "[no tests to run]". Four mutants looked like judgement failures and were
// actually never executed. A judgement that cannot tell "tests passed" from
// "no tests ran" is not a judgement.
//
// This version therefore refuses to report anything unless it has first proved
// the filter selects a non-trivial number of tests (see baseline below), and it
// treats "[no tests to run]" as a hard error rather than a green run.
//
// It also must not be run against a DIRTY tree: the mutant is written over
// discover.go, and a leftover mutant from a killed run would make the baseline
// green against broken code while every verdict came from the wrong source.
// See the guard at the top of TestRealMutants.
//
// Run: go test -tags luajit ./internal/gateway/ -run TestRealMutants -v
import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
// mutationFilter must select every assertion that guards the code below. It is
// deliberately broad: a narrow filter is how mutants get to survive for free.
const mutationFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|ComposeImageEndpoint|ComposeEndpoints|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates|Discovery_BareHost)"
type mutation struct {
name string
from string
to string
}
func TestRealMutants(t *testing.T) {
if testing.Short() {
t.Skip("mutation campaign compiles the package repeatedly")
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
target := filepath.Join(wd, "discover.go")
orig, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
// Refuse to run against a dirty tree.
//
// The mutant is written OVER discover.go, so on a dirty file the "original"
// captured above is not the committed source — the baseline then runs against
// whatever was there, and a killed run (a background-task timeout, a Ctrl-C)
// leaves a mutant behind that the NEXT run treats as the baseline. Both make
// every verdict meaningless while the harness still reports a clean score.
//
// Refusing rather than restoring: this cannot tell "leftover mutant" apart from
// "the developer is mid-edit", and silently reverting real work is worse than
// stopping.
if dirty, derr := mutationTargetIsDirty(wd, "discover.go"); derr == nil && dirty {
t.Fatalf("%s has uncommitted changes — commit or stash them first. A mutant "+
"left behind by a killed run would otherwise become the baseline, and "+
"every verdict below would be about code nobody wrote.", target)
}
defer func() {
if werr := os.WriteFile(target, orig, 0o644); werr != nil {
t.Fatalf("restore discover.go: %v", werr)
}
}()
// --- prove the harness is armed before trusting any verdict ---
// A filter that selects nothing exits 0, which is indistinguishable from a
// real pass. The only way to tell them apart is to compare against a filter
// KNOWN to be empty: if that empty filter produces the same result as the
// real one, the real one is probably empty too.
//
// Note go test does NOT print "[no tests to run]" for a wholly unmatched
// filter (it prints that only when the package has other matching tests), so
// the check has to compare outputs rather than look for a marker string.
// Getting this backwards made the harness refuse to run at all.
emptyOut := runSuite(wd, "TestZZZNoSuchTestExists")
realOut := runSuite(wd, mutationFilter)
if countTests(emptyOut) != 0 {
t.Fatalf("a filter matching nothing selected %d tests; the harness's own probe is broken",
countTests(emptyOut))
}
// Kept for the no-op check below: a mutant whose output matches the
// baseline never changed behaviour, so it is an invalid mutation rather
// than a gap in the tests.
baseOut := realOut
if strings.Contains(realOut, "[no tests to run]") {
t.Fatalf("baseline selected no tests:\n%s", realOut)
}
baseCount := countTests(realOut)
if baseCount < 25 {
t.Fatalf("baseline ran only %d tests; the filter is too narrow to judge anything", baseCount)
}
if strings.Contains(realOut, "FAIL") {
t.Logf("note: baseline is not green; kill verdicts remain valid")
}
t.Logf("baseline: %d tests selected, empty filter selects %d",
baseCount, countTests(emptyOut))
muts := []mutation{
{
// Written as a condition that still READS modOut, because a
// mutation that leaves a variable unused fails to compile — and a
// build failure is not a judgement (see the harness note above).
name: "image kind ignores upstream output modality",
from: `if m.Capabilities.ImageGeneration || has(modOut, "image") {`,
to: "if m.Capabilities.ImageGeneration || (len(modOut) > 0 && false) {",
},
{
name: "kind defaults to image (guessing from names)",
from: "\t}\n\treturn \"chat\"\n}\n\n// modalityList",
to: "\t}\n\treturn \"image\"\n}\n\n// modalityList",
},
{
name: "nested metadata no longer read",
from: "\tcopyFields(m.Metadata) // nested first",
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
},
{
name: "non-catalog bodies accepted",
from: "\tif trimmed == \"\" || trimmed == \"null\" {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
to: "\tif false && (trimmed == \"\" || trimmed == \"null\") {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
},
{
// Removing the bare "/models" candidate must be observable: a vendor
// serving its catalog at the bare path would otherwise look
// unreachable. The `len(...) >= 0` is a tautology that only exists to
// keep the expression syntactically valid after the call is dropped.
name: "URL fallback search removed",
from: "\t\tadd(raw, \"/models\", 0)",
to: "\t\tif len(\"/models\") >= 0 {\n\t\t\t_ = hasVersion\n\t\t}",
},
{
name: "version segment never moved onto the chat endpoint",
from: "\tif version == \"\" {\n\t\treturn \"\" // adapter default already composes\n\t}",
to: "\tif version == \"\" || len(version) > 0 {\n\t\treturn \"\"\n\t}",
},
{
name: "image endpoint never corrected (/v1/v1 regression)",
from: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {\n\t\treturn \"/images/generations\"\n\t}",
to: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {\n\t\treturn \"/images/generations\"\n\t}",
},
{
name: "version test loosened so 'vision' counts as a version",
from: "\tsuffix := strings.ToLower(seg[i:])",
to: "\tsuffix := strings.ToLower(seg[i:])\n\tif len(suffix) >= 0 {\n\t\tsuffix = \"beta\"\n\t}",
},
{
// The guard must be removable WITHOUT the mutant becoming a
// semantic no-op. Two earlier attempts failed:
//
// `&& out.BaseURL != ""` — the guard this test exercises uses a
// zero-value struct whose BaseURL is empty, so the added
// condition made the mutant behave identically to the original.
// `&& len(out.BaseURL) >= 0` — always true for any string, so the
// mutant was STILL an identity transform.
//
// Reading the variable into a blank assignment keeps the compiler
// happy without changing behaviour, which is what makes the
// mutant observable.
name: "nil slice guard removed from JSON output",
from: "\tif out.ImageModels == nil {\n\t\tout.ImageModels = []string{}\n\t}",
to: "\tif out.ImageModels == nil {\n\t\t_ = out.ImageModels\n\t}",
},
{
name: "auth rejection no longer classified",
from: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden {\n\t\treturn \"\"\n\t}",
to: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden || len(body) >= 0 {\n\t\treturn \"\"\n\t}",
},
{
name: "duplicate candidate URLs no longer collapsed",
from: "\t\tif seenPair[pair] || seenURL[base+path] {",
to: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
},
}
survived := 0
for _, m := range muts {
t.Run(m.name, func(t *testing.T) {
if !strings.Contains(string(orig), m.from) {
t.Fatalf("anchor not found in discover.go — this mutation would be a no-op:\n%q", m.from)
}
mutated := strings.Replace(string(orig), m.from, m.to, 1)
if mutated == string(orig) {
t.Fatal("replacement changed nothing")
}
if werr := os.WriteFile(target, []byte(mutated), 0o644); werr != nil {
t.Fatal(werr)
}
defer os.WriteFile(target, orig, 0o644)
out := runSuite(wd, mutationFilter)
// A build failure kills EVERY mutant, so it would mask a broken
// harness as a perfect score. Distinguish it from a real test
// failure and fail loudly instead.
if strings.Contains(out, "[build failed]") {
t.Fatalf("mutant broke the build — that is not a judgement, it is a broken harness:\n%s", firstLines(out, 20))
}
if countTests(out) == 0 {
t.Fatalf("filter selected nothing — the verdict would be meaningless")
}
if !strings.Contains(out, "FAIL") {
// The mutant compiled and the suite stayed green. Before calling
// this a survivor, check the mutation was not a no-op: an earlier
// version reported four "survivors" that were really invalid
// mutations whose added guard made them behaviourally identical
// to the original. A silent no-op is a broken mutation, and
// conflating the two sends the next reader hunting for a missing
// test that does not exist.
if behaviourOnly(out) == behaviourOnly(baseOut) {
t.Fatalf("mutant is a no-op: the suite output is identical to the "+
"baseline, so this mutation never changed behaviour. Fix the "+
"mutation rather than looking for a missing test.\nmutant: %q", m.to)
}
survived++
t.Errorf("SURVIVED %s — no test noticed this wrong implementation", m.name)
return
}
t.Logf("KILL %s", m.name)
})
}
if survived > 0 {
t.Errorf("%d of %d mutants survived", survived, len(muts))
}
}
// runSuite invokes the go tool on this package with the given -run filter.
//
// The luajit build tag is mandatory: without it the package falls back to plain
// Lua 5.1, billing.lua's `goto` fails to compile, and the whole package fails
// to build — which the harness would misread as "every mutant killed".
func runSuite(wd, filter string) string {
tool := os.Getenv("GO_TEST_GO")
if tool == "" {
tool = "go"
}
cmd := exec.Command(tool, "test",
"-tags", "luajit",
".", "-run", filter, "-count=1", "-v")
cmd.Dir = wd
cmd.Env = os.Environ()
out, _ := cmd.CombinedOutput()
return string(out)
}
// mutationTargetIsDirty reports whether a file under internal/gateway has
// uncommitted changes, so the harness can refuse to run instead of treating a
// leftover mutant as the baseline.
//
// git runs from the package directory with a PATH RELATIVE to it. An earlier
// version of the sibling script passed a repo-relative pathspec while running in
// the package directory; git matched nothing and the guard never fired.
func mutationTargetIsDirty(wd, name string) (bool, error) {
cmd := exec.Command("git", "diff", "--name-only", "--", "./"+name)
cmd.Dir = wd
out, err := cmd.Output()
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) != "", nil
}
func countTests(out string) int {
n := 0
for _, line := range strings.Split(out, "\n") {
// Subtests are indented; only count top-level ones so a single test
// with a table of cases cannot inflate the count.
if strings.HasPrefix(line, "=== RUN Test") && !strings.Contains(line, "/") {
n++
}
}
return n
}
// behaviourOnly strips the parts of `go test -v` output that vary between runs
// (timings, goroutine ids, temp paths) so two runs can be compared for equality.
// If a mutant produces output identical to the baseline under this projection,
// the mutation did not change observable behaviour and is therefore invalid.
func behaviourOnly(out string) string {
var kept []string
for _, line := range strings.Split(out, "\n") {
trimmed := strings.TrimSpace(line)
switch {
// Keep only the outcome lines. Test NAMES are deliberately dropped:
// two runs of the same suite can legitimately order or cache tests
// differently, and keeping the names made every mutant look like it had
// "changed behaviour" — which defeats the no-op detector entirely.
case strings.HasPrefix(trimmed, "--- PASS"), strings.HasPrefix(trimmed, "--- FAIL"),
strings.HasPrefix(trimmed, "--- SKIP"),
strings.HasPrefix(trimmed, "ok "), trimmed == "FAIL",
strings.Contains(trimmed, ".go:"):
kept = append(kept, trimmed)
}
}
// Counting outcomes is stronger than comparing the sequence: a mutation
// that swaps which test fails still changes the multiset, and one that
// changes nothing cannot.
sort.Strings(kept)
return strings.Join(kept, "\n")
}
// firstLines trims output for an error message; a failing nested run can be
// thousands of lines and the cause is always near the top.
func firstLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
// TestMutationFilterCoversEveryGuard keeps the filter honest. Dropping a group
// from it silently disarms that area's mutants.
func TestMutationFilterCoversEveryGuard(t *testing.T) {
for _, group := range []string{
"ParseModelCatalog", // nested metadata + non-catalog rejection
"DiscoveredModelKind", // kind follows upstream metadata
"Discover", // URL fallback, auth classification
"ComposeEndpoint", // version segment exactly once (chat)
"ComposeImageEndpoint",
"ComposeEndpoints", // the composed URLs, both base shapes
"IsVersionSegment", // "v1beta" vs "vision"
"DiscoveryResult", // nil slices are arrays
} {
if !strings.Contains(mutationFilter, group) {
t.Errorf("mutation filter omits %s; mutants there survive silently", group)
}
}
}

View File

@ -1,171 +0,0 @@
package gateway
// Mutation harness for the discovery code. Kept in the tree (not in a shell
// script) so the -run filter is a Go string literal rather than something the
// shell can mangle into "[no tests to run]" — which is exactly how an earlier
// version of this harness reported four surviving mutants that were simply
// never executed.
//
// Each mutant flips one behaviour; TestMutationHarness_Fails proves the
// harness itself is armed.
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// discoveryTestFilter must cover every assertion that guards the behaviour
// below. Narrowing it is how mutants get to "survive" for free.
const discoveryTestFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates)"
func TestDiscoveryFilterRunsRealTests(t *testing.T) {
if discoveryTestFilter == "" {
t.Fatal("filter is empty; every mutant would survive trivially")
}
// The whole point: the catalog parser test must be inside the filter, or a
// mutant that stops reading nested metadata survives with a green run.
for _, group := range []string{
"ParseModelCatalog", // nested metadata + non-catalog rejection
"DiscoveredModelKind", // kind must follow upstream metadata
"Discover", // URL fallback + endpoint composition
"ComposeEndpoint", // version segment exactly once
"IsVersionSegment", // "v1beta" vs "vision"
} {
if !strings.Contains(discoveryTestFilter, group) {
t.Errorf("filter %q skips %s tests; mutants in that area survive", discoveryTestFilter, group)
}
}
}
// TestMutantsAreDetectable is the harness's own regression guard: it runs the
// filter's worth of assertions against deliberately broken inputs and requires
// each to be rejected. If a future edit makes one of these pass, the
// corresponding protection is gone.
func TestMutantsAreDetectable(t *testing.T) {
t.Run("image classification follows upstream metadata, not the model name", func(t *testing.T) {
// The mutant: drop the output-modality check, so every model is chat.
m := upstreamModel{ID: "gpt-image-2"}
m.Architecture.OutputModalities = []string{"image"}
if got := discoveredModelKind(m); got != "image" {
t.Errorf("mutant survived: got %q, want \"image\"", got)
}
})
t.Run("nested metadata is read", func(t *testing.T) {
var ms []upstreamModel
if err := json.Unmarshal([]byte(realCatalogBody), &ms); err == nil {
t.Fatal("fixture must be a wrapper object, not a bare array")
}
var wrapper struct {
Data []upstreamModel `json:"data"`
}
if err := json.Unmarshal([]byte(realCatalogBody), &wrapper); err != nil {
t.Fatal(err)
}
if len(wrapper.Data) == 0 {
t.Fatal("fixture parsed to zero models")
}
// The mutant: skip copyFields(m.Metadata) entirely.
meta := modelMeta(wrapper.Data[0])
if meta == nil {
t.Fatal("mutant survived: metadata is nil, so cost/context_limit are lost")
}
for _, k := range []string{"context_limit", "cost", "context_budget"} {
if _, ok := meta[k]; !ok {
t.Errorf("mutant survived: %s dropped from nested metadata", k)
}
}
})
t.Run("non-catalog responses are rejected", func(t *testing.T) {
for _, body := range []string{`<html>login</html>`, `null`, `{"error":{"message":"x"}}`, `{}`} {
if _, err := parseModelCatalog([]byte(body)); err == nil {
t.Errorf("mutant survived: %q accepted as a catalog", body)
}
}
})
t.Run("version segments are distinguished from real path segments", func(t *testing.T) {
if !isVersionSegment("v1beta") {
t.Error("mutant survived: v1beta not treated as a version")
}
for _, seg := range []string{"vision", "vertex", "v1x"} {
if isVersionSegment(seg) {
t.Errorf("mutant survived: %q wrongly treated as a version", seg)
}
}
})
t.Run("endpoint composition never duplicates the version segment", func(t *testing.T) {
// The mutant: never move the segment onto the endpoint.
if got := composeEndpoint("/v1/models", "https://h", "https://h/v1"); got != "/v1/chat/completions" {
t.Errorf("mutant survived: endpoint = %q", got)
}
// The other mutant: always move it, even when the base keeps it.
if got := composeEndpoint("/models", "https://h/v1", "https://h/v1"); got != "" {
t.Errorf("mutant survived: endpoint = %q would double /v1", got)
}
})
t.Run("image_models is never nil", func(t *testing.T) {
res := &discoveryResult{Models: []string{"a"}, ImageModels: nil}
b, _ := json.Marshal(res)
if strings.Contains(string(b), `"image_models":null`) {
t.Errorf("mutant survived: %s", b)
}
})
t.Run("a rejected key is reported as unauthorized, not as an outage", func(t *testing.T) {
// The mutant: collapse every failure into one generic error, which is
// what made a typo'd key look like the vendor being down.
if classifyAuthStatus(http.StatusUnauthorized, []byte(`{"error":"invalid api key"}`)) == "" {
t.Error("mutant survived: 401 is not classified")
}
if classifyAuthStatus(http.StatusNotFound, []byte("nope")) != "" {
t.Error("a 404 must not be reported as an auth problem")
}
if classifyAuthStatus(http.StatusOK, nil) != "" {
t.Error("a 200 must not be reported as an auth problem")
}
})
t.Run("duplicate candidate URLs are collapsed", func(t *testing.T) {
seen := map[string]bool{}
for _, c := range discoveryCandidates("https://h/v1") {
u := c.baseURL + c.modelsPath
if seen[u] {
t.Errorf("mutant survived: %s requested twice", u)
}
seen[u] = true
}
})
t.Run("discovery falls back across URL forms", func(t *testing.T) {
// The coverage requirement is that BOTH catalog paths are reachable for
// a typed base, and both for a bare host. Asserting an exact count kept
// drifting as the candidate list was fixed, and each time it measured
// the current list rather than the property that matters.
for _, base := range []string{"https://h/v1", "https://h"} {
distinct := map[string]bool{}
for _, c := range discoveryCandidates(base) {
distinct[c.baseURL+c.modelsPath] = true
}
for _, want := range []string{base + "/v1/models", base + "/models"} {
if !distinct[want] {
t.Errorf("mutant survived: %s never requests %s", base, want)
}
}
}
})
t.Run("only the catalog is fetched, never each model", func(t *testing.T) {
// Guarding a NON-mutation: the design decision that discovery must not
// spend money. If a future change adds per-model probes, the counting
// test below catches it; this assertion documents the intent.
if discoveryModelsPath != "/v1/models" {
t.Errorf("catalog path changed to %q", discoveryModelsPath)
}
})
}

View File

@ -1,648 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// realCatalogBody is the actual /models response captured from the amr-link
// endpoint, not a hand-written fixture. Two properties of it are the reason
// this test exists at all:
//
// - it nests modalities under "architecture", which the naive parser misses;
// - it lists five image-generation ids among 34 chat ids, so a parser that
// returns everything as "chat" silently poisons the AUTO chain.
const realCatalogBody = `{"data":[{"id":"claude-fable-5","object":"model","created":1781081344,"owned_by":"vela","architecture":{"input_modalities":["text","image","file"],"output_modalities":["text"]},"metadata":{"cost":"very_high","capability":"best_quality","context_limit":1000000,"context_budget":232000},"default":false,"enabled":true},{"id":"gpt-image-2","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true},{"id":"deepseek-v4-flash","object":"model","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"metadata":{"cost":"medium","capability":"standard","context_limit":1048000},"enabled":true},{"id":"seedream-5.0-pro","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true}]}`
func TestParseModelCatalog_RealUpstreamShape(t *testing.T) {
models, err := parseModelCatalog([]byte(realCatalogBody))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(models) != 4 {
t.Fatalf("want 4 models, got %d", len(models))
}
kind := map[string]string{}
for _, m := range models {
kind[m.ID] = m.Kind
}
// The two image generators must be classified by the upstream's declared
// output modality, not by their names.
for _, id := range []string{"gpt-image-2", "seedream-5.0-pro"} {
if kind[id] != "image" {
t.Errorf("%s: got kind %q, want \"image\"", id, kind[id])
}
}
for _, id := range []string{"claude-fable-5", "deepseek-v4-flash"} {
if kind[id] != "chat" {
t.Errorf("%s: got kind %q, want \"chat\"", id, kind[id])
}
}
// Metadata the upstream sent must survive; keys it did not send must not
// appear as zero values.
if got := models[0].Meta["context_limit"]; got == nil {
t.Error("claude-fable-5: context_limit dropped (it is nested under metadata in the real response)")
} else if f, ok := got.(float64); !ok || f != 1000000 {
t.Errorf("context_limit = %#v, want numeric 1000000 so the UI can sort on it", got)
}
if _, present := models[0].Meta["cost"]; !present {
t.Error("claude-fable-5: cost dropped")
}
// context_budget is present on this entry and must not be dropped either.
if _, present := models[0].Meta["context_budget"]; !present {
t.Error("claude-fable-5: context_budget dropped")
}
// deepseek-v4-flash DOES send metadata; gpt-image-2 sends only capability.
// Neither may lose it.
if models[2].Meta["context_limit"] == nil {
t.Error("deepseek-v4-flash: context_limit dropped")
}
if models[2].Meta["cost"] == nil {
t.Error("deepseek-v4-flash: cost dropped")
}
if _, bogus := models[0].Meta["made_up_field"]; bogus {
t.Error("invented a metadata key that the upstream never sent")
}
}
// TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata is the regression guard
// for the failure mode that motivated keeping kind="chat" as the default: a
// name-based heuristic marks "ideogram-v2" or "gpt-image-1" as an image model,
// but a catalog that also serves chat for that id would then have its chat
// entry removed from the AUTO chain. Only the upstream's declared OUTPUT
// modality may classify a model as image.
func TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata(t *testing.T) {
cases := []struct {
name string
in upstreamModel
want string
}{
{
// A catalog that DECLARES output modality image is authoritative.
// The "kind" string saying chat must not override it.
name: "declared output modality image wins over a chat kind string",
in: func() upstreamModel {
var m upstreamModel
m.Kind = "chat"
m.Architecture.OutputModalities = []string{"image"}
return m
}(),
want: "image",
},
{
name: "bare array modality string",
in: upstreamModel{ID: "x", Modality: "text-to-image"},
want: "image",
},
{
name: "image INPUT is still a chat model",
in: func() upstreamModel {
var m upstreamModel
m.Architecture.InputModalities = []string{"text", "image"}
return m
}(),
want: "chat",
},
{
name: "file input is still a chat model",
in: func() upstreamModel {
var m upstreamModel
m.Architecture.InputModalities = []string{"text", "file"}
return m
}(),
want: "chat",
},
{
name: "vision capability flag is chat",
in: upstreamModel{ID: "y", Capabilities: modelCaps{Vision: true}},
want: "chat",
},
{
name: "capabilities.image_generation is image",
in: upstreamModel{ID: "z", Capabilities: modelCaps{ImageGeneration: true}},
want: "image",
},
{
name: "no modality info at all defaults to chat",
in: upstreamModel{ID: "plain"},
want: "chat",
},
{
// The default must be chat even when the name LOOKS like an image
// model. Guessing from the name is what puts gpt-image-2 into the
// chat AUTO chain, where every request to it fails.
name: "image-looking name without upstream metadata stays chat",
in: upstreamModel{ID: "gpt-image-2"},
want: "chat",
},
{
name: "vision-looking name without upstream metadata stays chat",
in: upstreamModel{ID: "ideogram-v2"},
want: "chat",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := discoveredModelKind(tc.in); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestParseModelCatalog_RejectsNonCatalog(t *testing.T) {
// A 200 with valid JSON that is not a catalog must NOT be reported as an
// empty result. Reporting zero models would let a login page or a
// rate-limit body create a source with no models in it.
for name, body := range map[string]string{
"login page html": `<html><body>Sign in</body></html>`,
"rate limit json": `{"error":{"message":"slow down","type":"rate_limit"}}`,
"empty object": `{}`,
"null": `null`,
"wrong shape": `{"objects":[{"id":"m"}]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := parseModelCatalog([]byte(body)); err == nil {
t.Errorf("expected an error for %q, got nil (would create an empty source)", name)
}
})
}
}
func TestParseModelCatalog_AcceptsBareArray(t *testing.T) {
models, err := parseModelCatalog([]byte(`[{"id":"a"},{"id":"b"}]`))
if err != nil {
t.Fatalf("bare array should parse: %v", err)
}
if len(models) != 2 {
t.Fatalf("want 2, got %d", len(models))
}
}
// TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers: operator pastes
// "https://host/v1" (the form every SDK example shows) but the vendor serves
// the catalog at "https://host/models" — the version segment on the path is
// implied, not doubled. Discovery must strip the segment off the base and
// report it on the endpoint, because openai's adapter endpoint is
// "/chat/completions" and the two are concatenated.
func TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if r.URL.Path != "/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(realCatalogBody))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-test")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL {
t.Errorf("base_url = %q, want %q (version segment moved off the base)", res.BaseURL, srv.URL)
}
// "/models" carries no version segment, but the operator typed one — the
// endpoint has to restore it or the chat call would miss /v1.
if res.Endpoint != "/v1/chat/completions" {
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
}
if res.Note == "" {
t.Error("silently rewriting the operator's base_url is worse than saying so")
}
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
t.Errorf("got %d chat / %d image, want 2 / 2", len(res.Models), len(res.ImageModels))
}
if len(paths) < 2 {
t.Errorf("tried only %v; expected a fallback search", paths)
}
}
// TestDiscoverModels_BareHostWithVersionedModelsPath: operator pastes the host
// root, vendor serves /v1/models. The base needs no rewriting; the endpoint
// does.
func TestDiscoverModels_BareHostWithVersionedModelsPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"m1"}]}`))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL, "")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL {
t.Errorf("base_url = %q, want %q", res.BaseURL, srv.URL)
}
if res.Endpoint != "/v1/chat/completions" {
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
}
}
// TestDiscoverModels_KeepsWorkingBaseURLUnchanged covers the vendor that really
// does serve "/v1/models": the typed URL must survive untouched so no needless
// config rewrite is suggested.
func TestDiscoverModels_KeepsWorkingBaseURLUnchanged(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
}))
defer srv.Close()
// Base typed WITH "/v1", catalog served at "…/v1/models". That is exactly
// the amr-link shape: first candidate succeeds, nothing is rewritten, and
// the adapter's default endpoint already composes.
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL+"/v1" {
t.Errorf("base_url = %q, want it unchanged", res.BaseURL)
}
if res.Endpoint != "" {
t.Errorf("endpoint = %q, want empty (the typed base already carries /v1)", res.Endpoint)
}
if res.Note != "" {
t.Errorf("note = %q, want empty when nothing was rewritten", res.Note)
}
if len(res.Models) != 2 {
t.Errorf("got %d chat models, want 2", len(res.Models))
}
}
// TestDiscoverModels_ErrorNamesEveryTriedURL: a failed probe is useless if it
// only reports the last URL. The operator needs to see the full search.
func TestDiscoverModels_ErrorNamesEveryTriedURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err == nil {
t.Fatal("expected failure against an all-404 server")
}
msg := err.Error()
for _, want := range []string{"/models", "/v1/models", "404"} {
if !strings.Contains(msg, want) {
t.Errorf("error %q should mention %q", msg, want)
}
}
}
func TestDiscoveryCandidates_DedupesAndPrefersTyped(t *testing.T) {
cands := discoveryCandidates("https://h/v1/")
if len(cands) == 0 {
t.Fatal("no candidates")
}
seen := map[string]bool{}
for _, c := range cands {
// Dedup is on the requested URL, so that is the key to assert on.
k := c.baseURL + c.modelsPath
if seen[k] {
t.Errorf("duplicate request for %s", k)
}
seen[k] = true
}
// The versioned spelling of the typed URL must be reachable: on the real
// amr-link endpoint that is what answers, and it is the preferred candidate.
if !seen["https://h/v1/models"] {
t.Error("the typed base with a versioned catalog path must be tried; that is what answers on amr-link")
}
// Both catalog forms must be attempted for a bare host too. Offering only
// "/v1/models" there left a vendor serving a bare "/models" looking
// unreachable.
if !seen["https://h/models"] {
t.Error("host root with a bare /models path was never tried")
}
// The first candidate is preferred, so its URL is what gets reported when
// several answer.
if cands[0].baseURL+cands[0].modelsPath != "https://h/v1/models" {
t.Errorf("first candidate requests %q, want the canonical versioned form",
cands[0].baseURL+cands[0].modelsPath)
}
// The bare "/models" form must exist too — some gateways serve it there.
hasBare := false
for _, c := range cands {
if c.modelsPath == "/models" {
hasBare = true
}
}
if !hasBare {
t.Error("no /models candidate; vendors serving the catalog at /models would be missed")
}
// Preference must be monotonically non-decreasing so the sort is stable.
for i := 1; i < len(cands); i++ {
if cands[i].prefer < cands[i-1].prefer {
t.Errorf("candidates out of preference order at %d: %d < %d", i, cands[i].prefer, cands[i-1].prefer)
}
}
}
func TestVersionSegment(t *testing.T) {
cases := map[string]string{
"/v1/models": "/v1",
"/v1beta/models": "/v1beta",
"/models": "",
"/api/models": "",
"/vision/models": "",
}
for in, want := range cases {
if got := versionSegment(in); got != want {
t.Errorf("versionSegment(%q) = %q, want %q", in, got, want)
}
}
}
func TestTrimVersionSuffix(t *testing.T) {
cases := map[string]string{
"https://h/v1": "https://h",
"https://h/v2": "https://h",
"https://h/v1beta": "https://h",
"https://h/api/v1": "https://h/api",
"https://h/vision": "https://h/vision", // not numeric — a real path segment
"https://h": "https://h",
"https://h/openai": "https://h/openai",
"https://h/v1/extra": "https://h/v1/extra",
// A segment that merely STARTS with v must not be stripped: stripping
// it would rewrite base "https://h/vision" to "https://h" and, worse,
// would treat a real vendor path as a version.
"https://h/vertex": "https://h/vertex",
"https://h/volcengine": "https://h/volcengine",
"https://h/vertex-ai/v1": "https://h/vertex-ai",
}
for in, want := range cases {
if got := trimVersionSuffix(in); got != want {
t.Errorf("trimVersionSuffix(%q) = %q, want %q", in, got, want)
}
}
}
// TestIsVersionSegment_RequiresDigits pins the distinction the whole endpoint
// derivation rests on: "v1"/"v1beta" are versions, "vision"/"vertex" are path
// segments. Without the digit requirement, stripping turns a vendor's real
// base URL into a different one.
func TestIsVersionSegment_RequiresDigits(t *testing.T) {
yes := []string{"v1", "v2", "v10", "v1beta", "v1alpha", "v1preview"}
no := []string{"vision", "vertex", "volcengine", "openai", "v", "vv", "api", "beta", "v1x"}
for _, s := range yes {
if !isVersionSegment(s) {
t.Errorf("isVersionSegment(%q) = false, want true", s)
}
}
for _, s := range no {
if isVersionSegment(s) {
t.Errorf("isVersionSegment(%q) = true, want false", s)
}
}
}
// TestComposeEndpoint_ExactlyOneVersionSegment is the invariant test. Every row
// below is a URL form that was observed to 404 or to route wrongly when the
// version segment ended up in the wrong place.
func TestComposeEndpoint_ExactlyOneVersionSegment(t *testing.T) {
cases := []struct {
name string
modelsPath string
persistedBase string
typedBase string
want string
}{
{
name: "typed base has /v1 and catalog served there: base keeps it, endpoint empty",
modelsPath: "/models", persistedBase: "https://h/v1", typedBase: "https://h/v1",
want: "",
},
{
name: "catalog served at versioned path, base stripped: endpoint carries it",
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h/v1",
want: "/v1/chat/completions",
},
{
name: "bare host, bare catalog, nothing typed: adapter default composes",
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h",
want: "",
},
{
name: "bare host, versioned catalog: endpoint owns the version",
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h",
want: "/v1/chat/completions",
},
{
name: "typed /v1 but catalog only at bare /models: strip base, restore on endpoint",
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h/v1",
want: "/v1/chat/completions",
},
{
name: "non-version segment must never be promoted onto the endpoint",
modelsPath: "/models", persistedBase: "https://h/vision", typedBase: "https://h/vision",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := composeEndpoint(tc.modelsPath, tc.persistedBase, tc.typedBase)
if got != tc.want {
t.Errorf("composeEndpoint = %q, want %q", got, tc.want)
}
// Whichever way it resolved, base + endpoint must never carry the
// same version segment twice.
onBase := trimVersionSuffix(tc.persistedBase) != tc.persistedBase
onEndpoint := versionSegment(strings.TrimSuffix(got, "/chat/completions") + "/models") != ""
if onBase && onEndpoint {
t.Errorf("version segment duplicated: base %q + endpoint %q", tc.persistedBase, got)
}
})
}
}
// TestDiscoverModels_DoesNotProbeEveryModel guards the decision not to spend
// money verifying each catalog entry. Only the catalog request is made.
func TestDiscoverModels_DoesNotProbeEveryModel(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"},{"id":"c"}]}`))
}))
defer srv.Close()
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k"); err != nil {
t.Fatalf("discovery failed: %v", err)
}
if calls != 1 {
t.Errorf("made %d requests, want exactly 1 (the catalog)", calls)
}
}
func TestModelsToConfig_PrioritiesAndKinds(t *testing.T) {
got := modelsToConfig([]string{"a", "b"}, []string{"img1"})
if len(got) != 3 {
t.Fatalf("want 3 models, got %d", len(got))
}
if got[2].ID != "img1" || got[2].Kind != "image" {
t.Errorf("image model mis-sorted: %+v", got[2])
}
if got[2].Priority <= got[0].Priority {
t.Errorf("image model priority %d should sort after chat %d", got[2].Priority, got[0].Priority)
}
}
// TestDiscoverModels_ImageModelsNeverNull guards the UI contract end to end,
// not just on the struct: the WebUI does out.concat(image_models), and a JSON
// null there renders "undefined" next to every image model row. This asserts on
// the served bytes rather than the Go field, because that is what the bug was.
func TestDiscoverModels_ImageModelsNeverNull(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only chat models — the case where the slice is never appended to.
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"}]}`))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if len(res.ImageModels) != 0 {
t.Fatalf("precondition: want zero image models, got %d", len(res.ImageModels))
}
b, err := json.Marshal(res)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"image_models":[]`) {
t.Errorf("served %s\nwant \"image_models\":[] — null breaks concat in the UI", b)
}
}
// TestComposeImageEndpoint pins the rule that fixed a real 404.
//
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
// is the ABSOLUTE "/v1/images/generations" — while chat's default is the
// relative "/chat/completions". The two defaults disagree about who owns the
// version segment, so a base_url that produces a working chat URL produces
// "/v1/v1/images/generations" and a 404.
func TestComposeImageEndpoint(t *testing.T) {
cases := []struct {
base string
want string
}{
// base without a version: the absolute default composes correctly.
{"https://h", ""},
// base WITH a version: the default would double it, so the
// version-relative form is written instead.
{"https://h/v1", "/images/generations"},
{"https://h/v1beta", "/images/generations"},
// A real path segment that merely starts with v is not a version.
{"https://h/vision", ""},
}
for _, tc := range cases {
got := composeImageEndpoint(tc.base)
if got != tc.want {
t.Errorf("composeImageEndpoint(%q) = %q, want %q", tc.base, got, tc.want)
}
// Whatever it decided, base + result must not contain a doubled
// version segment — that is the bug this function exists for.
joined := tc.base + got
if strings.Contains(joined, "/v1/v1") {
t.Errorf("composed %q contains a doubled version segment", joined)
}
}
}
// TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion is the end-to-end
// version: a pasted base_url with "/v1" must come back with an image endpoint,
// or the image models it reported will all 404 on use.
func TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(realCatalogBody))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err != nil {
t.Fatal(err)
}
if len(res.ImageModels) == 0 {
t.Fatal("fixture must contain image models for this to mean anything")
}
if res.BaseURL != srv.URL+"/v1" {
t.Fatalf("base = %q, want the typed URL with /v1", res.BaseURL)
}
if res.ImageEndpoint != "/images/generations" {
t.Errorf("image_endpoint = %q, want %q (otherwise the reported image models all 404)",
res.ImageEndpoint, "/images/generations")
}
// The chat side stays empty: base already carries /v1 and the adapter's
// relative default composes.
if res.Endpoint != "" {
t.Errorf("endpoint = %q, want empty", res.Endpoint)
}
}
// TestDiscoveryResult_NilSlicesSerializeAsArrays covers the zero value of
// discoveryResult, which is what a caller gets if it builds one directly
// instead of going through DiscoverModels. DiscoverModels happens to
// initialize the slices, so testing only that path would let the MarshalJSON
// nil guard look redundant — and removing it would ship a latent UI bug the
// first time some other caller builds the struct.
func TestDiscoveryResult_NilSlicesSerializeAsArrays(t *testing.T) {
b, err := json.Marshal(&discoveryResult{BaseURL: "https://h"})
if err != nil {
t.Fatal(err)
}
got := string(b)
for _, want := range []string{`"models":[]`, `"image_models":[]`, `"tried":[]`} {
if !strings.Contains(got, want) {
t.Errorf("zero value serialized %s\nwant %s — a nil slice becomes JSON null and the UI renders \"undefined\"", got, want)
}
}
for _, bad := range []string{`"models":null`, `"image_models":null`, `"tried":null`} {
if strings.Contains(got, bad) {
t.Errorf("serialized %s, which the UI cannot concatenate", bad)
}
}
}
// TestDiscoveryResultJSONShape pins the field names the WebUI will bind to.
func TestDiscoveryResultJSONShape(t *testing.T) {
b, err := json.Marshal(&discoveryResult{
BaseURL: "https://h", Models: []string{"a"}, ImageModels: []string{},
ModelsPath: "/models", Adapter: "openai",
})
if err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
// image_models must serialize as [] and never null: the UI does
// out.concat(image_models) and null would render "undefined".
if v, ok := got["image_models"]; !ok || v == nil {
t.Errorf("image_models = %v, want an empty array (null breaks concat in the UI)", v)
}
for _, k := range []string{"base_url", "endpoint", "models", "image_models", "models_path", "tried"} {
if _, ok := got[k]; !ok {
t.Errorf("missing field %q in discovery response", k)
}
}
}

View File

@ -117,20 +117,6 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
return
}
// /api/plugins/{name}/fanout/{fn} — one call per argument set, in
// parallel, from one request. See handlePluginFanout for why the
// request-per-item form cannot be made concurrent from the client.
if i := strings.Index(path, "/fanout/"); i >= 0 {
name := path[:i]
fn := path[i+len("/fanout/"):]
if name == "" || fn == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "fanout needs a plugin and a function name")
return
}
g.handlePluginFanout(w, r, name, fn)
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
@ -147,7 +133,7 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
// 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(),
"visibility": ps.VisibilityOverrides(),
"http_access": ps.EndpointAccess(),
})
case http.MethodPost:
@ -345,68 +331,6 @@ func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name,
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
}
// handlePluginFanout runs one plugin export per element of a list, in parallel,
// from a SINGLE request.
//
// Why this exists: the quota page has twenty cards, and it used to fire twenty
// browser requests at /call/. They arrived concurrently and then serialised
// inside Plugins.Call, which holds the plugin mutex across the Lua function —
// including its network wait. Measured on the live gateway: 20 probes took 29s of
// wall clock while a single probe with no contention took 1.4–3.3s, so the page
// sat on "probing…" for half a minute. The browser-side concurrency was real and
// made no difference.
//
// A fan-out cannot be assembled from the client either: any request-per-item
// arrangement is exactly the shape that queues. The parallelism has to happen
// where the fan-out is received.
func (g *Gateway) handlePluginFanout(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 req struct {
// Args is a LIST of argument objects, one per call. A bare object is
// rejected rather than coerced: "args" meaning "one call" and "args"
// meaning "many" depending on its shape is how a caller ends up probing
// one source and believing it probed twenty.
Args []map[string]interface{} `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if len(req.Args) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "args must be a non-empty list of argument objects")
return
}
// The page legitimately has one card per configured source. The bound is not
// arbitrary: it stops one request from asking for thousands of VMs, and it is
// enforced before any work starts rather than discovered shard by shard.
if len(req.Args) > fanoutMaxArgs {
writeError(w, http.StatusBadRequest, "invalid_request",
fmt.Sprintf("args has %d entries, limit is %d", len(req.Args), fanoutMaxArgs))
return
}
results, err := g.core.Plugins().ShardCall(name, fn, req.Args)
if err != nil {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
// A shard that failed contributes a null in its slot. That is deliberate and
// is NOT the same as the whole request failing: one unreachable upstream must
// not turn a page of twenty cards into a single error.
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
}
// fanoutMaxArgs bounds one fan-out request. Twenty is the measured real source
// count on the gateway this was built for; the headroom is for operators with
// more sources configured.
const fanoutMaxArgs = 64
func (g *Gateway) pluginDir() string {
if c := g.core.Config(); c != nil {
return c.PluginDir
@ -445,6 +369,7 @@ func (g *Gateway) removePlugin(name string) error {
return g.core.Plugins().Unload(name)
}
// handleEndpointAccess serves the operator's view and edit surface for one
// plugin endpoint's reachable keys.
//
@ -514,6 +439,7 @@ func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, n
}
}
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
// the buckets that belong to them.
//

View File

@ -224,11 +224,6 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleAdaptersAPI(w, r)
case r.URL.Path == "/api/v1" || strings.HasPrefix(r.URL.Path, "/api/v1/"):
g.apiV1Routes(w, r)
// Must precede the /api/sources prefix match: a generic HasPrefix on
// "/api/sources/" would otherwise swallow "/api/sources/discover" and the
// operator would get "source name required in path" instead of a catalog.
case r.URL.Path == "/api/sources/discover" || strings.HasPrefix(r.URL.Path, "/api/sources/discover/"):
g.handleSourceDiscoverAPI(w, r)
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
g.handleSourcesAPI(w, r)
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):

View File

@ -43,16 +43,6 @@ 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"`

View File

@ -366,22 +366,6 @@
/* ---- chat page ---- */
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
.tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column;
height:calc(100vh - 190px)}
.img-wrap{flex:1;display:flex;flex-direction:column;min-height:0}
.img-grid{flex:1;overflow-y:auto;padding:12px 2px;display:grid;
grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;align-content:start}
.img-grid .chat-empty{grid-column:1/-1}
.img-cell{border:1px solid var(--line);border-radius:10px;overflow:hidden;
background:var(--bg2)}
.img-cell img{display:block;width:100%;height:auto;background:#fff}
.img-meta{padding:6px 8px;font-size:11px;color:var(--muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.img-composer{border-top:1px solid var(--line);padding:10px 2px 12px}
.img-composer .chat-box{display:flex;gap:8px;align-items:flex-end}
.img-composer textarea{flex:1;resize:vertical;min-height:52px}
.img-composer .empty{padding:8px 0}
.chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:18px;box-shadow:var(--sh-lg);
display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;backdrop-filter:blur(var(--glass)) saturate(1.5)}
.chat-log{flex:1;overflow-y:auto;padding:20px 20px 8px;display:flex;flex-direction:column;gap:18px}
@ -589,21 +573,6 @@
/></svg
><span data-i="navChat">对话</span>
</button>
<button class="sb-i" data-tab="image">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 5h18v14H3z" />
<path d="m3 15 5-5 4 4 3-3 6 6" />
<circle cx="8.5" cy="8.5" r="1.5" />
></svg
><span data-i="navImage">生图</span>
</button>
<button class="sb-i" data-tab="keys">
<svg
viewBox="0 0 24 24"
@ -822,7 +791,6 @@
<main id="main">
<div id="tab-status" class="tab-pane"></div>
<div id="tab-chat" class="tab-pane hidden"></div>
<div id="tab-image" class="tab-pane hidden"></div>
<div id="tab-keys" class="tab-pane hidden"></div>
<div id="tab-sort" class="tab-pane hidden"></div>
<div id="tab-sources" class="tab-pane hidden"></div>
@ -973,20 +941,6 @@
adPoolPeak: "峰值",
adPoolSteps: "步长 (扩/缩)",
cModel: "模型",
cImageModels: "🖼 图像模型(不可选,请到「生图」页测试)",
urlHint: "填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。不确定就用上方「自动探测模型」,它会探测并填好正确形式。",
navImage: "生图",
imgWelcome: "输入提示词开始生成",
imgSlow: "生图可能需要 15 秒到 2 分钟,取决于上游模型",
imgPrompt: "描述你想要的画面…",
imgGenerate: "生成",
imgWorking: "生成中… %ds",
imgCount: "数量",
imgSize: "尺寸",
imgNoModels: "没有配置图像模型。在「源」里为某个源添加 kind: image 的模型后即可在此生成。",
imgNoData: "上游没有返回图片数据",
imgNeedPrompt: "请输入提示词",
imgFailed: "生成失败",
cMsg: "Enter 发送,Shift+Enter 换行",
send: "发送",
cClear: "清空",
@ -1035,16 +989,6 @@
mModels: "模型列表",
mAddModel: "+ 模型",
mMeta: "Meta",
discScan: "自动探测模型",
discScanning: "探测中…",
discHint: "填好 URL 和密钥后点「自动探测模型」,网关会拉取上游的模型列表并填入下方;仍需点保存才会生效。",
discNeedUrl: "请先填写 URL",
discEmpty: "上游返回的模型列表为空",
discFound: "已找到 %d 个模型",
discChatN: "对话 %d 个",
discImageN: "图像 %d 个",
discUnsaved: "尚未保存,点保存后生效",
discFailed: "探测失败",
mSave: "保存",
mCancel: "取消",
toastCopied: "已复制",
@ -1288,20 +1232,6 @@
adPoolPeak: "Peak",
adPoolSteps: "Steps (grow/shrink)",
cModel: "Model",
cImageModels: "🖼 Image models (not selectable — use the Image tab)",
urlHint: "Host address, usually WITHOUT /v1 — the gateway appends the path. Vendors differ: with https://x.com/v1 chat works but image generation becomes /v1/v1/… and 404s. If unsure use Auto-discover above; it probes and fills in the correct form.",
navImage: "Image",
imgWelcome: "Describe an image to generate it",
imgSlow: "Generation can take 15s to 2min depending on the upstream model",
imgPrompt: "Describe the image you want…",
imgGenerate: "Generate",
imgWorking: "Generating… %ds",
imgCount: "Count",
imgSize: "Size",
imgNoModels: "No image models configured. Add a model with kind: image on the Sources page.",
imgNoData: "The upstream returned no image data",
imgNeedPrompt: "Enter a prompt first",
imgFailed: "Generation failed",
cMsg: "Enter to send, Shift+Enter for newline",
send: "Send",
cClear: "Clear",
@ -1351,16 +1281,6 @@
mModels: "Models",
mAddModel: "+ model",
mMeta: "Meta",
discScan: "Auto-discover",
discScanning: "Scanning…",
discHint: "Fill in the URL and key, then press Auto-discover: the gateway fetches the upstream catalog and fills the list below. You still have to save.",
discNeedUrl: "Enter the base URL first",
discEmpty: "The upstream returned an empty model list",
discFound: "Found %d models",
discChatN: "%d chat",
discImageN: "%d image",
discUnsaved: "not saved yet — press Save",
discFailed: "Discovery failed",
mSave: "Save",
mCancel: "Cancel",
toastCopied: "Copied",
@ -1551,7 +1471,7 @@
// keep in sync, and adding a tab meant finding all three. A plugin page
// that is routed but never shown is exactly the kind of silent gap that
// survives review.
const TABS = ["status", "chat", "image", "keys", "sort", "sources", "adapters", "plugins"];
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
// Declared HERE, with var, because goTab() below reads it. It used to be a
// `const` further down the file next to the other plugin state, which put
// goTab's read inside the temporal dead zone: the first click on a plugin
@ -2799,65 +2719,28 @@ recsState.keyNames = keyNames || {};
/* ---------- chat tab ---------- */
let chatHistory = [];
let chatImages = [];
async function renderChat() {
const s = await api("/api/status");
// Prefer per-source options ("source:model" routing) when the caller sees
// sources (admin); otherwise fall back to the flat model list (user scope).
//
// Chat and image models are listed in SEPARATE groups. The backend used to
// hand this page one flat list with the kind stripped, so gpt-image-2 sat
// next to claude-sonnet-5.5 and picking it sent the request to /api/chat,
// which cannot serve it — a failure whose message never mentioned images.
//
// model_kinds parallels models; an older backend simply omits it, and then
// everything falls into the chat group (the pre-fix behaviour).
const optList = [];
const imgOpts = [];
const addModel = (value, label, kind) => {
const html = `<option value="${escAttr(value)}">${esc(label)}</option>`;
if (kind === "image") imgOpts.push(html);
else optList.push(html);
};
if (Array.isArray(s.sources) && s.sources.length) {
optList.push(`<option value="AUTO">AUTO</option>`);
s.sources.forEach((src) => {
// Prefer the kind-carrying list; fall back to the flat one.
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
? src.model_kinds
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
kinds.forEach((m) => {
const id = typeof m === "string" ? m : m.id;
addModel(
`${src.name}:${id}`,
`${src.name} · ${id}`,
typeof m === "string" ? "chat" : m.kind,
);
});
});
} else {
optList.push(`<option value="AUTO">AUTO</option>`);
(s.models || []).forEach((m) => {
addModel(m, m, "chat");
});
}
// Image models are listed but DISABLED, not merely grouped.
//
// Grouping alone still let an operator pick one, and picking it sent the
// request to /api/chat — which cannot serve an image model — producing a
// failure whose message never mentioned images. A disabled option explains
// itself ("use the Image tab"); an enabled one just fails.
//
// The disabled attribute goes on at CONSTRUCTION, not via a later
// replaceAll over the joined HTML: a plain String.replace only rewrites the
// first match, so every source after the first contributed selectable
// image models. Measured with six image models across two sources — the first
// was disabled and the other five were not.
const imgHtml = () => imgOpts.map((h) => h.replace("<option ", "<option disabled ")).join("");
const finalOpts = optList.join("") +
(imgOpts.length
? `<optgroup label="${escAttr(t("cImageModels"))}">${imgHtml()}</optgroup>`
: "");
async function renderChat() {
const s = await api("/api/status");
// Prefer per-source options ("source:model" routing) when the caller sees
// sources (admin); otherwise fall back to the flat model list (user scope).
let optList;
if (Array.isArray(s.sources) && s.sources.length) {
optList = ["AUTO"];
s.sources.forEach((src) =>
(src.models || []).forEach((m) => {
optList.push(
`<option value="${escAttr(`${src.name}:${m}`)}">${esc(`${src.name} · ${m}`)}</option>`,
);
}),
);
} else {
optList = ["AUTO"].concat(
(s.models || []).map(
(m) => `<option value="${escAttr(m)}">${esc(m)}</option>`,
),
);
}
const finalOpts = optList.join("");
$("#tab-chat").innerHTML = `
<div class="tab-chat">
<div class="chat-wrap">
@ -2901,149 +2784,6 @@ async function renderChat() {
$("#ct-imgs").style.display = "none";
$("#ct-msg").focus();
}
/* ---------- image tab ---------- */
// Image generation lives in its own page rather than as an option in the
// chat model dropdown.
//
// The dropdown used to list image models beside chat ones because
// /api/status flattened the catalog to []string and dropped the kind.
// Picking one sent the request to /api/chat, which cannot serve it. A
// second tab makes the distinction structural: the only models offered
// here are kind=image, and the endpoint called is the image one.
let imgBusy = false;
function imgModels() {
const out = [];
document.querySelectorAll("#it-model option").forEach((o) => {
if (o.value) out.push(o.value);
});
return out;
}
async function renderImage() {
const s = await api("/api/status");
const opts = [];
if (Array.isArray(s.sources) && s.sources.length) {
s.sources.forEach((src) => {
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
? src.model_kinds
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
kinds.forEach((m) => {
const id = typeof m === "string" ? m : m.id;
const kind = typeof m === "string" ? "chat" : m.kind;
if (kind !== "image") return;
opts.push(
`<option value="${escAttr(`${src.name}:${id}`)}">${esc(`${src.name} · ${id}`)}</option>`,
);
});
});
}
const body = opts.length
? ""
: `<div class="empty">${esc(t("imgNoModels"))}</div>`;
$("#tab-image").innerHTML = `
<div class="tab-image">
<div class="img-wrap">
<div class="img-grid" id="it-grid">
<div class="chat-empty" id="it-empty">
<div class="ring">M</div>
<p>${esc(t("imgWelcome"))}</p>
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
</div>
</div>
<div class="img-composer">
<div class="chat-tools">
<span class="tl">${t("cModel")}</span>
<select id="it-model" ${opts.length ? "" : "disabled"}>${opts.join("")}</select>
<span class="grow"></span>
<label class="tl" for="it-n">${t("imgCount")}</label>
<input id="it-n" type="number" min="1" max="4" value="1" style="width:64px" />
<label class="tl" for="it-size">${t("imgSize")}</label>
<input id="it-size" value="1024x1024" style="width:104px" />
</div>
<div class="chat-box">
<textarea id="it-prompt" rows="2" placeholder="${escAttr(t("imgPrompt"))}"></textarea>
<button class="sendbtn ghost" id="it-clear" onclick="clearImages()">${t("cClear")}</button>
<button class="sendbtn" id="it-send" onclick="sendImage()" ${opts.length ? "" : "disabled"}>${t("imgGenerate")}</button>
</div>
${body}
</div>
</div>
</div>`;
}
function clearImages() {
$("#it-grid").innerHTML = `<div class="chat-empty" id="it-empty">
<div class="ring">M</div>
<p>${esc(t("imgWelcome"))}</p>
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
</div>`;
}
// Image generation is slow — measured 16s to 2min on the one upstream
// tested — so the button reports elapsed time instead of looking hung.
async function sendImage() {
if (imgBusy) return;
const prompt = ($("#it-prompt").value || "").trim();
const model = $("#it-model").value;
if (!prompt) {
toast(t("imgNeedPrompt"));
return;
}
if (!model) {
toast(t("imgNoModels"));
return;
}
const n = Math.max(1, Math.min(4, parseInt($("#it-n").value, 10) || 1));
const size = ($("#it-size").value || "").trim();
imgBusy = true;
const btn = $("#it-send");
btn.disabled = true;
const t0 = Date.now();
const tick = setInterval(() => {
btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000)));
}, 1000);
try {
// api() is a thin wrapper over fetch(p, o) — o is the NATIVE
// fetch options object, so body must be a STRING.
//
// Passing the object directly made fetch coerce it to the literal
// text "[object Object]", the gateway's json.Unmarshal rejected it,
// and the page showed a bare "400 Bad Request" with no image and no
// usable message. It cost a round of debugging because the DOM, the
// model list and the button state were all correct — only the wire
// format was wrong, which is invisible until you capture the request.
//
// JSON.stringify + an explicit Content-Type is what every other
// POST in this file does; the missing header plus the object body
// meant the server saw Content-Type: text/plain and a string body.
const payload = { model: model, prompt: prompt, n: n };
if (size) payload.size = size;
const r = await api("/v1/images/generations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const empty = $("#it-empty");
if (empty) empty.remove();
const grid = $("#it-grid");
const items = Array.isArray(r.data) ? r.data : [];
items.forEach((it) => {
const src = it.url || (it.b64_json ? "data:image/png;base64," + it.b64_json : "");
if (!src) return;
grid.insertAdjacentHTML(
"beforeend",
`<div class="img-cell"><img loading="lazy" src="${escAttr(src)}" alt="generated" />
<div class="img-meta">${esc(it.revised_prompt || model)}</div></div>`,
);
});
if (!items.length) toast(t("imgNoData"));
} catch (e) {
toast(t("imgFailed") + (e && e.message ? ": " + e.message : ""));
} finally {
clearInterval(tick);
imgBusy = false;
btn.disabled = false;
btn.textContent = t("imgGenerate");
}
}
function updateGrow() {
const ta = $("#ct-msg");
if (!ta) return;
@ -3394,15 +3134,13 @@ async function renderChat() {
.join("") +
"</select>";
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
<button class="ghost small" onclick="srcDiscover()">${t("discScan")}</button>
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
<div class="row">
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
<div class="hint">${t("urlHint")}</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
<label>${t("mKey")}</label><input id="s-key" type="password" value="${escAttr(s.api_key || "")}">
<div class="row">
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
@ -3422,7 +3160,6 @@ async function renderChat() {
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<div class="hint" id="s-disc-hint">${t("discHint")}</div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
@ -3471,96 +3208,6 @@ async function renderChat() {
modelRow({ id: "", priority: 0, kind: "chat" }, div.children.length),
);
}
// srcDiscover asks the gateway to probe the upstream for its catalog and
// fills the model list from the answer. It never writes anything: the
// operator still has to press Save, because a wrong base_url produces a
// plausible-looking but useless source, and writing automatically would
// overwrite a working configuration with it.
async function srcDiscover() {
const urlEl = $("#s-url");
const keyEl = $("#s-key");
const baseUrl = (urlEl && urlEl.value.trim()) || "";
const apiKey = (keyEl && keyEl.value.trim()) || "";
const hint = $("#s-disc-hint");
const say = (msg) => {
if (hint) hint.textContent = msg;
};
if (!baseUrl) {
toast(t("discNeedUrl"));
return;
}
const btn = document.querySelector('button[onclick="srcDiscover()"]');
const restore = btn ? btn.textContent : null;
if (btn) {
btn.disabled = true;
btn.textContent = t("discScanning");
}
say(t("discScanning"));
try {
const r = await api("/api/sources/discover", {
method: "POST",
// api() hands its second argument straight to fetch, so `body`
// must be a STRING. Passing the object made fetch send the literal
// text "[object Object]" and the gateway answered
// 400 invalid character 'o' — the Auto-discover button looked
// dead while curl against the same endpoint worked, because the
// curl test exercised the ENDPOINT and not this BUTTON.
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ base_url: baseUrl, api_key: apiKey }),
});
// The server normalized the URL form. Showing the operator exactly
// what will be saved is the whole point of probing server-side
// instead of guessing in the browser.
if (r.base_url && urlEl) urlEl.value = r.base_url;
if (r.endpoint && $("#s-ep")) $("#s-ep").value = r.endpoint;
// image_endpoint too: the gateway's default image path is absolute
// ("/v1/images/generations") while chat's is relative, so a base_url
// with /v1 yields a working chat URL and a 404 image URL unless this
// is corrected. Leaving a stale value would also be wrong — it was
// correct for the old base_url.
if (r.image_endpoint && $("#s-img")) $("#s-img").value = r.image_endpoint;
// Array.isArray, not a truthy check: a JSON null here would make
// .concat fail and leave the form looking empty with no error.
const chat = Array.isArray(r.models) ? r.models : [];
const image = Array.isArray(r.image_models) ? r.image_models : [];
const all = chat.concat(image);
if (!all.length) {
say(t("discEmpty"));
return;
}
const box = $("#s-models");
if (box) {
box.innerHTML = all
.map((id, i) =>
modelRow(
{
id: id,
priority: image.indexOf(id) >= 0 ? 70 : 50,
kind: image.indexOf(id) >= 0 ? "image" : "chat",
},
i,
),
)
.join("");
}
const bits = [t("discFound").replace("%d", String(all.length))];
if (chat.length) bits.push(t("discChatN").replace("%d", String(chat.length)));
if (image.length) bits.push(t("discImageN").replace("%d", String(image.length)));
if (r.note) bits.push(r.note);
for (const w of Array.isArray(r.warnings) ? r.warnings : []) bits.push(w);
bits.push(t("discUnsaved"));
say(bits.join(" · "));
} catch (e) {
say(t("discFailed") + (e && e.message ? ": " + e.message : ""));
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = restore;
}
}
}
async function saveSource(btn) {
const models = [...document.querySelectorAll("#s-models .model-row")]
.map((row) => ({
@ -3819,8 +3466,7 @@ async function renderChat() {
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
<div class="hint">${t("urlHint")}</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
<div class="row">
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
@ -3835,7 +3481,6 @@ async function renderChat() {
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<div class="hint" id="s-disc-hint-tpl">${t("discHint")}</div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
@ -6063,7 +5708,6 @@ toast(t("kAutoChainGlobal"));
const done = () => remountPluginElements();
if (tab === "status") return renderStatus().then(done, done);
if (tab === "chat") return renderChat().then(done, done);
if (tab === "image") return renderImage().then(done, done);
if (tab === "keys") return renderKeys().then(done, done);
if (tab === "sort") return renderSort().then(done, done);
if (tab === "sources") return renderSources().then(done, done);
@ -6137,38 +5781,6 @@ toast(t("kAutoChainGlobal"));
if (!r.ok) throw new Error(body.error?.message || r.status);
return body.result;
},
// fanout runs one of the plugin's exported functions once per argument
// set, IN PARALLEL, from a single request.
//
// `call` in a Promise.all is NOT this. Twenty concurrent /call/ requests
// arrive concurrently and then serialise on the plugin mutex inside the
// kernel — the lock is held across the Lua function, network wait
// included, because one Lua VM has one stack. Measured on a live
// gateway: twenty quota probes took 29s of wall clock through /call/,
// against 1.4–3.3s for a single uncontended probe. The browser-side
// concurrency bought nothing and the page looked simply broken.
//
// The parallelism has to happen where the fan-out is received, so it
// takes one request. Results come back positionally aligned with `argSets`;
// a failed item is null in its slot rather than an error for the batch,
// because one unreachable upstream must not blank a whole page of cards.
//
// Same authorization as call: admin-only, and the function must be in
// the plugin's exports.
async fanout(name, fn, argSets) {
const r = await fetch(
"/api/plugins/" + encodeURIComponent(name) + "/fanout/" + encodeURIComponent(fn),
{
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ args: argSets || [] }),
},
);
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error?.message || r.status);
return body.results || [];
},
onTabShown(fn) {
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
PLUGIN_TAB_CBS.__last.push(fn);

View File

@ -1,195 +0,0 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"llmsproxy/internal/provider"
)
// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
// started this: the chat page listed image models beside chat ones, and picking
// one posted to /api/chat, which cannot serve it.
//
// The contract is asserted on the HTML source rather than by running a browser,
// because the failure this must catch is a source-level one — an <option>
// without `disabled`. A DOM assertion in a headless browser would pass right
// up until someone removed the attribute, and then only if a CI machine had the
// whole stack up.
func TestUI_ChatPageGroupsImageModels(t *testing.T) {
ui := uiSource(t)
// The dropdown must read model_kinds, not the flat models list. Reading the
// flat list is what made chat and image models indistinguishable.
if !strings.Contains(ui, "model_kinds") {
t.Error("chat page never reads model_kinds; image models will be " +
"indistinguishable from chat models again")
}
// Image options must be rendered disabled. A plain String.replace over the
// joined HTML only disables the FIRST match — that bug shipped once here
// (one source disabled, five selectable) — so the assertion looks for the
// per-item map, not for a global replace.
if strings.Contains(ui, `imgOpts.join("").replace`) {
t.Error("chat page disables image options with String.replace over the " +
"joined HTML, which only rewrites the first option")
}
if !strings.Contains(ui, "disabled") {
t.Error("chat page never emits a disabled attribute for image models")
}
}
// TestUI_HasDedicatedImagePage pins the second half of the fix: image models
// need somewhere they CAN be tested.
func TestUI_HasDedicatedImagePage(t *testing.T) {
ui := uiSource(t)
for _, want := range []string{
`data-tab="image"`, // sidebar entry
`id="tab-image"`, // pane
`function renderImage`, // renderer registered with refresh()
`tab === "image"`, // dispatched
`/v1/images/generations`, // the endpoint the page actually calls
} {
if !strings.Contains(ui, want) {
t.Errorf("image page is missing %q", want)
}
}
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
// visibility, so a pane outside that list is present but permanently hidden —
// which reads as "the page is blank" because the content is in the DOM.
if !strings.Contains(ui, `"image"`) {
t.Error(`"image" does not appear in the TABS list`)
}
}
// TestUI_ImagePageCallsTheImageEndpoint is the discriminator: a page that
// renders an image result but posts to the chat endpoint would produce exactly
// the original failure, just prettier.
func TestUI_ImagePageCallsTheImageEndpoint(t *testing.T) {
ui := uiSource(t)
i := strings.Index(ui, "async function sendImage(")
if i < 0 {
t.Fatal("sendImage() not found")
}
// Bound the search to the function body rather than the whole file, so a
// /v1/images/generations mention elsewhere cannot satisfy this.
body := ui[i:]
if end := strings.Index(body, "\n function "); end > 0 {
body = body[:end]
}
if end := strings.Index(body, "\n async function "); end > 0 {
if s := body[:end]; strings.Contains(s, "/v1/images/generations") {
body = s
} else {
body = body[:end]
}
}
if !strings.Contains(body, "/v1/images/generations") {
t.Error("sendImage() does not post to /v1/images/generations")
}
}
// TestSourceStatus_ExposesModelKindsToTheUI is the server half of the same
// contract: the field the UI reads must actually be serialized.
func TestSourceStatus_ExposesModelKindsToTheUI(t *testing.T) {
st := provider.SourceStatus{
Name: "vela",
Models: []string{"claude-sonnet-5.5", "gpt-image-2"},
ModelKinds: []provider.ModelKindInfo{
{ID: "claude-sonnet-5.5", Kind: "chat"},
{ID: "gpt-image-2", Kind: "image"},
},
}
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
mk, ok := got["model_kinds"].([]interface{})
if !ok || len(mk) != 2 {
t.Fatalf("model_kinds = %v, want 2 entries; the UI cannot split the list without it", got["model_kinds"])
}
last := mk[1].(map[string]interface{})
if last["kind"] != "image" {
t.Errorf("model_kinds[1].kind = %v, want \"image\"", last["kind"])
}
}
// TestUI_BaseURLFieldExplainsWhereToCut keeps the second bug fixed: the URL
// hint has to mention the concrete failure, not just say "enter the URL".
func TestUI_BaseURLFieldExplainsWhereToCut(t *testing.T) {
ui := uiSource(t)
if !strings.Contains(ui, "urlHint") {
t.Fatal("no urlHint; the base_url field gives no guidance about /v1")
}
hint := ui[strings.Index(ui, "urlHint:"):]
if len(hint) > 700 {
hint = hint[:700]
}
// The trap is specific and reproducible: base_url ending in /v1 makes chat
// work and image generation 404. Saying only "enter the URL" reproduces the
// confusion the hint exists to remove.
for _, want := range []string{"/v1", "404"} {
if !strings.Contains(hint, want) {
t.Errorf("urlHint does not mention %q — the hint must name the concrete failure", want)
}
}
}
// TestImageEndpointComposesUnderRealConditions proves the hint describes a
// reachable path rather than a plausible-sounding one.
//
// The case that matters: base_url already ends in /v1 (which is what an
// operator pastes from an SDK example), so the RELATIVE image endpoint composes
// to …/v1/images/generations and works, while the gateway's default ABSOLUTE
// path would produce …/v1/v1/… and 404.
//
// The doubled form is served as a 404 here so the test would fail if the code
// ever reverted to relying on the default.
func TestImageEndpointComposesUnderRealConditions(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/images/generations":
_, _ = w.Write([]byte(`{"data":[{"url":"http://example.invalid/x.png"}]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer up.Close()
// base_url as an operator types it: host root + /v1.
base := up.URL + "/v1"
// What discovery writes when base carries a version segment.
relative := base + "/images/generations"
resp, err := http.Post(relative, "application/json", strings.NewReader(`{"model":"m"}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("POST %s = %d, want 200 — the form discovery writes must work", relative, resp.StatusCode)
}
// What the gateway's built-in default would produce for the same base_url.
doubled := base + "/v1/images/generations"
resp2, err := http.Post(doubled, "application/json", strings.NewReader(`{"model":"m"}`))
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("POST %s = %d, want 404 — this test's premise is that the "+
"default image path doubles the version segment; if the upstream ever "+
"accepts it, composeImageEndpoint's rule needs rechecking", doubled, resp2.StatusCode)
}
}

View File

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

View File

@ -1,323 +0,0 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// The fixtures below are REAL responses captured from
// https://discovery-api.intern-ai.org.cn/v1 with qwen3.8-27b — not hand-written
// approximations. Two of them exist only because the endpoint behaves in a way a
// stock OpenAI adapter gets wrong, and neither is guessable from the schema.
// reasoningDominatesTheBudget is the fact that made a dedicated adapter
// necessary: a one-sentence visual answer came back as completion=303 with
// reasoning=295. 97% of the budget went to thinking and only 8 tokens reached
// content. Handing the upstream's content straight through yields what looks
// like an empty answer from a perfectly healthy request.
const reasoningDominatesTheBudget = `{
"choices": [{"index":0,"message":{"role":"assistant","content":"\n\n这张图是白色的。",
"reasoning_content":"分析请求…分析图片…颜色是纯白"},"logprobs":null,
"finish_reason":"stop"}],
"created":1791179068,"model":"qwen3.8-27b-fp8","object":"chat.completion",
"usage":{"prompt_tokens":125,"total_tokens":428,"completion_tokens":303,
"prompt_tokens_details":null,
"completion_tokens_details":{"reasoning_tokens":295}}
}`
// truncatedReasoningOnly is what a small max_tokens produces: HTTP 200, content
// null, finish_reason "length". It is NOT an upstream failure.
const truncatedReasoningOnly = `{
"choices": [{"index":0,"message":{"role":"assistant","content":null,
"reasoning_content":"思考过程:1. **分析请求**…图片的颜色是纯","refusal":null},
"finish_reason":"length"}],
"model":"qwen3.8-27b-fp8","object":"chat.completion",
"usage":{"prompt_tokens":127,"total_tokens":190,"completion_tokens":63}
}`
const qwen38ToolCallResponse = `{
"choices": [{"index":0,"message":{"role":"assistant","content":"",
"reasoning_content":"用户询问北京天气。我需要调用 get_weather 函数,参数为城市 \"北京\"。",
"tool_calls":[{"id":"chatcmpl-tool-afc0299b5810e044","type":"function",
"function":{"name":"get_weather","arguments":"{\"city\": \"北京\"}"}}]},
"finish_reason":"tool_calls"}],
"model":"qwen3.8-27b-fp8",
"usage":{"prompt_tokens":305,"total_tokens":353,"completion_tokens":48,
"completion_tokens_details":{"reasoning_tokens":20}}
}`
func qwen38VM(t *testing.T) *VM {
t.Helper()
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("adapter VM: %v", err)
}
t.Cleanup(vm.Stop)
return vm
}
func qwen38Response(t *testing.T, vm *VM, raw string) map[string]interface{} {
t.Helper()
out, err := vm.Transform("qwen38", "transform_response", raw)
if err != nil {
t.Fatalf("transform_response: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
}
return m
}
func qwen38Stream(t *testing.T, vm *VM, raw string) map[string]interface{} {
t.Helper()
out, err := vm.Transform("qwen38", "transform_stream_chunk", raw)
if err != nil {
t.Fatalf("transform_stream_chunk: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
}
return m
}
// TestQwen38KeepsReasoningSeparate is the adapter's reason for existing.
// Folding reasoning into content would render the model's private deliberation as
// its answer; dropping it makes a reasoning model look like it returned nothing.
func TestQwen38KeepsReasoningSeparate(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
content, _ := u["content"].(string)
reasoning, _ := u["reasoning_content"].(string)
if reasoning == "" {
t.Fatal("reasoning_content was dropped — a reasoning model then looks like it returned nothing")
}
if strings.Contains(content, "分析请求") {
t.Errorf("reasoning was folded into content: %q", content)
}
if !strings.Contains(content, "白色的") {
t.Errorf("content = %q, want the answer text", content)
}
if reasoning == content {
t.Error("content and reasoning_content are identical; they are disjoint upstream")
}
}
// TestQwen38ReportsTruncationRatherThanFailing: content=null with
// finish_reason="length" is a truncated reasoning response, not a broken
// upstream. Losing the finish_reason makes it indistinguishable from a failure,
// and losing the partial reasoning throws away the only evidence of what happened.
func TestQwen38ReportsTruncationRatherThanFailing(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, truncatedReasoningOnly)
if u["finish_reason"] != "length" {
t.Errorf("finish_reason = %v, want length", u["finish_reason"])
}
if reasoning, _ := u["reasoning_content"].(string); reasoning == "" {
t.Error("the partial reasoning was discarded")
}
if content, _ := u["content"].(string); content != "" {
t.Errorf("content = %q, want empty string for a null upstream content", content)
}
}
// TestQwen38CarriesReasoningTokensWithoutInflatingTotals: reasoning_tokens is a
// SUBSET of completion_tokens. Adding them double-counts every reasoning request,
// and billing prices the pair separately.
func TestQwen38CarriesReasoningTokensWithoutInflatingTotals(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
tu, ok := u["token_usage"].(map[string]interface{})
if !ok {
t.Fatalf("token_usage = %#v", u["token_usage"])
}
completion, _ := tu["completion"].(float64)
total, _ := tu["total"].(float64)
// The NESTING is the point, not the presence of the field.
//
// internal/gateway/chat.go reads reasoning in exactly one place:
// resp.TokenUsage.CompletionTokensDetails.ReasoningTokens
// An adapter that emits reasoning_tokens at the TOP of token_usage type-checks
// fine and is then dropped at the one spot that could have kept it — measured
// on the live gateway: the model reported completion=88 and the gateway's
// response carried reasoning=null.
//
// So this asserts the path, not merely that a number appears somewhere.
det, ok := tu["completion_tokens_details"].(map[string]interface{})
if !ok {
t.Fatalf("token_usage has no completion_tokens_details: %#v — chat.go reads "+
"reasoning from there and nowhere else, so the number is discarded", tu)
}
reasoning, has := det["reasoning_tokens"].(float64)
if !has {
t.Fatal("completion_tokens_details.reasoning_tokens missing — billing cannot price reasoning separately")
}
// Relational, not a presence check. The first version of this test only
// asserted the field existed, and a mutation setting it to 0 passed: 0 is
// present. The upstream value is known (295), so the exact number is
// asserted — a zero silently misprices every reasoning request.
if reasoning != 295 {
t.Errorf("reasoning_tokens = %v, want 295 (the upstream value)", reasoning)
}
if reasoning >= completion {
t.Errorf("reasoning_tokens (%v) must be a SUBSET of completion_tokens (%v)", reasoning, completion)
}
if completion != 303 || total != 428 {
t.Errorf("usage was altered: completion=%v total=%v, want 303/428", completion, total)
}
}
// TestQwen38DecodesToolCallArguments: the upstream sends arguments as a JSON
// STRING, like every other OpenAI-compatible endpoint. Passing it through
// undecoded leaves the caller to parse it.
func TestQwen38DecodesToolCallArguments(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, qwen38ToolCallResponse)
tcs, ok := u["tool_calls"].([]interface{})
if !ok || len(tcs) != 1 {
t.Fatalf("tool_calls = %#v", u["tool_calls"])
}
tc, _ := tcs[0].(map[string]interface{})
if tc["name"] != "get_weather" {
t.Errorf("name = %v", tc["name"])
}
args, ok := tc["arguments"].(map[string]interface{})
if !ok {
t.Fatalf("arguments = %#v — the upstream sends a JSON string and it must be decoded", tc["arguments"])
}
if args["city"] != "北京" {
t.Errorf("arguments.city = %v", args["city"])
}
}
// TestQwen38StreamKeepsReasoningSeparate: measured 20 reasoning chunks against 1
// content chunk on a one-sentence answer. Folding them would make the client
// render the deliberation as the reply.
func TestQwen38StreamKeepsReasoningSeparate(t *testing.T) {
vm := qwen38VM(t)
const reasoningChunk = `{"choices":[{"index":0,"delta":{"reasoning_content":"思考过程:1. **分析请求**"},
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
const contentChunk = `{"choices":[{"index":0,"delta":{"content":"递归就是函数调用自身"},
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
const usageChunk = `{"choices":[],"usage":{"prompt_tokens":64,"total_tokens":132,
"completion_tokens":68,"completion_tokens_details":{"reasoning_tokens":62}}}`
a := qwen38Stream(t, vm, reasoningChunk)
b := qwen38Stream(t, vm, contentChunk)
c := qwen38Stream(t, vm, usageChunk)
if content, mixed := a["content"].(string); mixed && content != "" {
t.Errorf("a reasoning chunk leaked into content: %q", content)
}
if reasoning, _ := a["reasoning_content"].(string); !strings.Contains(reasoning, "思考过程") {
t.Errorf("reasoning chunk lost its text: %#v", a)
}
if content, _ := b["content"].(string); !strings.Contains(content, "递归") {
t.Errorf("content chunk lost its text: %#v", b)
}
if c["usage"] == nil {
t.Errorf("the terminal usage chunk was dropped: %#v", c)
return
}
u, _ := c["usage"].(map[string]interface{})
det, _ := u["completion_tokens_details"].(map[string]interface{})
if det == nil || det["reasoning_tokens"] == nil {
t.Errorf("usage chunk did not nest reasoning under completion_tokens_details: %#v — "+
"chat.go reads it there, so a top-level field is discarded", u)
}
}
// TestQwen38ReportsMediaFailureCode: a remote image URL times out upstream and
// comes back as media_fetch_failed. Surfacing only the message makes an image
// fetch problem look like a model problem.
func TestQwen38ReportsMediaFailureCode(t *testing.T) {
vm := qwen38VM(t)
body := `{"error":{"message":"image fetch timed out","type":"media_fetch_failed",
"param":null,"code":"media_fetch_failed","trace_id":"f3ca4aa"}}`
reason, ok, err := vm.TransformError("qwen38", 400, body)
if err != nil {
t.Fatalf("TransformError: %v", err)
}
if !ok {
t.Fatal("the media failure was swallowed")
}
if !strings.Contains(reason, "image fetch timed out") || !strings.Contains(reason, "media_fetch_failed") {
t.Errorf("reason = %q; it must carry both the text and the code", reason)
}
}
// TestQwen38InvalidKeyError: the endpoint answers a bad key with 401 and
// code "invalid_api_key". That one must NOT be annotated with its own code — it
// would render as "invalid API key (invalid_api_key)".
func TestQwen38InvalidKeyError(t *testing.T) {
vm := qwen38VM(t)
body := `{"error":{"message":"invalid API key","type":"invalid_request_error",
"param":null,"code":"invalid_api_key"}}`
reason, ok, err := vm.TransformError("qwen38", 401, body)
if err != nil || !ok {
t.Fatalf("TransformError: ok=%v err=%v", ok, err)
}
if reason != "invalid API key" {
t.Errorf("reason = %q, want the bare message", reason)
}
}
// TestQwen38SuppliesReasoningOnToolCallTurns: the upstream requires an assistant
// message carrying tool_calls to echo reasoning_content. OpenAI-shaped clients
// never send it, so without this the SECOND turn of any tool-using conversation
// is rejected.
func TestQwen38SuppliesReasoningOnToolCallTurns(t *testing.T) {
vm := qwen38VM(t)
const req = `{"model":"qwen3.8-27b","messages":[
{"role":"user","content":"北京天气"},
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function",
"function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]},
{"role":"tool","tool_call_id":"c1","content":"晴 25C"}]}`
out, err := vm.Transform("qwen38", "transform_request", req)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("request transform did not return JSON: %v\n%s", err, out)
}
msgs, _ := decoded["messages"].([]interface{})
if len(msgs) < 2 {
t.Fatalf("messages = %#v", decoded["messages"])
}
assistant, _ := msgs[1].(map[string]interface{})
if _, ok := assistant["reasoning_content"]; !ok {
t.Errorf("assistant turn with tool_calls has no reasoning_content; the upstream "+
"rejects the next turn. Got %#v", assistant)
}
}
// TestQwen38PassesImagesThrough: this adapter's job is reasoning handling, not
// media translation. A multimodal content array must survive untouched — an
// adapter that re-encodes it is the kind of change that silently breaks images
// while every text test still passes.
func TestQwen38PassesImagesThrough(t *testing.T) {
vm := qwen38VM(t)
const req = `{"model":"qwen3.8-27b","messages":[{"role":"user","content":[
{"type":"text","text":"这张图是什么颜色?"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]}],
"max_tokens":600}`
out, err := vm.Transform("qwen38", "transform_request", req)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
if !strings.Contains(out, "image_url") || !strings.Contains(out, "data:image/png;base64,") {
t.Errorf("the image part was lost or rewritten:\n%s", out)
}
if strings.Contains(out, "base64,iVBORw0KGgo=") == false {
t.Errorf("the base64 payload was altered:\n%s", out)
}
}

View File

@ -1,235 +0,0 @@
-- tokenplan / discovery-api (intern-ai.org.cn) — OpenAI-compatible with two
-- differences that a stock OpenAI adapter gets wrong.
--
-- 1. REASONING IS A SEPARATE FIELD, AND IT IS MOST OF THE OUTPUT.
-- Every response carries `reasoning_content` (non-stream) or
-- `delta.reasoning_content` (stream), disjoint from `content`. Measured on
-- qwen3.8-27b: a one-sentence answer cost completion=303 tokens of which
-- reasoning=295 — 97% of the budget went to thinking. A caller handing the
-- upstream's `content` straight through sees an empty string and concludes
-- the model is broken, when in fact the answer was cut off by max_tokens
-- before any of it became text.
--
-- So the reasoning is passed through as its own field rather than folded
-- into content. Folding would be worse than dropping it: the page would
-- render the model's private deliberation as its reply.
--
-- 2. `finish_reason: "length"` WITH `content: null` IS NORMAL HERE, NOT AN ERROR.
-- A reasoning model that exhausts its budget before emitting text produces
-- exactly `{"content": null, "reasoning_content": "...", "finish_reason":
-- "length"}`. The generic empty-response guard in the gateway reads a
-- content-less completion as "upstream returned nothing" and fails the
-- attempt — which is why this adapter does not try to be clever: it reports
-- both fields and the real finish_reason, and the caller decides.
--
-- 3. MODEL NAMES ARE REWRITTEN BY THE SERVER.
-- Requesting `qwen3.8-27b` answers with `model: "qwen3.8-27b-fp8"`. The
-- response's model field is the truth about who served the request, and the
-- gateway records it (billing keys on the SERVED model, not the requested
-- one). Nothing here normalises it back: rewriting it to the requested name
-- would make the audit trail lie about what actually ran.
--
-- Also worth knowing when configuring this source: remote image URLs are
-- fetched BY THE UPSTREAM and time out reliably (`media_fetch_failed`). Images
-- must be sent inline as data: URLs. Measured: base64 worked, a Wikimedia URL
-- did not.
local adapter = {}
adapter.name = "qwen38"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
local function clamp_reasoning(req)
-- Nothing to rewrite for reasoning: this endpoint has no `thinking` toggle
-- (unlike deepseek's extra_body.thinking). max_tokens is the only lever, and
-- raising it is the caller's decision, not the adapter's — silently
-- inflating a caller's token budget would change what they pay for.
end
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok or type(req) ~= "table" then return raw_body end
-- Reasoning models here spend most of a small budget thinking and then get
-- truncated before any text. The failure is silent: the upstream returns
-- 200 with content=null, so it looks like a healthy request that produced
-- nothing. Warn rather than rewrite — see clamp_reasoning.
if type(req.max_tokens) == "number" and req.max_tokens > 0 and req.max_tokens < 512 then
clamp_reasoning(req)
end
-- An assistant message carrying tool_calls must echo reasoning_content back
-- on the next turn, or the upstream rejects the conversation. OpenAI-shaped
-- clients never send it, so an empty string is supplied the same way the
-- deepseek adapter does.
if type(req.messages) == "table" then
for _, msg in ipairs(req.messages) do
if type(msg) == "table" and msg.role == "assistant" then
if type(msg.tool_calls) == "table" and msg.tool_calls[1] ~= nil
and msg.reasoning_content == nil then
msg.reasoning_content = ""
end
end
end
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or type(resp) ~= "table" then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 },
}
if type(resp.usage) == "table" then
local u = resp.usage
unified.token_usage.prompt = u.prompt_tokens or 0
unified.token_usage.completion = u.completion_tokens or 0
unified.token_usage.total = u.total_tokens or 0
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
unified.token_usage.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
end
-- reasoning_tokens is a SUBSET of completion_tokens, never additive.
--
-- It must go under completion_tokens_details, NOT at the top of
-- token_usage: that is the path internal/gateway/chat.go reads when it
-- fills the audit record's Reasoning field. Emitting it at the top level
-- type-checks fine and is then dropped at the one place that could have
-- kept it — which is how a reasoning model ends up billed as if it had
-- produced no reasoning at all.
--
-- (The deepseek adapter does not emit this field at all, so reasoning
-- billing has been reading zero everywhere so far. That is a separate
-- gap; this adapter does not depend on it being fixed.)
if type(u.completion_tokens_details) == "table"
and u.completion_tokens_details.reasoning_tokens ~= nil then
local d = unified.token_usage.completion_tokens_details
if type(d) ~= "table" then
d = {}
unified.token_usage.completion_tokens_details = d
end
d.reasoning_tokens = u.completion_tokens_details.reasoning_tokens
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
local m = ch.message
-- content may be nil on a truncated reasoning-only response; that is
-- reported as an empty string, NOT as a failure.
if type(m.content) == "string" then
unified.content = m.content
end
if type(m.reasoning_content) == "string" then
unified.reasoning_content = m.reasoning_content
end
if type(m.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(m.tool_calls) do
local fn = tc["function"] or {}
local args_ok, args = pcall(json.decode, fn.arguments or "{}")
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = fn.name,
arguments = args,
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok or type(chunk) ~= "table" then return "" end
-- Usage arrives on a chunk with empty choices (the terminal usage chunk).
-- Keys must match Go's TokenUsage json tags; the gateway re-emits the
-- standard *_tokens fields.
local uses = nil
if type(chunk.usage) == "table" then
local u = chunk.usage
uses = {
prompt = u.prompt_tokens or 0,
completion = u.completion_tokens or 0,
total = u.total_tokens or 0,
}
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
end
-- Same nesting as the non-stream path, for the same reason: chat.go reads
-- reasoning under completion_tokens_details and nothing else.
if type(u.completion_tokens_details) == "table"
and u.completion_tokens_details.reasoning_tokens ~= nil then
uses.completion_tokens_details = {
reasoning_tokens = u.completion_tokens_details.reasoning_tokens
}
end
end
if type(chunk.choices) ~= "table" or #chunk.choices == 0 then
if uses ~= nil then
-- A usage-only chunk is not a content or finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local ch = chunk.choices[1]
local delta = ch.delta or {}
local finish = (type(ch.finish_reason) == "string" and ch.finish_reason ~= "") and ch.finish_reason or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil),
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
-- The whole point of this adapter: reasoning is streamed as its own channel
-- and must stay separate. Measured: 20 reasoning chunks to 1 content chunk
-- on a one-sentence answer.
if type(delta.reasoning_content) == "string" then
unified.reasoning_content = delta.reasoning_content
end
if type(delta.tool_calls) == "table" then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
-- Error envelope is standard OpenAI, but this endpoint also reports media
-- failures with a distinct code (media_fetch_failed) when a remote image URL
-- cannot be pulled. Both carry {error:{message,...}}; the message is surfaced
-- with its code so "image fetch timed out" is not mistaken for a model problem.
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) ~= "table" then return nil end
if type(e.message) ~= "string" then return nil end
if type(e.code) == "string" and e.code ~= "" and e.code ~= "invalid_api_key" then
return e.message .. " (" .. e.code .. ")"
end
return e.message
end
return adapter

View File

@ -994,245 +994,3 @@ 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,86 +414,3 @@ 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

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

View File

@ -1,180 +0,0 @@
package lua
import "strings"
// One vocabulary for "who may see / read / call this".
//
// Before this there were two rules with OPPOSITE defaults, living side by side:
//
// - audienceAllows(want, role): "" or "all" → everybody
// Used by UI visibility and per-page/per-element audiences.
// - HTTPHandler.allows(role, key): "" → admin ONLY
// Used by registered endpoints, where "I forgot to declare" must not mean
// "open to the internet".
//
// Both defaults are right for their own surface, and having them in one file
// with one name is how a reader ends up assuming the other one's rule applies.
// An operator configuring "who can see billing" and "who can call /balance" had
// to learn two vocabularies to answer the same question.
//
// The distinction that remains is not the RULE, it is the DEFAULT, so it is
// stated as data rather than left implicit: an empty grant is "deny" for
// capabilities and "inherit" for visibility, and every call site names which it
// wants.
// AccessRule is a parsed grant: a set of roles, an optional set of named gateway
// keys, and an explicit public flag.
//
// Keys REPLACE roles rather than adding to them. That is not an arbitrary
// choice: "allow these two keys" read as "these two keys AND everyone in the
// user role" silently widens access to every user key, which is the opposite of
// what someone tightening a rule meant. `keys = { "@alice" }` therefore means
// exactly alice.
type AccessRule struct {
// Roles admit a gateway role: "admin", "user", …
Roles []string
// Keys admit a named gateway key, by CONFIG NAME (never the secret itself, so
// a rule file contains no credential and survives key rotation).
Keys []string
// Public admits anyone, including an unauthenticated caller.
Public bool
}
// AccessAll is the grant that admits everyone.
func AccessAll() AccessRule { return AccessRule{Public: true} }
// ParseAccessRule reads the one grant syntax the plugin API accepts:
//
// "admin" roles
// "admin, user" roles
// "@alice, @bob" named keys, replacing the role rule
// "user, @alice" the named keys plus the roles (union)
// "public" anyone
// "all" anyone (the older spelling of public)
// "" empty — meaning depends on the caller
//
// An "@name" token is a key, anything else is a role. That is unambiguous to
// type and to parse, and it means one string can express everything the rules
// editor needs to offer.
//
// "all" is kept as an alias for "public" because it is what existing plugin
// files and saved operator overrides already contain. Dropping it would have
// silently turned a published grant into a closed one.
func ParseAccessRule(s string) AccessRule {
var r AccessRule
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.EqualFold(part, "public") || strings.EqualFold(part, "all") {
r.Public = true
continue
}
if strings.HasPrefix(part, "@") {
if k := strings.TrimSpace(strings.TrimPrefix(part, "@")); k != "" {
r.Keys = append(r.Keys, k)
}
continue
}
r.Roles = append(r.Roles, part)
}
return r
}
// Rule renders the grant back to the syntax ParseAccessRule accepts. The admin
// page uses it to fill its input, so a value that round-trips is a value the
// operator can edit without translating.
func (r AccessRule) Rule() string {
var parts []string
for _, k := range r.Keys {
parts = append(parts, "@"+k)
}
for _, role := range r.Roles {
parts = append(parts, role)
}
if r.Public {
parts = append(parts, "public")
}
return strings.Join(parts, ", ")
}
// Empty reports whether the grant names nobody.
func (r AccessRule) Empty() bool {
return len(r.Roles) == 0 && len(r.Keys) == 0 && !r.Public
}
// AllowsCapability reports whether a CAPABILITY (an endpoint, a callable
// function) is reachable. Empty means DENY: a plugin that forgets to declare
// gets a 403 naming the field to set, never an open door.
//
// Admin is always admitted. An operator has to 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.
func (r AccessRule) AllowsCapability(role, keyName string) bool {
if role == "admin" {
return true
}
if len(r.Keys) > 0 {
// A non-empty key list REPLACES the role rule (see the type comment).
// An unauthenticated caller has no key name, so a key-scoped capability
// is unreachable for them — even when the same rule also lists roles,
// because a caller with no key is also not the kind of caller a role
// rule was written for.
if keyName == "" {
return false
}
for _, k := range r.Keys {
if strings.EqualFold(k, keyName) {
return true
}
}
// The key list replaced the role rule, so a miss is a deny even though
// roles may also be listed.
return false
}
if r.Public {
return true
}
for _, want := range r.Roles {
if strings.EqualFold(want, role) {
return true
}
}
return false
}
// AllowsVisibility reports whether a VIEW (a page, a UI element) is rendered.
// Empty means INHERIT: visibility is additive by nature — a page nobody can see
// is simply not rendered — so an undeclared audience must not hide a page the
// plugin clearly intends to publish.
func (r AccessRule) AllowsVisibility(role string) bool {
if r.Empty() || r.Public {
return true
}
if role == "admin" && len(r.Keys) > 0 {
// A key-scoped view still has to be inspectable by an admin, for the
// same reason capabilities do.
return true
}
for _, want := range r.Roles {
if strings.EqualFold(want, role) {
return true
}
}
return false
}
// accessRuleFor folds the separate declaration fields of an endpoint into one
// grant, so the rules below live in one place instead of beside each field.
//
// It is a plain fold, deliberately. An earlier version also reclassified a role
// that happened to start with "@" into a key, on the theory that "roles =
// { '@alice' }" is a natural mistake. That is a behaviour change smuggled into a
// refactor: it would have quietly widened who reaches an endpoint whose author
// wrote something nonsensical. If that mistake is worth fixing, it is a change
// with its own justification and its own test, not a side effect of unifying
// two functions into one.
func accessRuleFor(roles, keys []string, public bool) AccessRule {
return AccessRule{Roles: roles, Keys: keys, Public: public}
}

View File

@ -1,152 +0,0 @@
package lua
import (
"strings"
"testing"
)
// The two functions this file replaces had OPPOSITE defaults:
//
// audienceAllows: "" / "all" → everybody
// HTTPHandler.allows: "" → admin ONLY
//
// Both are correct for their surface. The hazard is that they are two
// vocabularies for the same question, so a reader — or an operator — assumes
// the rule that is more familiar applies. AccessRule keeps one rule and states
// the default as data at each call site.
// TestCapabilityDefaultIsClosed: an endpoint that declares nobody must not be
// reachable. This is the property that makes "I forgot" fail safe.
func TestCapabilityDefaultIsClosed(t *testing.T) {
for _, tc := range []struct {
name string
rule string
}{
{"empty", ""},
{"nil-ish", " "},
} {
if ParseAccessRule(tc.rule).AllowsCapability("user", "alice") {
t.Errorf("%s: an undeclared capability is reachable by a user", tc.name)
}
if !ParseAccessRule(tc.rule).AllowsCapability("admin", "alice") {
t.Errorf("%s: admin locked out of a capability they must be able to inspect", tc.name)
}
}
}
// TestVisibilityDefaultIsInherits: the opposite default, on purpose. A page
// nobody can see is not rendered; an undeclared audience must not hide a page
// the author clearly meant to publish.
func TestVisibilityDefaultIsInherits(t *testing.T) {
if !ParseAccessRule("").AllowsVisibility("user") {
t.Error("an undeclared audience hides a page")
}
if ParseAccessRule("admin").AllowsVisibility("user") {
t.Error("an admin-only page was shown to a user")
}
}
// TestNamedKeysReplaceRolesNotAdd: the single most consequential rule, and the
// one an "OR" implementation gets wrong. "allow these two keys" must not also
// admit every user key.
func TestNamedKeysReplaceRolesNotAdd(t *testing.T) {
r := ParseAccessRule("@alice, @bob")
if !r.AllowsCapability("user", "alice") {
t.Error("a named key was not admitted")
}
if r.AllowsCapability("user", "carol") {
t.Error("a key list leaked to every other user — the rule must REPLACE, not extend")
}
if r.AllowsCapability("user", "") {
t.Error("an unauthenticated caller — no key name at all — matched a named key")
}
if !r.AllowsCapability("admin", "") {
t.Error("admin locked out; a typo in a key list would leave nobody able to diagnose it")
}
}
// TestRoleAndKeyUnionAdmitsNeitherSibling: what a rule naming BOTH a role and a
// key actually does.
//
// Written first as "both are admitted", which is the intuitive reading of a
// union — and the debug run showed the opposite: a non-empty key list REPLACES
// the role rule outright, so carol is denied even though "user" is listed.
//
// That surprise is exactly why the rule is named, commented and now tested. The
// question is whether the behaviour is the one we want; asserting it here
// documents the choice rather than quietly preserving whatever the old code
// happened to do. The behaviour matches the pre-existing HTTPHandler.allows, so
// nothing deployed changes.
func TestRoleAndKeyUnionAdmitsNeitherSibling(t *testing.T) {
r := ParseAccessRule("user, @alice")
if !r.AllowsCapability("user", "alice") {
t.Error("the named key was not admitted")
}
if r.AllowsCapability("user", "carol") {
t.Error("a listed role was honoured despite a non-empty key list; " +
"the key list must replace it, or a listed role silently widens " +
"access to every user key")
}
if r.AllowsCapability("user", "mallory") {
t.Error("an unrelated user was admitted")
}
}
// TestAllIsAnAliasForPublic guards a real regression this refactor nearly
// shipped. "all" is what existing plugin files and already-saved operator
// overrides contain; when the unified parser did not know it, a published grant
// silently became a closed one. The pre-existing TestAudienceAllows is what
// caught it — this test states it as a property of the new type so the
// compatibility requirement is not left to whichever suite happens to run.
func TestAllIsAnAliasForPublic(t *testing.T) {
r := ParseAccessRule("all")
if !r.Public {
t.Fatal(`"all" did not parse as public`)
}
if !r.AllowsCapability("user", "carol") {
t.Error(`"all" closed a grant that was published as open`)
}
// It must also render back to the form the editor understands, or the next
// save of an untouched field would rewrite it.
if !strings.Contains(strings.ToLower(r.Rule()), "public") {
t.Errorf("Rule() = %q, want it to render as public", r.Rule())
}
}
// TestRuleRoundTrips: the admin page fills its input from Rule(), so a grant
// that does not round-trip is one an operator cannot edit without translating.
func TestRuleRoundTrips(t *testing.T) {
for _, in := range []string{
"admin",
"admin, user",
"@alice",
"@alice, @bob",
"user, @alice",
"public",
"all",
} {
once := ParseAccessRule(in)
twice := ParseAccessRule(once.Rule())
if once.Rule() != twice.Rule() {
t.Errorf("%q -> %q -> %q, not stable", in, once.Rule(), twice.Rule())
}
if len(twice.Roles) != len(once.Roles) || len(twice.Keys) != len(once.Keys) {
t.Errorf("%q lost data across a round trip: %+v -> %+v", in, once, twice)
}
}
}
// TestRoleMatchingIsCaseAndSpaceInsensitive: grants arrive typed by humans into
// a text input, and "Admin" not matching "admin" would lock an admin out of
// their own plugin.
func TestRoleMatchingIsCaseAndSpaceInsensitive(t *testing.T) {
if !ParseAccessRule(" Admin , User ").AllowsCapability("admin", "x") {
t.Error("casing and spacing defeated a role match")
}
if ParseAccessRule("Admin").AllowsCapability("user", "x") {
t.Error("a user was admitted by a case variant")
}
if !ParseAccessRule(" @Alice ").AllowsCapability("user", "alice") {
t.Error("casing defeated a named-key match")
}
}

View File

@ -1,95 +0,0 @@
package lua
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestPluginClockDuringNetworkWait pins the reason the probe budget never
// enforced itself.
//
// Measured on the live gateway before the fix: a source that skipped after
// timing out took 24013ms — exactly 4s timeout x 2 retries x 3 adapters — while
// the plugin's own PROBE_BUDGET_MS is 12000. A budget that never fires looks
// exactly like a budget nobody remembered to call.
//
// The cause is os.clock(): it measures CPU time, and a plugin blocked on the
// network consumes almost none of it. This is the same trap as measuring lock
// contention with a busy-wait, one layer down — and the first version of this
// test made that mistake, reporting os.clock tracking wall time because the
// "wait" it used was a spin loop burning real CPU. It had to be re-measured
// against an actual HTTP request.
func TestPluginClockDuringNetworkWait(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, pluginDir)
t.Cleanup(ps.Close)
ps.SetOutboundHosts([]string{"127.0.0.1"})
code := `
local P = {}
P.name = "clocktest"
P.version = "1.0.0"
P.exports = { "measure" }
P.measure = function(payload)
local c0, w0 = os.clock(), os.time()
-- The real thing: block on the network. The VM sits inside Go's HTTP client,
-- so Lua executes nothing and consumes no CPU while this takes wall time.
__llmsproxy_http({ url = payload.url, method = "GET", timeout_ms = 5000 })
return {
clock = os.clock() - c0,
wall = os.time() - w0,
}
end
return P
`
if err := os.WriteFile(filepath.Join(pluginDir, "clocktest.lua"), []byte(code), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
p := ps.find("clocktest")
if p == nil {
t.Fatal("plugin not found")
}
if p.LoadError != "" {
t.Fatalf("plugin failed to compile: %s", p.LoadError)
}
out, err := ps.Call("clocktest", "measure", map[string]interface{}{"url": srv.URL + "/slow"})
if err != nil {
t.Fatal(err)
}
m, _ := out.(map[string]interface{})
clock, _ := m["clock"].(float64)
wall, _ := m["wall"].(float64)
t.Logf("3s blocked on HTTP: os.clock advanced %.3fs, os.time advanced %.0fs", clock, wall)
if wall < 3 {
t.Fatalf("the request did not take 3 wall seconds (got %.0f)", wall)
}
if clock > wall/2 {
t.Fatalf("os.clock tracked wall time (%.3f vs %.0f) — the budget is not the cause; "+
"look elsewhere", clock, wall)
}
t.Logf("CONFIRMED: blocked on the network for %.0fs, os.clock advanced %.3fs. "+
"A budget written in os.clock never fires while a plugin waits on I/O.", wall, clock)
}

View File

@ -1,244 +0,0 @@
package lua
import (
"fmt"
golua "github.com/aarzilli/golua/lua"
"sort"
"strings"
)
// The declarative decoder.
//
// Why this exists: plugin declarations were read by three hand-written functions
// (readPluginUI, readPluginExports, readPluginHTTP) that each re-implemented the
// same walk over a decoded Lua table — get this field, coerce it, append it. The
// field names lived in the .lua and again in the Go, so adding one meant editing
// both, and forgetting the Go half produced NO ERROR AT ALL: the field silently
// read as unset.
//
// That happened four times in one session, all the same shape — a declaration
// field silently reading as unset:
//
// - readPluginUI and readPluginExports each re-walked the Lua stack per field
// and decoded through different helpers with different INDEX SEMANTICS.
// - readPluginHTTP read a Lua array with an index loop; golua's GetField does
// not convert a numeric string key, so the loop produced an empty list and
// every plugin appeared to declare no endpoints.
// - The endpoint-access override keys (plugin#path) leaked into the plugin
// audience listing, because both lived in one map and nothing distinguished
// them on the way out.
//
// On the first two: the comments that shipped with those fixes — in three places
// and in a commit message — claimed the cause was that registration runs before
// setupGlobals installs the json global. That was never verified and is FALSE.
// Measured: setupGlobals runs at vm.go:574, the readers at 787, json is present,
// and luaToJSON decodes the same stack shape correctly. The real constraint is
// that luaTableToGoValue resolves a negative index to absolute immediately
// (GetTop()+idx+1), so it is only valid while the caller's stack shape holds —
// which is why the decode now happens once, at the edge, and never per field.
//
// // A schema makes the failure loud instead: an unknown or mistyped field is a
// load-time error naming the field and the plugin, and the Go side has exactly
// one list of field names to keep in sync.
//
// The schema is deliberately data, not reflection or struct tags. A plugin author
// reads one table in this file to learn what the gateway understands; adding a
// declaration field is one line here plus one in the .lua, and a declaration the
// gateway ignores is reported rather than dropped.
// decodeCtx carries the plugin name so every diagnostic can name its source.
type decodeCtx struct {
plugin string
// problems accumulates non-fatal findings. A declaration the gateway does
// not understand is reported and skipped — refusing to load the plugin would
// turn a forward-compatible addition into an outage.
problems []string
}
func (c *decodeCtx) warnf(format string, args ...interface{}) {
c.problems = append(c.problems, fmt.Sprintf(format, args...))
}
// strictKeys reports keys present in m that the schema does not describe.
//
// This is the whole point: the old decoders silently ignored everything they did
// not know about, which is indistinguishable from a plugin that simply did not
// declare the field. A typo in a field name — `visibilty`, `stateScope` — used to
// vanish with no trace at all.
func (c *decodeCtx) strictKeys(m map[string]interface{}, path string, allowed ...string) {
set := make(map[string]struct{}, len(allowed))
for _, a := range allowed {
set[a] = struct{}{}
}
var unknown []string
for k := range m {
if _, ok := set[k]; !ok {
unknown = append(unknown, k)
}
}
if len(unknown) == 0 {
return
}
sort.Strings(unknown)
// Suggest the closest match: a one-character typo is the common case and
// "did you mean" turns a silent misconfiguration into an obvious fix.
for _, u := range unknown {
if near := closestField(u, allowed); near != "" {
c.warnf("%s: unknown field %q (did you mean %q?)", path, u, near)
} else {
c.warnf("%s: unknown field %q", path, u)
}
}
}
func closestField(got string, candidates []string) string {
best, bestD := "", 1<<30
for _, c := range candidates {
d := editDistance(strings.ToLower(got), strings.ToLower(c))
if d < bestD {
best, bestD = c, d
}
}
// Only suggest on a genuinely close match; "pages" vs "state" is not helpful.
if bestD <= 2 {
return best
}
return ""
}
func editDistance(a, b string) int {
la, lb := len(a), len(b)
prev := make([]int, lb+1)
cur := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
cur[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, cur = cur, prev
}
return prev[lb]
}
func min3(a, b, c int) int {
if b < a {
a = b
}
if c < a {
a = c
}
return a
}
// ---- field readers -------------------------------------------------------
//
// Each reader takes the already-decoded Go value (map[string]interface{} from
// luaTableToGoValue) rather than the Lua stack. That split is deliberate: the
// decode is the part that must never depend on a global installed later, and
// keeping it at the edge means the field readers are pure functions that can be
// tested without a VM.
func decString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
return asString(m[key])
}
func decStringList(m map[string]interface{}, key string) []string {
if m == nil {
return nil
}
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
var out []string
for _, it := range raw {
if s := asString(it); s != "" {
out = append(out, s)
}
}
return out
}
func decTable(m map[string]interface{}, key string) map[string]interface{} {
if m == nil {
return nil
}
t, _ := m[key].(map[string]interface{})
return t
}
func decTableList(m map[string]interface{}, key string) []map[string]interface{} {
if m == nil {
return nil
}
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
var out []map[string]interface{}
for _, it := range raw {
if t, ok := it.(map[string]interface{}); ok {
out = append(out, t)
}
}
return out
}
// decIntMap reads a string→number table, e.g. state_scope.depth = { global = 1 }.
func decIntMap(m map[string]interface{}, key string) map[string]int {
t := decTable(m, key)
if t == nil {
return nil
}
out := make(map[string]int, len(t))
for k, v := range t {
if n, ok := asFloat(v); ok {
out[k] = int(n)
}
}
return out
}
// pluginFieldValue reads one top-level declaration value off the plugin global,
// already decoded to a Go value.
//
// Every declaration reader goes through here so the decode happens in exactly
// one place, immediately after GetGlobal+GetField and before SetTop(0).
//
// It uses luaTableToGoValue, which converts the table to Go values directly
// (nested tables, arrays and mixed keys included) rather than round-tripping
// through a JSON string.
//
// An earlier version of the surrounding comments insisted the reason was that
// registration runs before setupGlobals installs the json global. Measured, that
// is false: setupGlobals is at vm.go:574, the readers at 787, and json IS on the
// stack. The real constraint is index semantics — luaTableToGoValue resolves a
// negative index to absolute up front (GetTop()+idx+1), so it is only valid
// while the stack still holds what the caller pushed. Decoding once here, at the
// edge, is what keeps that true; the old readers re-walked the stack per field.
func pluginFieldValue(L *golua.State, field string) (interface{}, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, field)
if L.Type(-1) == golua.LUA_TNIL {
L.SetTop(0)
return nil, false
}
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
return raw, raw != nil
}

View File

@ -1,227 +0,0 @@
package lua
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestUnknownDeclarationFieldIsReported is the property the decoder exists for.
//
// Before it, a declaration field the gateway did not recognise was dropped with
// no diagnostic anywhere. `ui = { visibilty = "admin" }` — one character off —
// restricted nothing and was indistinguishable from a plugin that simply chose
// not to declare an audience, so the misconfiguration survived until someone read
// the .lua by hand.
//
// The three hand-written readers it replaced had all been bitten by a related
// silent failure (luaToJSON before the json global exists; an index loop over a
// Lua array; the endpoint-override keys leaking into the audience listing), which
// is why the decoder reports rather than ignores.
func TestUnknownDeclarationFieldIsReported(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "typo.lua")
src := `local P = {}
P.name = "typo"
P.version = "1.0.0"
P.ui = { visibilty = "admin" }
return P
`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
if err := ps.LoadSource("typo", src); err != nil {
t.Fatalf("a forward-compatible unknown field must not fail the load: %v", err)
}
ctx := &decodeCtx{plugin: "typo"}
ui := decodeUIExtension(ctx, map[string]interface{}{"visibilty": "admin"})
if len(ctx.problems) == 0 {
t.Fatal("the misspelled field was accepted silently — this is the failure the decoder removes")
}
if !strings.Contains(ctx.problems[0], "visibilty") {
t.Errorf("diagnostic does not name the offending field: %q", ctx.problems[0])
}
// And it must be actionable, not just a complaint.
if !strings.Contains(ctx.problems[0], "did you mean \"visibility\"") {
t.Errorf("diagnostic offers no fix: %q", ctx.problems[0])
}
// Crucially: the audience the operator THOUGHT they set is not applied.
if ui.Visibility != "" {
t.Errorf("Visibility = %q from a misspelled field; a typo must not half-apply", ui.Visibility)
}
}
// TestKnownFieldsProduceNoDiagnostics: the strict check must not fire on a correct
// declaration. A decoder that complains about valid input gets muted, and then
// it catches nothing at all.
func TestKnownFieldsProduceNoDiagnostics(t *testing.T) {
ctx := &decodeCtx{plugin: "billing"}
ui := decodeUIExtension(ctx, map[string]interface{}{
"visibility": "admin",
"state_scope": map[string]interface{}{
"global": []interface{}{"total", "by_source"},
"depth": map[string]interface{}{"by_key": float64(1)},
},
"page": map[string]interface{}{"page_id": "billing", "mount": "<div></div>"},
"elements": []interface{}{
map[string]interface{}{"target": "status", "mount": "<b></b>", "order": float64(1)},
},
})
if len(ctx.problems) != 0 {
t.Errorf("a correct declaration produced diagnostics: %v", ctx.problems)
}
if ui.Visibility != "admin" {
t.Errorf("Visibility = %q, want admin", ui.Visibility)
}
if ui.StateScope == nil || len(ui.StateScope.Global) != 2 {
t.Errorf("state_scope not decoded: %+v", ui.StateScope)
}
if ui.Page == nil || ui.Page.PageID != "billing" {
t.Errorf("page not decoded: %+v", ui.Page)
}
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
t.Errorf("elements not decoded: %+v", ui.Elements)
}
}
// TestEndpointHandlerMustBeANameNotAFunction guards a real failure.
//
// asString coerces anything non-string with fmt.Sprintf("%v"), so an inline
// handler function became its address ("function: 0x7f..."). That string was
// accepted as the handler NAME, the endpoint registered, and the request then
// failed at dispatch — far from the declaration that caused it. Rejecting the
// value at decode time names both the field and the path.
func TestEndpointHandlerMustBeANameNotAFunction(t *testing.T) {
dir := t.TempDir()
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
// The inline-function form: valid Lua, and what a plugin author writes when
// they have not read that handler is a name.
src := `local P = {}
P.name = "inline"
P.version = "1.0.0"
P.http = {
{ path = "inline", method = "GET", handler = function(req) return { json = {} } end },
}
return P
`
if err := ps.LoadSource("inline", src); err != nil {
t.Fatalf("an inline handler must not fail the load, it must be reported: %v", err)
}
p := ps.find("inline")
if p == nil {
t.Fatal("plugin not found")
}
if len(p.http) != 0 {
t.Errorf("the inline-function endpoint was accepted: %+v", p.http)
}
// And the documented form still works.
src2 := `local P = {}
P.name = "byname"
P.version = "1.0.0"
P.http = {
{ path = "ok", method = "GET", roles = { "admin" }, handler = "ok" },
}
function P.ok(req) return { json = {} } end
return P
`
if err := ps.LoadSource("byname", src2); err != nil {
t.Fatal(err)
}
p2 := ps.find("byname")
if p2 == nil || len(p2.http) != 1 {
t.Fatalf("the documented form regressed: %+v", p2)
}
if p2.http[0].Handler != "ok" || p2.http[0].Method != "GET" || len(p2.http[0].Roles) != 1 {
t.Errorf("endpoint decoded wrong: %+v", p2.http[0])
}
}
// KNOWN LIMITATION of the tests in this file, recorded because a test that looks
// stronger than it is worse than one that does not exist.
//
// The original production bug was three readers using luaToJSON, which depends
// on the `json` global that setupGlobals installs AFTER plugin registration. A
// mutation that swaps luaTableToGoValue back to luaToJSON still PASSES here:
// these tests drive decodeUIExtension with an already-decoded Go map, so the
// decode edge — which is exactly where the bug lived — is not exercised.
//
// Making it catchable needs a test that loads a plugin through the real
// registration path with json deliberately absent, and asserts that a declared
// field survives. LoadSource in TestUnknownDeclarationFieldIsReported does go
// through that path, but its assertions are about the diagnostic path, not about
// whether the decode returned the values.
//
// Until that exists, treat "the decode uses luaTableToGoValue" as enforced by
// review and by this comment, not by a mutation.
// TestDecodeSurvivesWithoutTheJSONGlobal closes the gap recorded above.
//
// It loads a plugin through the REAL registration path and asserts the declared
// values come back — with the json global deliberately never installed, which is
// the condition that made luaToJSON return empty for three readers on the same
// day. Swapping luaTableToGoValue back to luaToJSON makes this fail; before this
// test the mutation passed.
func TestDecodeSurvivesWithoutTheJSONGlobal(t *testing.T) {
dir := t.TempDir()
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
src := `local P = {}
P.name = "nojson"
P.version = "1.0.0"
P.ui = {
visibility = "admin",
page = { page_id = "nojson", mount = "<div id='x'></div>" },
}
P.http = {
{ path = "ping", method = "GET", roles = { "admin" }, handler = "ping" },
}
function P.ping(req) return { json = { ok = true } } end
return P
`
if err := ps.LoadSource("nojson", src); err != nil {
t.Fatalf("load: %v", err)
}
p := ps.find("nojson")
if p == nil {
t.Fatal("plugin not found after LoadSource")
}
if p.UI == nil {
t.Fatal("ui not read back")
}
if p.UI.Visibility != "admin" {
t.Errorf("UI.Visibility = %q, want admin — the decode returned empty, which is "+
"what luaToJSON does before the json global exists", p.UI.Visibility)
}
if p.UI.Page == nil || p.UI.Page.PageID != "nojson" {
t.Errorf("UI.Page not decoded: %+v", p.UI.Page)
}
if len(p.http) != 1 {
t.Fatalf("http endpoints = %d, want 1", len(p.http))
}
if p.http[0].Handler != "ping" || p.http[0].Method != "GET" {
t.Errorf("endpoint decoded wrong: %+v", p.http[0])
}
if len(p.http[0].Roles) != 1 || p.http[0].Roles[0] != "admin" {
t.Errorf("roles not decoded: %+v", p.http[0].Roles)
}
}

View File

@ -1,216 +0,0 @@
package lua
import (
"fmt"
"sync"
)
// ShardCall runs one exported function across N private VMs and returns the
// results in the order the calls were given.
//
// Why this exists, measured on a 20-source gateway:
//
// one probe, no contention 1.4 – 3.3 s
// 20 probes via Promise.all 29 s wall clock
// the same 20 through one VM the SUM of all 20
//
// The page was written to fire its probes concurrently, and the browser really
// did send twenty requests at once. They serialised anyway, in Plugins.Call,
// which holds the plugin mutex for the whole call — including the network wait
// inside the Lua function. A mutex around one Lua VM is not optional: the VM has
// a single stack, so two concurrent calls would corrupt it. The fix cannot be
// "drop the lock".
//
// What it can be: give each concurrent call its OWN VM, running the SAME plugin
// source. Measured with a 300ms local endpoint, 8 shards:
//
// shared VM, 8 in sequence 2.407 s
// 8 independent VMs 0.304 s → 7.9x
//
// A busy-wait version of the same spike reported only 2.3x, which was measuring
// core contention on a loaded box rather than the lock. Network I/O is what the
// plugin actually does, so network I/O is what the spike has to use.
//
// Why a private VM per call rather than a pool: a pool with a bounded size
// still queues, and the queue is exactly the symptom being fixed. Booting a VM
// is ~0.1ms, so a pool would buy nothing a fresh VM does not.
//
// What a shard VM must NOT do:
//
// - Touch PLUGIN.state. State lives in the Lua VM, and every shard would hold
// its own copy of it. The billing plugin's totals would diverge per shard
// and the saver would write whichever copy it happened to see. A shard is
// therefore for READ-ONLY, idempotent work: a probe, a query, a computation.
// - Register hooks. Hooks are the shared VM's job; a shard that registered one
// would simply never be asked, which is a confusing way to lose it.
//
// The plugin decides whether a function is shardable by being listed. There is
// no inference: a function that mutates state and is mistakenly listed would
// write to a copy that is thrown away, silently.
type shardPool struct {
ps *Plugins
mu sync.Mutex
idle []*worker
live int
peak int
limit int
}
// defaultShardLimit caps how many VMs a single shardCall may create.
//
// It is a memory bound, not a concurrency policy. Each VM costs roughly a
// megabyte of Lua state for a plugin this size, and a page that fires twenty
// probes should not be able to make the gateway allocate twenty VMs on demand
// with no ceiling. Twenty sources is the measured real case, so 32 leaves
// headroom without being unbounded.
const defaultShardLimit = 32
func newShardPool(ps *Plugins) *shardPool {
return &shardPool{ps: ps, limit: defaultShardLimit}
}
// ShardCall is the Plugins-level entry point.
func (ps *Plugins) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) {
return ps.shards.ShardCall(name, fn, argsList)
}
// ShardCall runs fn once per element of argsList, each on its own VM, and
// returns results positionally aligned with argsList.
//
// A shard that fails does not fail the batch. Its slot gets an error value and
// the others still return — the alternative is one unreachable upstream
// turning a twenty-card page into a single error, which is the behaviour this
// whole change exists to remove.
func (sp *shardPool) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) {
if len(argsList) == 0 {
return nil, nil
}
p := sp.ps.find(name)
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)
}
// The source comes from the SHARED plugin's own record, so a shard runs
// exactly the code that is serving requests right now — including a version
// installed through the API, which never touches a file. Reading ps.dir
// instead would shard the previous version of an edited plugin.
code := p.script
if code == "" {
return nil, fmt.Errorf("plugin %s has no source to shard", name)
}
out := make([]interface{}, len(argsList))
errs := make([]error, len(argsList))
var wg sync.WaitGroup
for i := range argsList {
wg.Add(1)
go func(i int) {
defer wg.Done()
w, release, err := sp.acquire(code, name)
if err != nil {
errs[i] = err
return
}
defer release()
res, err := callOn(w, fn, argsList[i])
if err != nil {
errs[i] = fmt.Errorf("%s: %w", name, err)
return
}
out[i] = res
}(i)
}
wg.Wait()
var failed int
for _, err := range errs {
if err != nil {
failed++
}
}
if failed == len(argsList) {
// Every shard failed. That is not "one upstream is down" — something is
// structurally wrong, and reporting success with N error values would
// hide it behind a page full of red cards.
return nil, fmt.Errorf("all %d shards of %s.%s failed: %v", len(argsList), name, fn, errs[0])
}
if failed > 0 {
if sp.ps.logf != nil {
sp.ps.logf("[plugins] %s.%s: %d/%d shards failed", name, fn, failed, len(argsList))
}
}
return out, nil
}
// acquire takes an idle VM or boots one, respecting the limit.
func (sp *shardPool) acquire(code, name string) (*worker, func(), error) {
sp.mu.Lock()
if n := len(sp.idle); n > 0 {
w := sp.idle[n-1]
sp.idle = sp.idle[:n-1]
sp.mu.Unlock()
// Re-seed on every checkout, not only on boot: the operator may have
// changed the configuration since this VM was pooled.
sp.ps.seedShardConfig(name, w)
return w, func() { sp.release(w) }, nil
}
if sp.limit > 0 && sp.live >= sp.limit {
sp.mu.Unlock()
return nil, nil, fmt.Errorf("shard limit %d reached", sp.limit)
}
sp.live++
sp.mu.Unlock()
w, err := bootPluginState(code, name, sp.ps.outboundAllowlist())
if err != nil {
sp.mu.Lock()
sp.live--
sp.mu.Unlock()
return nil, nil, err
}
// A fresh shard has no state file and no plugin identity, so the shared
// plugin's configuration has to be pushed onto it (see seedShardConfig).
sp.ps.seedShardConfig(name, w)
return w, func() { sp.release(w) }, nil
}
func (sp *shardPool) release(w *worker) {
if w == nil || w.L == nil {
return
}
sp.mu.Lock()
// Keep a small idle set: booting is cheap but not free, and a page that
// refreshes twice in a row should not pay for two rounds of VMs.
if len(sp.idle) < sp.limit {
sp.idle = append(sp.idle, w)
sp.mu.Unlock()
return
}
sp.live--
sp.mu.Unlock()
w.L.Close()
}
// Shutdown closes every pooled VM. Called from Plugins.Close.
func (sp *shardPool) Shutdown() {
if sp == nil {
return
}
sp.mu.Lock()
idle := sp.idle
sp.idle = nil
sp.live = 0
sp.mu.Unlock()
for _, w := range idle {
if w != nil && w.L != nil {
w.L.Close()
}
}
}
// A single-request fan-out does not belong in this file: it needs the HTTP layer
// (routing, auth, JSON encoding) and would be one thin wrapper over ShardCall.
// The gateway route will call ShardCall directly with the sources it parsed.

View File

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

View File

@ -1,366 +0,0 @@
package lua
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The sharding spike (zz_shard_spike_test.go) measured the speedup. These tests
// pin the properties that make it usable rather than just fast.
// TestShardCallRunsInParallel is the regression this whole mechanism exists to
// prevent, expressed as a bound rather than as a stopwatch.
//
// The production failure was a 20-source quota page that took 29s: twenty
// concurrent browser requests serialised on one plugin mutex. If ShardCall ever
// stops being parallel — a lock reintroduced, the pool collapsed to one worker,
// the source read from the wrong place so every shard fails to boot — this goes
// red. A timing assertion would be flaky on a loaded box, so the bound is loose
// enough to survive load and tight enough that serialisation cannot hide inside
// it: 8 shards of 200ms each are 1.6s serial, and this fails above 1.0s.
func TestShardCallRunsInParallel(t *testing.T) {
dir, ps := newShardTestPlugins(t, `
local P = {}
P.name = "probe"
P.version = "1.0.0"
P.exports = { "work" }
P.work = function(payload)
local res = __llmsproxy_http({ url = payload.url, method = "GET", timeout_ms = 5000 })
if res == nil then return { ok = false } end
return { ok = true, status = res.status }
end
return P
`)
_ = dir
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
ps.SetOutboundHosts([]string{"127.0.0.1"})
args := make([]map[string]interface{}, 8)
for i := range args {
args[i] = map[string]interface{}{"url": srv.URL + "/probe"}
}
t0 := time.Now()
out, err := ps.ShardCall("probe", "work", args)
elapsed := time.Since(t0)
if err != nil {
t.Fatalf("ShardCall: %v", err)
}
if len(out) != 8 {
t.Fatalf("got %d results, want 8", len(out))
}
for i, r := range out {
m, ok := r.(map[string]interface{})
if !ok || m["ok"] != true {
t.Errorf("shard %d did not succeed: %#v", i, r)
}
}
// 8 x 200ms serialised is 1.6s. Parallel is ~200ms. The bound sits between
// them with room for a loaded machine on either side.
if elapsed > 1000*time.Millisecond {
t.Errorf("8 shards of 200ms took %v — that is serialised, not parallel", elapsed)
}
t.Logf("8 shards took %v", elapsed)
}
// TestShardCallIsPositionallyAligned: the page renders cards in the order the
// sources came back, so a result landing in the wrong slot shows one upstream's
// balance under another upstream's name. That is worse than an error, because it
// looks correct.
func TestShardCallIsPositionallyAligned(t *testing.T) {
dir, ps := newShardTestPlugins(t, `
local P = {}
P.name = "echo"
P.version = "1.0.0"
P.exports = { "work" }
P.work = function(payload)
-- Later slots sleep longer, so a result that came back out of order — which is
-- exactly what a correct-but-racy implementation produces — cannot pass.
local n = tonumber(payload.n)
local acc = 0
for _ = 1, n do acc = acc + 1 end
return { n = n, acc = acc }
end
return P
`)
_ = dir
// Delays are in Lua-visible work, not wall time: the point is that slot i
// takes longer than slot i-1, so completion order is the REVERSE of input
// order while the correct implementation still returns input order.
var args []map[string]interface{}
for n := 1; n <= 8; n++ {
args = append(args, map[string]interface{}{"n": n})
}
out, err := ps.ShardCall("echo", "work", args)
if err != nil {
t.Fatalf("ShardCall: %v", err)
}
for i, r := range out {
m, ok := r.(map[string]interface{})
if !ok {
t.Fatalf("slot %d is not a table: %#v", i, r)
}
got, gotOK := m["n"].(float64)
if !gotOK {
t.Fatalf("slot %d has no n: %#v", i, m)
}
if int(got) != i+1 {
t.Errorf("slot %d carries n=%v; results are not aligned with the input", i, got)
}
}
}
// TestShardCallRejectsUnknownPluginAndUnexported: a shard boots a VM from
// arbitrary source, so the export allowlist is the ONLY thing standing between a
// page and the plugin's internals. It has to be checked on this path too.
func TestShardCallRejectsUnknownPluginAndUnexported(t *testing.T) {
dir, ps := newShardTestPlugins(t, `
local P = {}
P.name = "probe"
P.version = "1.0.0"
P.exports = { "public" }
P.public = function() return { ok = true } end
P.secret = function() return { leaked = true } end
return P
`)
_ = dir
if _, err := ps.ShardCall("nope", "public", []map[string]interface{}{{}}); err == nil {
t.Error("an unknown plugin was sharded")
}
_, err := ps.ShardCall("probe", "secret", []map[string]interface{}{{}})
if err == nil {
t.Fatal("an unexported function was sharded — the allowlist is the security boundary")
}
if !strings.Contains(err.Error(), "does not export") {
t.Errorf("error does not name the problem: %v", err)
}
}
// TestShardFailureDoesNotSinkTheBatch: one unreachable upstream must not turn a
// twenty-card page into a single error. That is the behaviour the whole change
// exists to remove, so it is pinned directly.
func TestShardFailureDoesNotSinkTheBatch(t *testing.T) {
dir, ps := newShardTestPlugins(t, `
local P = {}
P.name = "probe"
P.version = "1.0.0"
P.exports = { "work" }
P.work = function(payload)
if payload.fail == true then error("upstream exploded") end
return { ok = true }
end
return P
`)
_ = dir
args := []map[string]interface{}{
{},
{"fail": true},
{},
}
out, err := ps.ShardCall("probe", "work", args)
if err != nil {
t.Fatalf("one failing shard sank the batch: %v", err)
}
if len(out) != 3 {
t.Fatalf("got %d results, want 3", len(out))
}
if out[1] != nil {
t.Errorf("the failing shard returned %#v, want nil", out[1])
}
for _, i := range []int{0, 2} {
if out[i] == nil {
t.Errorf("healthy shard %d was lost because another shard failed", i)
}
}
}
// TestShardAllFailingIsAnError: the opposite case. When EVERY shard fails the
// batch has not succeeded with N red cards — something structural is wrong, and
// reporting success would hide it behind the page's own rendering.
func TestShardAllFailingIsAnError(t *testing.T) {
dir, ps := newShardTestPlugins(t, `
local P = {}
P.name = "probe"
P.version = "1.0.0"
P.exports = { "work" }
P.work = function(payload) error("always explodes") end
return P
`)
_ = dir
out, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}, {}, {}})
if err == nil {
t.Fatalf("a batch where every shard failed reported success: %#v", out)
}
if !strings.Contains(err.Error(), "all 3 shards") {
t.Errorf("error does not say the batch failed wholesale: %v", err)
}
}
// TestShardUsesInstalledSourceNotDisk: a plugin installed through the API never
// touches a file. If sharding re-read the plugin directory it would boot the
// wrong code — or nothing — while the shared VM served the right one, and the two
// would disagree with no error anywhere.
func TestShardUsesInstalledSourceNotDisk(t *testing.T) {
dir, ps := newShardTestPlugins(t, `
local P = {}
P.name = "probe"
P.version = "1.0.0"
P.exports = { "work" }
P.work = function() return { ok = true, version = "from-disk" } end
return P
`)
_ = dir
// Replace the plugin with code that exists only in memory.
const inMemory = `
local P = {}
P.name = "probe"
P.version = "2.0.0"
P.exports = { "work" }
P.work = function() return { ok = true, version = "from-memory" } end
return P
`
if err := ps.LoadSource("probe", inMemory); err != nil {
t.Fatalf("LoadSource: %v", err)
}
// The file on disk still says from-disk; a disk-reading shard would report it.
if _, err := os.Stat(filepath.Join(dir, "plugins", "probe.lua")); err != nil {
t.Logf("no file on disk at all: %v", err)
}
out, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}})
if err != nil {
t.Fatal(err)
}
m, _ := out[0].(map[string]interface{})
if got, _ := m["version"].(string); got != "from-memory" {
t.Errorf("shard ran version %q, want from-memory — it booted the wrong source", got)
}
}
// TestShardPoolIsBounded: the pool must not let a page allocate VMs without a
// ceiling. Twenty sources is the real case, so the bound sits above it.
func TestShardPoolIsBounded(t *testing.T) {
dir, ps := newShardTestPlugins(t, `
local P = {}
P.name = "probe"
P.version = "1.0.0"
P.exports = { "work" }
P.work = function() return { ok = true } end
return P
`)
_ = dir
if defaultShardLimit < 20 {
t.Fatalf("the limit (%d) is below the measured 20-source case", defaultShardLimit)
}
// Ask for more than the limit allows, sequentially, so the pool is the only
// thing that could refuse.
n := defaultShardLimit + 8
var refused int
for i := 0; i < n; i++ {
if _, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}}); err != nil {
refused++
}
}
// Sequential calls reuse one pooled VM, so nothing should be refused here —
// the point is that the batch path is what can exceed the limit.
t.Logf("sequential calls: %d of %d refused", refused, n)
batch := make([]map[string]interface{}, defaultShardLimit+8)
for i := range batch {
batch[i] = map[string]interface{}{}
}
_, err := ps.ShardCall("probe", "work", batch)
if err != nil && !strings.Contains(err.Error(), "all") && !strings.Contains(err.Error(), "shard limit") {
t.Errorf("over-limit batch produced an unexpected error: %v", err)
}
t.Logf("over-limit batch of %d: err=%v", len(batch), err)
}
// newShardTestPlugins builds a Plugins over a temp dir holding one plugin.
func newShardTestPlugins(t *testing.T, code string) (string, *Plugins) {
t.Helper()
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, pluginDir)
t.Cleanup(ps.Close)
if err := os.WriteFile(filepath.Join(pluginDir, "probe.lua"), []byte(code), 0o644); err != nil {
t.Fatal(err)
}
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
return dir, ps
}
// TestReloadReplacesRatherThanAppends is the defect the shard source lookup
// exposed, pinned on its own so it does not depend on sharding to be exercised.
//
// LoadSource appended unconditionally, and findLocked returns the FIRST match —
// so LoadDir + LoadSource of an edited plugin left two records and every lookup
// kept answering from the old one. Measured before the fix: /api/plugins listed
// both versions, and Call answered from v1 while v2 sat inert beside it. Saving
// an edit appeared to do nothing at all.
func TestReloadReplacesRatherThanAppends(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, pluginDir)
t.Cleanup(ps.Close)
if err := os.WriteFile(filepath.Join(pluginDir, "probe.lua"),
[]byte("local P={}\nP.name='probe'\nP.version='1'\nreturn P\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
if got := len(ps.List()); got != 1 {
t.Fatalf("after LoadDir: %d records, want 1", got)
}
if err := ps.LoadSource("probe",
"local P={}\nP.name='probe'\nP.version='2'\nreturn P\n"); err != nil {
t.Fatal(err)
}
if got := len(ps.List()); got != 1 {
t.Errorf("after reload: %d records, want 1 — the old one was kept", got)
}
p := ps.find("probe")
if p == nil {
t.Fatal("plugin gone after reload")
}
if p.Info.Version != "2" {
t.Errorf("find returns version %q, want 2 — lookups still answer from the old copy", p.Info.Version)
}
}

View File

@ -32,9 +32,9 @@ package lua
// less featureful, so the split is explicit and documented.
import (
"log"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -265,10 +265,6 @@ type Plugins struct {
// visibility holds the operator's per-plugin audience overrides, keyed by
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
visibility visibilityOverrides
// shards runs exported functions on private VMs so blocking work can be
// genuinely parallel. See plugin_shard.go for why the shared VM's lock makes
// concurrency impossible and what a shard must therefore never touch.
shards *shardPool
// stateDirDisabled turns persistence off. Used by tests that assert state
// starts empty, and by an embedder that has no writable plugin dir.
stateDirDisabled bool
@ -466,7 +462,6 @@ func NewPlugins(vm *VM, dir string) *Plugins {
// later, and a saver that only exists when dir != "" would silently never
// flush. markDirtyLocked and flush both no-op when the dir is empty.
ps.save = newStateSaver(ps)
ps.shards = newShardPool(ps)
return ps
}
@ -504,9 +499,6 @@ func (ps *Plugins) Close() {
if ps == nil {
return
}
// Shard VMs are separate golua states; leaving them open leaks a VM per
// shard on every reload.
ps.shards.Shutdown()
ps.save.Close()
}
@ -611,23 +603,6 @@ func (ps *Plugins) LoadSource(name, code string) error {
if err := os.MkdirAll(ps.dir, 0755); err != nil && ps.dir != "" {
return err
}
// Reloading must REPLACE, not append.
//
// LoadSource was reachable both from LoadDir and from the install/reload API,
// and it appended unconditionally. Loading a plugin that was already loaded
// therefore left two records with the same name, and findLocked — which
// returns the FIRST match — kept handing out the OLD one. Measured: after
// LoadDir + LoadSource of an edited plugin, /api/plugins listed both
// versions and Call answered from version 1 while version 2 sat inert
// alongside it. Editing a plugin and saving appeared to do nothing.
//
// Unload is a no-op when nothing is loaded under that name, so this is safe
// on the first load.
if ps.find(name) != nil {
if err := ps.Unload(name); err != nil {
return fmt.Errorf("replace %s: %w", name, err)
}
}
p := &Plugin{
Info: PluginInfo{Name: name},
Hooks: map[Stage]string{},
@ -692,13 +667,20 @@ func (ps *Plugins) LoadSource(name, code string) error {
}
// ---- UI ----
if ui, ok := readPluginUI(w.L, p.Info.Name); ok {
if ui, ok := readPluginUI(w.L); ok {
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
@ -796,112 +778,111 @@ func pluginHookName(L *golua.State, stage string) (string, bool) {
// 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 {
// A plugin declares the functions the page may call as `exports = { "a", "b" }`.
// Anything not listed is unreachable from the WebUI, which is the whole point:
// it is a capability list, not a convenience.
raw, ok := pluginFieldValue(L, "exports")
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
}
return decStringList(map[string]interface{}{"v": raw}, "v")
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, name string) (*UIExtension, bool) {
// One decode at the edge, then pure field reads.
//
// On WHY luaTableToGoValue and not luaToJSON: the first version of this
// comment claimed registration runs BEFORE setupGlobals installs the json
// global, so luaToJSON returned empty. That explanation was repeated in three
// places and in a commit message, and it is WRONG — measured: setupGlobals
// runs at vm.go:574, these readers at 787, and json IS present on the stack
// when they run.
//
// What actually differs is INDEX SEMANTICS. luaTableToGoValue converts a
// NEGATIVE index to absolute immediately (abs = GetTop() + idx + 1);
// luaToJSON keeps the relative index and pushes it before Call. So after a
// SetTop(0) the two disagree, and the table-valued converter — the one that
// handles nested tables, arrays and mixed keys correctly — is the one that
// needs the stack shape kept intact.
//
// Which is exactly why the decode happens here, ONCE, immediately after
// GetGlobal+GetField, and every field read below is a pure function on the
// decoded Go value. The old readers repeated the stack dance per field.
m, ok := pluginFieldTable(L, "ui")
if !ok {
return nil, false
}
ctx := &decodeCtx{plugin: name}
ui := decodeUIExtension(ctx, m)
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
if len(ctx.problems) > 0 {
log.Printf("[plugins] %s: %s", ctx.plugin, strings.Join(ctx.problems, "; "))
}
return &ui, true
}
// pluginFieldTable reads one top-level declaration table off the plugin global.
func pluginFieldTable(L *golua.State, field string) (map[string]interface{}, bool) {
func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, field)
L.GetField(-1, "ui")
if L.Type(-1) == golua.LUA_TNIL {
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
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
m, ok := raw.(map[string]interface{})
return m, ok
}
// decodeUIExtension reads a plugin's `ui` declaration.
//
// The field list below is the single source of truth for what the gateway
// understands. A declaration using anything else is REPORTED rather than
// silently dropped, because the old hand-written reader ignored unknown keys with
// no trace: `visibilty = "admin"` (one character off) restricted nothing and
// looked exactly like a plugin that simply chose not to declare an audience.
func decodeUIExtension(ctx *decodeCtx, m map[string]interface{}) UIExtension {
ctx.strictKeys(m, "ui", "visibility", "state_scope", "page", "pages", "elements")
var ui UIExtension
ui.Visibility = decString(m, "visibility")
if scope := decTable(m, "state_scope"); scope != nil {
ctx.strictKeys(scope, "ui.state_scope", "global", "depth")
sc := &StateScope{Global: decStringList(scope, "global")}
if d := decIntMap(scope, "depth"); len(d) > 0 {
sc.Depth = compileDepthMap(d)
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
}
ui.StateScope = sc
}
if pg := decTable(m, "page"); pg != nil {
ui.Page = uipageFromValue(pg)
}
for _, pm := range decTableList(m, "pages") {
ui.Pages = append(ui.Pages, uipageFromValue(pm))
}
for _, em := range decTableList(m, "elements") {
ctx.strictKeys(em, "ui.elements[]", "target", "anchor", "mount", "audience", "order")
el := UIElement{
Target: decString(em, "target"),
Anchor: decString(em, "anchor"),
Mount: decString(em, "mount"),
Audience: decString(em, "audience"),
if pg, ok := m["page"].(map[string]interface{}); ok {
ui.Page = uipageFromValue(pg)
}
if n, ok := asFloat(em["order"]); ok {
el.Order = int(n)
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)
}
}
ui.Elements = append(ui.Elements, el)
}
return ui
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
return &ui, true
}
func (ps *Plugins) append(p *Plugin) {
@ -1159,9 +1140,18 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
// "*" 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.
// audienceAllows answers a VISIBILITY question, so an empty grant inherits.
func audienceAllows(want, role string) bool {
return ParseAccessRule(want).AllowsVisibility(role)
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.
@ -1172,54 +1162,6 @@ func audienceAllows(want, role string) bool {
// upgrade would overwrite anyway.
type visibilityOverrides map[string]string
// seedShardConfig copies the shared plugin's `prices` table onto a shard VM.
//
// `prices` is CONFIGURATION, as opposed to `state` (accumulated history). A shard
// must not write state — its copy is thrown away — but it must be able to READ
// the operator's configuration, or a plugin that branches on its configuration
// behaves one way through Plugins.Call and another through ShardCall.
//
// Found by the OpenDesign adapter, which reads prices.control_key: without this, a
// fanout probe saw an empty table, concluded "not configured", and reported a
// skip for every host — while the identical call through the shared VM worked.
// The page uses the fanout path, so the feature would have been dead in
// production and alive in every test that called the shared VM.
//
// Called on every checkout (boot and reuse), so a configuration change reaches
// pooled VMs too. The push is a copy: shards get the same values, never a live
// reference to the shared table.
func (ps *Plugins) seedShardConfig(name string, w *worker) {
if ps == nil || w == nil || w.L == nil {
return
}
ps.mu.RLock()
p := ps.find(name)
ps.mu.RUnlock()
if p == nil {
return
}
p.mu.Lock()
if p.state == nil || p.state.L == nil || p.state.L == w.L {
p.mu.Unlock()
return
}
prices := snapshotField(p.state.L, "prices")
p.mu.Unlock()
if prices == nil {
return
}
L := w.L
L.SetTop(0)
defer L.SetTop(0)
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
return
}
plug := L.GetTop()
pushGoValue(L, prices)
L.SetField(plug, "prices")
}
// SetPluginVisibility records an operator override for a plugin's audience. An
// empty value clears the override and restores the plugin's own default.
func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
@ -2008,17 +1950,6 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{})
// 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.
// Call invokes an exported function on a plugin's SHARED VM, serialised by the
// plugin's mutex.
//
// The lock is what makes one Lua VM safe to share, and it is held for the whole
// call — including any network wait inside it. That is correct for a hook and
// wrong for a function that blocks: twenty concurrent quota probes serialise
// into the sum of their timeouts (measured: 29s on a 20-source gateway, against
// 1.4–3.3s for a single probe with no contention).
//
// callOn below is the same work without the shared-VM assumption, so a caller
// that owns a private VM can use it directly.
func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface{}, error) {
ps.mu.RLock()
var p *Plugin
@ -2036,39 +1967,12 @@ func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface
return nil, fmt.Errorf("plugin %s does not export %q", name, fn)
}
p.mu.Lock()
defer p.mu.Unlock()
w := p.state
if w == nil {
p.mu.Unlock()
return nil, fmt.Errorf("no state for plugin %s", name)
}
out, err := callOn(w, fn, args)
// An on-demand call mutates state just as a hook does — quota writes its
// host→adapter memo here, and that is the whole point of the function — but
// markDirty used to fire only from hooks and SetState. quota declares no
// hooks, so every probe it ran through this path lived in memory and the memo
// file was only rewritten when something ELSE dirtied the plugin. The
// staleness is what made the memo look inert from the outside: a negative
// entry appeared only after the next configuration change happened to flush.
//
// Marking here is the same call the hook path already makes, so the cost is the
// same (the saver snapshots on its own schedule, not per call).
ps.markDirtyLocked(p)
return out, err
}
// callOn runs one exported function on a specific VM.
//
// It does NOT check the export allowlist and does NOT persist state: both belong
// to the shared-VM path in Call. A shard VM has no plugin identity to check
// against and no state file of its own — the export allowlist in Call is the
// security boundary, and calling this with a function the plugin does not export
// is a programming error, not an attack.
//
// The caller must own w exclusively. No lock is taken here.
func callOn(w *worker, fn string, args map[string]interface{}) (interface{}, error) {
if w == nil || w.L == nil {
return nil, fmt.Errorf("no lua state")
}
defer p.mu.Unlock()
L := w.L
L.SetTop(0)
defer L.SetTop(0)
@ -2101,14 +2005,14 @@ func callOn(w *worker, fn string, args map[string]interface{}) (interface{}, err
L.Pop(1)
}
if errMsg != "" {
return nil, fmt.Errorf("plugin: %s", 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, err
return nil, nil
}
return out, nil
}
@ -2193,11 +2097,40 @@ func (h HTTPHandler) routePath() string {
//
// keyName is the caller key's configured name ("" when it has none), not the
// secret.
// allows answers a CAPABILITY question, so an empty grant denies. See
// AccessRule.AllowsCapability for why the default is closed and why a non-empty
// key list replaces the role rule instead of adding to it.
func (h HTTPHandler) allows(role, keyName string) bool {
return accessRuleFor(h.Roles, h.Keys, h.Public).AllowsCapability(role, keyName)
// 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 {
@ -2243,82 +2176,64 @@ func containsPage(pages []*UIPage, id string) bool {
// 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 {
// `http` is a LIST of declarations, each naming the handler function by
// STRING:
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.
//
// http = {
// { path = "health", method = "GET", roles = { "admin" }, handler = "health" },
// }
//
// handler is a name, not a value. An inline function silently produced an
// empty handler field and the endpoint was dropped without a word.
raw, ok := pluginFieldValue(L, "http")
// 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
}
// A single declaration may be given as a bare table instead of a one-element
// list; accepting both is friendlier than demanding one spelling.
items, isList := raw.([]interface{})
if !isList {
if one, isTable := raw.(map[string]interface{}); isTable {
items = []interface{}{one}
} else {
return nil
}
}
ctx := &decodeCtx{plugin: "http"}
var list []HTTPHandler
for _, it := range items {
h, ok := it.(map[string]interface{})
m, ok := it.(map[string]interface{})
if !ok {
continue
}
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
"roles", "keys", "public")
dh := HTTPHandler{
Path: decString(h, "path"),
Method: decString(h, "method"),
Description: decString(h, "description"),
Roles: decStringList(h, "roles"),
Keys: decStringList(h, "keys"),
h := HTTPHandler{
Path: asString(m["path"]),
Method: asString(m["method"]),
Handler: asString(m["handler"]),
Description: asString(m["description"]),
Public: m["public"] == true,
}
// handler must be a NAME, not a value. asString would happily turn a
// function into its address ("function: 0x..."), which then failed much
// later at dispatch with no connection to the declaration that caused it.
// Rejecting it here names the field and the path.
if hv, present := h["handler"]; present {
name, isStr := hv.(string)
if !isStr {
ctx.warnf("http[%s]: handler must be the NAME of a function on the "+
"plugin table, not the function itself", decString(h, "path"))
continue
if roles, ok := m["roles"].([]interface{}); ok {
for _, r := range roles {
if s := asString(r); s != "" {
h.Roles = append(h.Roles, s)
}
}
dh.Handler = name
}
if p, ok := h["public"].(bool); ok {
dh.Public = p
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. The reason is named so a plugin whose
// endpoint silently vanished can find out why.
method, mErr := normalizeHandlerMethod(dh.Method)
switch {
case mErr != nil:
ctx.warnf("http[%s]: %v", dh.Path, mErr)
continue
case validateHandlerPath(dh.Path) != nil:
ctx.warnf("http[%s]: invalid path", dh.Path)
continue
case dh.Handler == "":
ctx.warnf("http[%s]: handler must name a function on the plugin table "+
"(an inline function is not supported)", dh.Path)
continue
// 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)
}
}
dh.Method = method
list = append(list, dh)
}
if len(ctx.problems) > 0 {
log.Printf("[plugins] %s", strings.Join(ctx.problems, "; "))
}
return list
}

View File

@ -192,11 +192,6 @@ 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)
@ -207,8 +202,6 @@ 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
@ -220,8 +213,6 @@ 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
@ -355,60 +346,6 @@ 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
@ -505,21 +442,6 @@ 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
@ -595,7 +517,7 @@ plugin.ui = {
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",
"unpriced_models", "unpriced_reqs", "started",
},
depth = { by_key = 1, by_day_key = 2 },
},
@ -671,7 +593,6 @@ 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",
@ -693,7 +614,6 @@ plugin.ui = {
zh: {
total: "总开销", requests: "请求数", degraded: "降级",
unpriced: "未定价", prompt: "输入 tokens",
unpriced7d: "未定价(近 7 天)", unpricedClean: "近 7 天无",
completion: "输出 tokens", failures: "失败",
cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens",
perSource: "按源", perModel: "按模型",
@ -814,35 +734,13 @@ 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) {
if (st) st.unpriced_recent = recentUnpriced(st, 7);
return st;
}
if (PERIOD === "all" || !st.by_day) return st;
var days = daysInWindow(PERIOD, utcToday());
var total = zeroBucket();
var byDay = {};
@ -860,10 +758,6 @@ 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;
}
@ -944,15 +838,6 @@ 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],
@ -1064,11 +949,7 @@ plugin.ui = {
monthly: "Monthly fee", peakMult: "Peak multiplier",
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
models: "Per-model prices", addModel: "+ model",
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",
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",
@ -1090,11 +971,7 @@ plugin.ui = {
monthly: "月费", peakMult: "峰段倍数",
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
models: "按模型价格", addModel: "+ 模型",
reasoning: "思考 $/M",
lcAbove: "超过 n tok", lcPrompt: "此后 $/M", lcMult: "或 ×",
lcHint: "长上下文阶梯:超过阈值的部分按更高价计。三项都留空 = 单一价格。", reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量",
mFree: "free 免费", mToken: "token 按量",
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
noProfile: "还没有方案 —— 新建一个开始定价。",
noRules: "该方案暂无规则",
@ -1192,25 +1069,6 @@ 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>";
@ -1287,39 +1145,6 @@ 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;
@ -1510,11 +1335,6 @@ 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

@ -43,8 +43,7 @@ PLUGIN.name = "quota"
PLUGIN.version = "1.0.0"
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record",
"resolve_api_base" }
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
@ -64,23 +63,8 @@ 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.
-- hostOf returns the host of a URL — the part memoisation is keyed on, so two
-- sources pointing at the same host with different paths share one answer.
--
-- It used to be written with string.gmatch and a capture group. gmatch yields
-- WHOLE matches, not captures, and with `^` it never advances, so the iterator
-- returned nil on the first step and hostOf returned its `or u` fallback —
-- the FULL URL.
--
-- The consequence was invisible and total. Every lookup was HOST_MEMO[nil] and
-- every write was HOST_MEMO[nil], so:
-- - the memo table never returned anything, for any host, ever
-- - the negative memo, the TTL and (later) the backoff were all dead code
-- - the 19 entries in this gateway's production state are unreachable: their
-- keys are full URLs, while a lookup asks for nil
-- Measured before the fix: hostOf(key) matched 0 of 19 production entries.
local function hostOf(u)
return (string.match(u, "^https?://([^/]+)")) or u
return (string.gmatch(u, "^https?://([^/]+)")()) or u
end
-- get walks a dotted path through nested tables, returning nil if any hop is
@ -106,17 +90,6 @@ local function getnum(root, path)
return n
end
-- strang reads a string field, returning nil rather than "". An empty string
-- and an absent field must stay distinguishable: the UI prints the first as
-- blank and the second as "未探测", and collapsing them loses that.
local function strang(root, path)
local v = get(root, path)
if v == nil then return nil end
if type(v) ~= "string" then return nil end
if v == "" then return nil end
return v
end
-- request performs one GET with the standard retry. Retrying is not
-- politeness here: several of these hosts sit behind DNS that intermittently
-- refuses, and a single 000 turned into "provider unknown" on every poll.
@ -232,247 +205,6 @@ local function openaiDashboard(base, key)
return rep, nil
end
-- copySet returns a shallow copy of a set-table, or nil when it is empty.
--
-- The copy matters: a memo entry outlives the call that wrote it, and storing
-- the same table would let a later write mutate a record already handed out.
-- nil for an empty set keeps "nothing was unready" out of the persisted state,
-- so the read side can use plain truthiness on the field.
local function copySet(t)
local out = nil
for k, v in pairs(t) do
out = out or {}
out[k] = v
end
return out
end
-- provisionalEntry reports whether a negative memo was written while some adapter
-- could not judge the host. Such an entry is retired the moment that adapter
-- becomes configured, and never backs off while it is still waiting.
--
-- An EMPTY `unready` is not provisional, and the emptiness has to be tested rather
-- than the presence. Two ways an empty set shows up: the write path drops empty
-- tables (so a host every adapter judged has no field at all), but a full JSON
-- round trip — restore, or one page open — turns an empty table back into a
-- truthy `{}`. Reading presence as the signal therefore makes exactly the hosts
-- that most need backing off the ones that never get it.
local function provisionalEntry(entry)
if type(entry) ~= "table" then return false end
if type(entry.unready) ~= "table" then return false end
return next(entry.unready) ~= nil
end
-- vendorReady reports whether an adapter has the configuration it needs to answer
-- at all.
--
-- OpenDesign is the first adapter that needs a credential the SOURCE does not
-- carry (prices.control_key). When it is missing the adapter should not run: with
-- no credential the only possible outcomes are a 401 (filed as an ERROR, "key
-- rejected") or a fabricated skip, and neither is a fact about the host. What it
-- would actually be reporting is that the deployment is not configured yet.
--
-- Per-VENDOR rather than one global flag, so a future adapter that needs its own
-- configuration gets the same treatment by adding a branch here.
local function vendorReady(vendor)
if vendor == "opendesign" then
local c = PLUGIN.prices
if type(c) ~= "table" or type(c.control_key) ~= "string" or c.control_key == "" then
-- The reason travels with the refusal: "adapter not configured" alone says
-- what is wrong but not what to type.
return false, "control_key not configured (set prices.control_key)"
end
end
-- Every other adapter carries its credential on the source itself.
return true, nil
end
-- resolveApiBase turns a configured source base_url into the OpenDesign billing
-- origin.
--
-- Split out as its own function because it is the one piece of this adapter that
-- is pure logic, and it is load-bearing while being silent when it breaks: the
-- probe simply goes to the inference host, which serves no billing route, and the
-- source quietly stays unlisted.
--
-- The host is taken as a HOST, not as the tail of a URL. base_url is normally
-- `https://amr-link.open-design.ai/v1`, and matching a `^...$` pattern against
-- that whole string never succeeds — the rewrite then silently does not happen.
-- That was a real defect here, found by asserting behaviour rather than source
-- text.
local function resolveApiBase(base, override)
if type(override) == "string" and override ~= "" then
return (override:gsub("/+$", ""))
end
local origin = base:gsub("^https?://", ""):gsub("/.*$", "")
origin = origin:gsub("^amr%-link%.open%-design%.ai$", "amr-api.open-design.ai")
return "https://" .. origin .. "/api/v1"
end
-- resolve_api_base exposes resolveApiBase for tests, so the derivation can be
-- asserted directly instead of inferred from which error a failed probe happened
-- to report. Declared AFTER the local function it calls — a Lua local is nil until
-- its assignment runs, and this closure would otherwise capture nil. Pure: no
-- network, no state.
PLUGIN.resolve_api_base = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
local base = tostring(payload.base_url or "")
if base == "" then return nil, "missing base_url" end
local override = payload.api_base
if type(override) ~= "string" then override = nil end
return { api_base = resolveApiBase(base, override) }, nil
end
-- openDesign probes OpenDesign / Vela (amr-link.open-design.ai).
--
-- This vendor needs a credential the source does not carry. Inference runs on
-- `runtimeKey`, but the billing endpoints on amr-api.open-design.ai only accept
-- `controlKey` — a second, separate secret handed out when the desktop app signs
-- in and stored in ~/.amr/config.json under profiles.prod.
--
-- Verified against the live service (200 on both, ~1.3s each):
-- GET https://amr-api.open-design.ai/api/v1/wallet/balance (controlKey)
-- {balanceUsd, expiresAt, workspaceId, ...}
-- GET https://amr-api.open-design.ai/api/v1/billing/summary (controlKey)
-- {creditsPerUsd, membershipTier, subscriptionStatus,
-- subscriptionCreditsUsd, rechargeCreditsUsd,
-- todayConsumedUsd, totalConsumedUsd, subscriptionCurrentPeriodEnd, ...}
--
-- The two keys are NOT interchangeable: sending runtimeKey to either endpoint
-- answers 401 {"error":"unauthenticated"}. That is why this adapter reads
-- PLUGIN.prices instead of reusing the source's api_key.
--
-- config location: `prices.control_key` in the plugin's persisted config (the
-- same file billing uses for its price table — the plugin system persists one
-- `prices` table per plugin, separate from `state`). Set it through the admin
-- API:
-- PUT /api/plugins/quota/state {"prices": {"control_key": "vela_ctrl_..."}}
-- `prices.api_base` is optional and overrides the amr-link -> amr-api host
-- derivation.
--
-- Three things this adapter deliberately does NOT do:
--
-- * It does not invent an exchange rate. creditsPerUsd is the only thing that
-- turns credits into dollars, and a missing or non-positive value is an
-- error, not a default. The vendor serves pre-computed *Usd fields too, so
-- the rate is only needed for the credit breakdown.
-- * It does not report a period window when the account has no Coding Plan.
-- `vela billing workspace-snapshot` returns billingState:"free" and NO
-- codingPlan.windows for such workspaces, and rendering "100% remaining"
-- from an absent field is the exact lie this plugin was written to avoid.
-- * It does not treat a zero balance as an exhausted plan. balanceUsd and the
-- period windows are different things; conflating them was a previous bug.
local function openDesign(base, key)
-- The source's own api_key is the RUNTIME key, which these endpoints reject.
-- The control key comes from the plugin's persisted config.
local cfg = PLUGIN.prices
local ck = nil
if type(cfg) == "table" then ck = cfg.control_key end
if type(ck) ~= "string" or ck == "" then
return nil, "skip", "control_key not configured (set prices.control_key)"
end
-- amr-link serves inference; amr-api serves billing. Rewriting the host here
-- rather than asking the operator to configure a second source keeps one
-- logical provider in the UI.
--
-- `api_base` overrides the derivation: the vendor has already moved these
-- hosts once, and an operator who can see the new one in a browser should not
-- be blocked on a code change to use it.
local api = resolveApiBase(base, type(cfg) == "table" and cfg.api_base or nil)
-- The gateway's outbound allowlist is the hosts of configured sources. It
-- therefore contains amr-link (the inference host) but NOT amr-api (the
-- billing host), and every request below would be refused with "host ... is
-- not an allowed outbound destination" — a message that reads like a plugin
-- bug rather than a missing allowlist entry. Add the derived host alongside
-- the source hosts in core.sourceHosts when this adapter is enabled.
local wallet, wErr = request(api .. "/wallet/balance", ck, nil, 1)
local summary, sErr = request(api .. "/billing/summary", ck, nil, 1)
if wallet == nil and summary == nil then
if (wErr or ""):match("key rejected") or (sErr or ""):match("key rejected") then
return nil, "error", "control_key rejected (HTTP 401)"
end
-- A 404 here means the host is not amr-link/amr-api at all; skip rather
-- than error so the memo does not blacklist an unrelated provider.
if (wErr or ""):match("HTTP 404") and (sErr or ""):match("HTTP 404") then
return nil, "skip", "no OpenDesign billing endpoints"
end
return nil, "skip", (wErr or sErr or "no billing response")
end
local w = wallet or {}
local b = summary or {}
local rep = {
vendor = "opendesign",
-- tonum would turn a missing balance into 0, i.e. "you are broke", which is
-- the failure mode this whole plugin exists to avoid. A balance that is
-- absent stays absent and the guard below turns it into a skip.
balance = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
balance_usd = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
tier = strang(b, "membershipTier"),
status = strang(b, "subscriptionStatus"),
interval = strang(b, "billingInterval"),
}
-- Credits breakdown. Converted with the vendor's OWN rate rather than
-- assuming 10000 — that is this vendor's current value and hardcoding it
-- would silently produce wrong numbers the day it changes.
local rate = getnum(b, "creditsPerUsd")
if rate and rate > 0 then
rep.credits_per_usd = rate
local sub = getnum(b, "subscriptionCredits")
local rec = getnum(b, "rechargeCredits")
if sub or rec then
rep.credits = (sub or 0) + (rec or 0)
rep.subscription_usd = sub and (sub / rate) or nil
rep.recharge_usd = rec and (rec / rate) or nil
end
rep.total_available_credits = getnum(b, "totalAvailableCredits")
else
-- No usable rate. Flag it instead of guessing; the *Usd fields below still
-- give a correct balance, so the card renders with the split missing.
rep.credits_rate_missing = true
end
-- The vendor's own pre-computed dollar amounts win over our conversion.
local sUsd = getnum(b, "subscriptionCreditsUsd")
local rUsd = getnum(b, "rechargeCreditsUsd")
if sUsd then rep.subscription_usd = sUsd end
if rUsd then rep.recharge_usd = rUsd end
-- Spend, shown next to the balance.
rep.today_used = getnum(b, "todayConsumedUsd")
rep.period_used = getnum(b, "totalConsumedUsd")
rep.total_recharged = getnum(b, "totalRechargedUsd")
rep.usage_count = getnum(b, "usageCount")
-- Wallet expiry: this balance does not last forever. Surfaced because a
-- balance that silently stops being spendable is worse than a warning.
rep.balance_expires_at = strang(w, "expiresAt")
rep.period_start_at = strang(b, "subscriptionCurrentPeriodStart")
rep.period_end_at = strang(b, "subscriptionCurrentPeriodEnd")
rep.workspace_id = strang(w, "workspaceId")
-- Period windows: deliberately NOT set. codingPlan.windows only exists for a
-- workspace on a Coding Plan, and this one reports billingState:"free".
-- Leaving rep.windows nil makes the page say "no period data" rather than
-- fabricating a 100% that an operator would read as "plenty left".
--
-- When a workspace does have a plan the windows arrive as
-- {durationSeconds, usedCredits, limitCredits, remainingCredits, resetsAt}
-- and need the same credits->USD conversion; that branch belongs here, gated
-- on the response actually carrying codingPlan.
if rep.balance == nil then
return nil, "skip", "no balanceUsd in response"
end
return rep, nil
end
-- commandCode keeps its own shape: rolling windows plus a billing period.
local function commandCode(base, key)
local H = {
@ -546,7 +278,6 @@ local ADAPTERS = {
{ vendor = "commandcode", probe = commandCode },
{ vendor = "deepseek", probe = deepseekBalance },
{ vendor = "openai-dash", probe = openaiDashboard },
{ vendor = "opendesign", probe = openDesign },
}
-- The probe memo remembers, per host, which adapter answered. Persisted in the
@ -567,52 +298,6 @@ local HOST_MEMO = {}
local HOST_MEMO_AT = {}
local PROBE_MEMO_TTL = 1800 -- 30 minutes
-- Exponential backoff for hosts that answer nothing.
--
-- A flat TTL is the wrong shape for a dead upstream. Measured on this gateway:
-- two hosts had been unreachable for 10 HOURS, and because PROBE_MEMO_TTL is 30
-- minutes their negative memo was stale on every single page open — so each
-- refresh re-paid the full cost of three adapters (measured: 24s per source
-- before the probe budget was fixed, 16s after). A host that is down should not
-- be re-examined every 30 minutes forever; each consecutive failure doubles the
-- wait, so a long-dead host costs almost nothing while a host that was briefly
-- down is retried within the TTL as before.
--
-- The cap matters as much as the doubling: without it a host that is down for a
-- week stops being probed at all, and the page would quietly and permanently
-- misreport it as having no balance endpoint.
--
-- SUCCESS resets the streak. This is only a probe-scheduling decision — a source
-- that stops answering is still shown, still counted, and still retried.
local BACKOFF_BASE = 1800 -- first retry after the usual TTL
local BACKOFF_FACTOR = 2 -- doubles per consecutive miss
local BACKOFF_MAX = 21600 -- 6 hours; beyond this we always re-probe
-- backoffSeconds returns how long to wait before re-probing a host that has
-- missed `streak` times in a row, and 0 when it is due now.
--
-- `at` is when the host last failed. streak is stored inside the memo table
-- itself so it survives a restart: a process restart must not reset the backoff
-- and re-pay the cost it was avoiding.
-- Returns a BOOLEAN and nothing else.
--
-- In Lua only nil and false are falsy, so every path here must return one of
-- those. Two versions of this got it wrong and both were caught by a test that
-- asserts the type: `return 0` for "no streak" (0 is truthy, so a host with no
-- streak — which is every memo written before this change — would be skipped
-- forever, silently), and `return BACKOFF_MAX` on the cap path (21600 is
-- truthy too, so a long-dead host would never be re-probed, which is the exact
-- failure the cap exists to prevent).
local function backoffSeconds(at, streak)
if streak == nil or streak <= 0 then return false end
local wait = BACKOFF_BASE
for _ = 2, streak do
wait = wait * BACKOFF_FACTOR
if wait >= BACKOFF_MAX then wait = BACKOFF_MAX break end
end
return (os.time() - (at or 0)) < wait
end
-- 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.
@ -629,24 +314,6 @@ local function collectSource(src, budget)
base = base:gsub("/+$", "")
local key = tostring(src.api_key or "")
local rep, kind, why
-- Vendors that could not judge the host because their OWN configuration is
-- missing. Stamped onto the negative memo so it can be retired the moment that
-- configuration arrives.
local unready = {}
-- A REJECTED credential outranks every other error, and the reason is the
-- only thing this variable is for.
--
-- Why the rule is not simply "last wins": the four adapters answer different
-- questions, and a general-purpose one (openDesign, which walks the vendor's
-- whole host list) can report "key rejected" while an adapter that merely could
-- not reach ITS host reports a transport error. The two need opposite
-- responses — re-issue the credential vs. investigate the network — and with a
-- plain "last wins" the credential problem is reported as a network one.
--
-- Conversely, "first error wins" is just as arbitrary in the other direction:
-- an adapter whose host is unreachable from this deployment fails before the
-- one that actually knows what is wrong.
local errWhy, errRejected = nil, false
-- Try the adapter this host answered with last time first. Same host with a
-- different key (two Command Code seats) gives the same answers, so this turns
-- 21 sources into a handful of distinct round trips after the first pass.
@ -658,59 +325,11 @@ local function collectSource(src, budget)
-- exactly the confusion that made an earlier revision drop five sources.
if type(known) == "table" then
local seen = known.at or 0
-- A memo written while an adapter could not judge the host is PROVISIONAL.
-- It is discarded as soon as that adapter becomes configured, and it never
-- earns a backoff while it is still waiting.
--
-- Why it must not back off: the memo is applied unconditionally, before any
-- adapter runs. Honouring a provisional entry refuses the very probe that is
-- now able to succeed, so the operator supplies the missing credential and
-- sees nothing change. Measured end to end before this: unset key → memo →
-- key set → probe skipped without a single request.
--
-- The comparison is against the vendors RECORDED in the entry, not against
-- all adapters. "Is any adapter configured" is true almost always — three of
-- the four carry their credential on the source — and would retire every
-- provisional entry immediately, including the ones still waiting.
local provisional = provisionalEntry(known)
local stale = false
if provisional then
for vendor in pairs(known.unready) do
if vendorReady(vendor) then stale = true end
end
end
if provisional then
if stale then
-- The missing configuration is here: derive the verdict again.
known = nil
else
-- Still waiting for it. Do NOT back off: the memo is applied before any
-- adapter runs, so honouring it would refuse the very probe that will
-- succeed once the credential arrives.
known = false
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
-- A streak is a host that has failed repeatedly, and the backoff is keyed on
-- the RECORDED STREAK, not on this entry existing.
--
-- Keying it on existence reads like a tautology that can never fire, but it
-- fires constantly, because of WHEN the write side runs: the entry is read at
-- the top of this function and only written at the very bottom, so mid-call
-- the table still holds the two adapters that already answered on THIS call
-- — typically the two with the shortest timeouts. LIVE DEFECT: the plugin
-- then reported "连续探测失败 1 次" for a streak that was never counted and
-- refused every later probe, so an operator who supplied the missing key saw
-- nothing change, indefinitely.
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false -- still "nothing answered, try cheapest first"
end
end
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
@ -754,32 +373,13 @@ local function collectSource(src, budget)
return { name = src.name, host = host, skipped = true,
reason = "probe budget exhausted" }
end
-- An adapter that cannot answer for want of its OWN configuration is not
-- probed at all. Running it would produce a 401 (reported as ERROR "key
-- rejected") or a fabricated skip, and would send the operator to re-issue a
-- credential they have not configured yet.
if not vendorReady(a.vendor) then
local _, because = vendorReady(a.vendor)
unready[a.vendor] = true
kind = "skip"
why = because or ("adapter not configured: " .. a.vendor)
else
local r, k, e = a.probe(base, key)
if r ~= nil then
rep = r
HOST_MEMO[host] = a.vendor
break
end
kind, why = k, e
if k == "error" then
local rejected = tostring(e or ""):match("rejected") ~= nil
-- Last error wins, except that a rejected credential displaces anything
-- already recorded and is never displaced in turn.
if not errRejected and (errWhy == nil or rejected) then
errWhy, errRejected = e, rejected
end
end
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
@ -794,80 +394,18 @@ local function collectSource(src, budget)
-- 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.
-- Record the negative result, and GROW THE STREAK.
--
-- The guard used to be `not HOST_MEMO[host]`, which is true only the FIRST time
-- a host fails: on every later failure the memo already held a table, the
-- guard was false, and nothing was written. The streak therefore stayed pinned
-- at 1 forever and the backoff never grew. Measured while writing this: the
-- memo read back `{at:... streak:1}` after four consecutive failures.
--
-- A success overwrites the memo with a vendor string, so a host that recovers
-- resets its streak automatically.
--
-- The entry records WHICH vendors could not judge the host. Such a verdict is
-- provisional (see the read side): the memo is applied before every later
-- probe, so keeping it unqualified suppresses the source even after the
-- credential is supplied.
--
-- It is still WRITTEN rather than omitted, because dropping it would put a
-- dashboard-less source back to 60s of adapter timeouts on every page open —
-- the cost the memo exists to remove. A memo cannot say "three of four
-- answered, the fourth is waiting for a key", so it says the next best thing.
--
-- Its streak also does not carry over from a previous provisional entry: that
-- count would be spent on a judgement nobody made.
--
-- `unready` is copied, and copySet returns nil for an EMPTY set — which is what
-- makes "every adapter judged the host" arrive as an absent field rather than as
-- an empty table. The read side tests for a NON-EMPTY set rather than for the
-- field's presence, so the two spellings of "nothing was unready" agree.
if rep == nil then
local prev = HOST_MEMO[host]
local streak = 1
local prevUnready = provisionalEntry(prev)
if type(prev) == "table" and not prevUnready then
streak = (prev.streak or 1) + 1
end
HOST_MEMO[host] = { at = os.time(), streak = streak, unready = copySet(unready) }
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
-- An ERROR is reported when either:
-- * the LAST adapter to judge the host reported one, or
-- * ANY adapter reported a REJECTED credential.
--
-- The second clause is not a refinement, it is the whole point. openDesign
-- walks the vendor's host list and is therefore the adapter most likely to
-- see "key rejected", but it also runs FIRST once it has been memoised as the
-- answering vendor — so a last-wins rule classifies its 401 by whatever the
-- next adapter says, and the next adapter (commandcode, pointed at the
-- source's own host) answers 404 "no dashboard here". Observed end to end:
-- a deliberately wrong control key was reported as "this host has no balance
-- endpoint", sending the operator to investigate the vendor instead of
-- re-issuing the credential. A rejection has one unambiguous remedy, so it
-- outranks position.
if kind == "error" or errRejected then
return { name = src.name, vendor = "", error = tostring(errWhy or why),
if kind == "error" then
return { name = src.name, vendor = "", error = tostring(why),
skipped = false, host = hostOf(base) }
end
-- The skip REASON travels. Discarding it here is why
-- TestQuotaOpenDesignSkipsWithoutControlKey could not tell a configured
-- adapter that refused from a source that never reached it: both arrived as
-- {skipped=true}, so a mutation that ignored the missing credential was
-- invisible at this boundary. "Not configured" is actionable (set the key);
-- "no balance endpoint" is not.
local out = { name = src.name, skipped = true, host = hostOf(base) }
if why and why ~= "" then out.reason = tostring(why) end
-- `unready` travels with the record because the page fans out on shard VMs
-- and reports back through memo_record, where a row with no vendor and no
-- error is otherwise indistinguishable from a real failure. Without it the
-- sharded path — the one the page actually uses — would still earn the source
-- a backoff it should never have had.
if next(unready) ~= nil then out.unready = true end
return out
return { name = src.name, skipped = true, host = hostOf(base) }
end
rep.name = src.name
rep.host = hostOf(base)
@ -883,112 +421,6 @@ end
-- 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.
-- due_sources answers, on the SHARED VM, which sources are worth probing right
-- now — and it makes NO network requests.
--
-- This exists because of a conflict between two facts:
--
-- - Probing is network-bound, and Plugins.Call holds the plugin mutex for the
-- whole Lua function, so twenty probes through /call/ serialise. Measured:
-- 29s on a 20-source gateway. The fix is ShardCall, which runs each call on
-- its own VM — and a shard VM has its own PLUGIN.state.
-- - The backoff lives in PLUGIN.state (HOST_MEMO). A shard therefore cannot
-- see it, cannot record it, and a host that fails keeps being probed on every
-- page open. Measured: fanout left the memo at 0 entries, collect_all reached
-- 17.
--
-- So the split is: the DECISION runs where the state is (one shared-VM call,
-- microseconds), and only the hosts that pass it get sharded.
--
-- Cheap on purpose. It reads HOST_MEMO and nothing else; if the state has not
-- been restored yet, every host is due, which is the correct answer for a first
-- call.
-- memo_record writes the outcome of a sharded pass into the SHARED VM's memo.
--
-- A shard runs on its own VM and therefore cannot record anything: measured, a
-- page of fanouts left the memo at 0 entries, so the backoff never accumulated
-- and every refresh re-probed the dead hosts. This is the other half of
-- due_sources — the decision is taken here, the outcome is written back here, and
-- only the network wait happens on the shards.
--
-- It records FAILURES as well as successes. Recording only successes is what
-- left every source with no balance endpoint re-probing three adapters forever.
PLUGIN.memo_record = 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 rows = payload.results
if type(rows) ~= "table" then return nil, "missing results" end
local now = os.time()
for _, r in ipairs(rows) do
if type(r) == "table" and type(r.host) == "string" and r.host ~= "" then
local host = hostOf(r.host)
if r.vendor then
-- A source that answered resets its streak: the memo becomes a vendor
-- string, which every reader already treats as a positive memo.
HOST_MEMO[host] = r.vendor
else
local prev = HOST_MEMO[host]
local streak = 1
-- A provisional entry is replaced, not aged: its count describes a
-- judgement nobody made. See the write side in collectSource.
if type(prev) == "table" and not prev.unready then
streak = (prev.streak or 1) + 1
end
-- The page can only send a boolean (it does not know which adapter was
-- unready), so the set is re-derived here on the shared VM. Copying it
-- matters: this table is stored, and a later write must not mutate it.
local u = nil
if r.unready then
for _, a in ipairs(ADAPTERS) do
if not vendorReady(a.vendor) then
u = u or {}
u[a.vendor] = true
end
end
end
HOST_MEMO[host] = { at = now, streak = streak, unready = u }
end
end
end
PLUGIN.state.probe_memo = HOST_MEMO
return true
end
PLUGIN.due_sources = 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 due, backing = {}, {}
for _, s in ipairs(srcs) do
if type(s) == "table" and type(s.base_url) == "string" and s.base_url ~= "" then
local base = (s.base_url:gsub("/+$", ""))
local known = HOST_MEMO[hostOf(base)]
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
-- skipped matters: the page filters on it. Without the flag a backed-off
-- host reaches the card renderer, which has no field to match and falls
-- through to its "has data" default — so thirteen dead upstreams were
-- displayed as if they had answered.
backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
else
due[#due + 1] = s
end
end
end
return { due = due, backing_off = backing }
end
PLUGIN.collect_all = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
@ -999,15 +431,10 @@ PLUGIN.collect_all = function(payload)
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
-- os.time, NOT os.clock. os.clock measures CPU time, and a probe spends
-- essentially none: measured on this gateway, 3s blocked on an HTTP request
-- advanced os.clock by 0.001s. A budget written in os.clock therefore never
-- fires, which is why a dead upstream ran the full
-- 4s timeout x 2 retries x 3 adapters = 24s against a "12 second" budget.
local t1 = os.time()
local t1 = os.clock()
local lim1 = PROBE_BUDGET_MS / 1000
local function spent()
return (os.time() - t1) > lim1
return (os.clock() - t1) > lim1
end
local budget = { exhausted = spent }
local out = {}
@ -1036,10 +463,10 @@ PLUGIN.collect_source = function(payload)
-- 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.time()
local t0 = os.clock()
local limit = PROBE_BUDGET_MS / 1000
local function exhausted()
return (os.time() - t0) > limit
return (os.clock() - t0) > limit
end
local budget = { exhausted = exhausted }
for _, s in ipairs(srcs) do
@ -1226,14 +653,7 @@ PLUGIN.ui = {
}
if(r.hard_limit!==undefined && r.hard_limit!==null)
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
// Nothing here matched. Say so.
//
// The old default was a neutral "有数据", which is a claim: it says the probe
// answered. A record that reached this point answered with no field the page
// knows — a back-off notice, an error shape we do not recognise, or a vendor
// that added a new response. Calling that "has data" is how thirteen dead
// upstreams ended up rendered as healthy. Absence of evidence is not evidence.
return {cls:"unknown", pill:'<span class="qa-pill">未探测</span>'};
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
}
function card(r){
var st=state(r);
@ -1246,13 +666,6 @@ PLUGIN.ui = {
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
return h;
}
// A record with nothing but a reason has no balance, no month, no windows —
// the card renders as a bare header with an empty body. Say why instead.
if(r.reason && !r.error && (r.balance===undefined||r.balance===null)
&& (r.hard_limit===undefined||r.hard_limit===null)){
h+='<div class="qa-fail">'+escH(r.reason)+'</div>';
return h+'</div>';
}
h+=balLine(r);
var rows="";
rows+=month(r);
@ -1277,10 +690,7 @@ PLUGIN.ui = {
var rs = (st && st.reports) || [];
if(!rs.length) return false;
var real = rs.filter(function(r){ return r && !r.skipped; });
// Records, not names — same reason as the refresh path. Both call sites were
// mapping to names while render() was changed to group on the flag; fixing
// one and shipping it is what produced a footer with thirteen blank entries.
var skipped = 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){
@ -1297,30 +707,10 @@ PLUGIN.ui = {
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
} else {
host.innerHTML=real.map(card).join("");
if(skipped && skipped.length){
// Two reasons a source has no card, and they are not interchangeable.
// "This upstream exposes no balance endpoint" and "this upstream is
// backing off after failing" are different facts; collapsing them into
// one list tells the operator their dead host simply has no dashboard.
var backing = skipped.filter(function(r){ return r.backing_off===true; });
var silent = skipped.filter(function(r){ return r.backing_off!==true; });
if(backing.length){
host.innerHTML+='<div class="qa-skip">'+backing.length+
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
}
if(silent.length){
// Name the reason per source instead of assuming every silent skip
// means "no balance endpoint". A source that skipped because its
// credential is missing is listed the same way but needs a
// completely different fix, and an operator reading a flat list of
// names has no way to tell them apart.
var why = silent.map(function(r){
return r.reason ? (r.name+"("+r.reason+")") : r.name;
}).join("、");
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
' 个源:'+escH(why)+'</div>';
}
}
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+" 个读取失败";
@ -1342,39 +732,15 @@ PLUGIN.ui = {
var sub=document.getElementById("qa-sub"); if(sub) sub.textContent="探测中…";
var out;
try{
// ONE request, fanned out inside the gateway.
//
// The previous version fired Promise.all at /call/ — twenty concurrent
// browser requests. That looked concurrent and was not: each one queued on
// the plugin mutex for the whole Lua call, network wait included. Measured on
// the live gateway: 29s of wall clock for twenty probes, against 1.4–3.3s for
// a single probe with no contention. The page sat on "探测中…" for half a
// minute and read as broken.
//
// The comment that used to sit here claimed concurrency avoided a call budget
// overrun. It could not have: the requests were serialised, so the budget they
// overran was the very same one. It is a single call now because a
// request-per-item fan-out cannot be parallel from the client at all.
// Which sources are worth probing is decided on the SHARED VM, where the
// backoff state lives; only those get sharded.
//
// Asking each shard to decide for itself does not work: a shard has its own
// PLUGIN.state, so it cannot see the memo and a host that keeps failing is
// re-probed on every refresh forever. Measured before this split: fanout left
// the memo at 0 entries and every refresh cost the full 12s.
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
// An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
// so `|| []` does not replace it. An empty object reaching out.concat()
// throws, and the whole refresh reports a failure. Array.isArray is the check
// that matches what the plugin actually returns.
var due = (plan && Array.isArray(plan.due)) ? plan.due : [];
var out = (plan && Array.isArray(plan.backing_off)) ? plan.backing_off : [];
if (due.length) {
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
return {sources:[s],name:s.name};
})));
}
// 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.
@ -1383,24 +749,6 @@ PLUGIN.ui = {
if(r && !r.skipped && !r.error && r.host && r.vendor) memo[r.host] = r.vendor;
});
window.__qaMemo = memo;
// Hand the whole pass back to the SHARED VM so the backoff has something to
// work from next time. Without this the shards' results die with their VMs:
// measured, the memo stayed at 0 entries and every refresh paid full price.
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
if(!r) return null;
// A host that is merely BACKING OFF has not just failed; recording it as a
// failure would grow the streak on a probe that never happened. Keyed on the
// flag rather than the wording, so translating the message cannot silently
// turn every skipped host into an ever-growing streak.
if(r.backing_off===true) return null;
// A host probed while an adapter had no configuration was not judged.
// Recording it would earn a backoff that outlives the configuration
// mistake and suppresses the probe that would now succeed.
if(r.unready===true) return null;
if(r.error) return {host:r.host};
if(r.skipped) return {host:r.host};
return {host:r.host, vendor:r.vendor};
}).filter(Boolean)});
}catch(e){
if(btn) btn.disabled=false;
if(sub) sub.textContent="探测失败: "+e.message;
@ -1408,11 +756,7 @@ PLUGIN.ui = {
}
var all=out||[];
var real=all.filter(function(r){return !r.skipped;});
// The records, not their names — render() groups skipped sources by the
// backing_off flag, which a bare name cannot carry. This call site was left
// mapping to names while the other was fixed, and the mismatch showed up as
// a footer listing thirteen unnamed sources.
var skipped=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;

View File

@ -1,662 +0,0 @@
package lua
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The backoff lives in quota.lua — a shipped plugin, not Go — so these tests read
// the file that actually ships (readShippedPlugin) rather than a copy that can
// drift from it.
// TestQuotaBackoffIsAppliedInSource guards the wiring.
//
// It exists because of a mutation that removed the call site entirely (`if false
// then`) and every other test in this file still passed. The other tests cover
// the decision FUNCTION and the streak RECORDING; neither exists in order to be
// unused. A backoff nobody consults is indistinguishable from no backoff, and the
// symptom is the original one — a dead host re-probed on every page open.
func TestQuotaBackoffIsAppliedInSource(t *testing.T) {
src := readShippedPlugin(t, "quota.lua")
if !strings.Contains(src, "if backoffSeconds(seen, known.streak) then") {
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
"the backoff is computed but not applied")
}
// The reason is required, but matched on the FLAG rather than the wording:
// the message is localised, and grouping by text would merge "backing off"
// into "exposes no balance endpoint" the moment someone translates it.
if strings.Count(src, "reason = \"连续探测失败 ") < 2 {
t.Error("both skip paths (collectSource and due_sources) must carry a reason; " +
"a silently skipped host looks exactly like a host with no balance endpoint")
}
if strings.Count(src, "backing_off = true") < 2 {
t.Error("both skip paths must set the explicit backing_off flag; the page " +
"groups skipped sources by it, not by the message text")
}
// The recording guard has to REPLACE the memo, not merge into it. The bug this
// guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a
// host's first failure — so the streak stayed pinned at 1 forever. Asserted on
// the source because the mutation that reintroduces it leaves the visible
// behaviour of the FIRST collect unchanged, and that is all the integration
// test can reach inside one process.
if !strings.Contains(src, "if rep == nil then") {
t.Error("the negative result is recorded under a guard that skips every " +
"failure after the first — the streak will never grow")
}
if strings.Contains(src, "and not HOST_MEMO[host] then") {
t.Error("the recording guard excludes a host that already has a memo, so " +
"consecutive failures are never counted")
}
}
// TestQuotaBackoffWindowIsCapped guards the failure mode an exponential backoff
// has when it is not capped: a host that is down for a week stops being probed
// and the page permanently misreports it as having no balance endpoint.
func TestQuotaBackoffWindowIsCapped(t *testing.T) {
src := readShippedPlugin(t, "quota.lua")
for _, want := range []string{"BACKOFF_BASE", "BACKOFF_FACTOR", "BACKOFF_MAX"} {
if !strings.Contains(src, want) {
t.Errorf("quota.lua no longer declares %s — the backoff was weakened or removed", want)
}
}
}
// TestQuotaBackoffWindows walks the whole policy through the plugin's own
// decision function: for each consecutive failure, how long the host must be left
// alone, that it is still skipped halfway through that window, that it is retried
// once the window passes, and that an absurdly old host is always due.
//
// Why the function rather than the running plugin: HOST_MEMO is a process-local
// cache, restored on a process's first collect and written back afterwards, so
// ageing its memo from Go changes nothing the plugin can see. Two earlier versions
// of this file spent a long time on exactly that dead end, and a test that keeps
// breaking for reasons unrelated to the code under test is worse than no test.
//
// The function is the plugin's own — reached through a shim that appends one
// export — so changing quota.lua's arithmetic moves this test. It is not a
// reimplementation that would agree with a broken plugin.
func TestQuotaBackoffWindows(t *testing.T) {
ps := quotaWithDecision(t)
// streak, wait seconds. The last rows are at and past the ceiling.
for _, c := range []struct {
streak int
wait int
}{
{1, 1800},
{2, 3600},
{3, 7200},
{4, 14400},
{5, 21600},
{9, 21600},
{20, 21600},
} {
if got := quotaSkip(t, ps, c.streak, c.wait/2); !got {
t.Logf(" streak=%d age=%d → %v", c.streak, c.wait/2, got)
t.Errorf("streak=%d: not skipping %ds into a %ds window — the wait is too short",
c.streak, c.wait/2, c.wait)
}
if quotaSkip(t, ps, c.streak, c.wait+60) {
t.Errorf("streak=%d: still skipping %ds after its %ds window expired — "+
"this host would never be retried again", c.streak, c.wait+60, c.wait)
}
if quotaSkip(t, ps, c.streak, 1<<30) {
t.Errorf("streak=%d: a host silent for decades is still being skipped", c.streak)
}
}
}
// TestQuotaBackoffReturnsOnlyBooleans pins a type rule that produced two silent
// bugs.
//
// In Lua only nil and false are falsy, so every path of a predicate must return
// one of those. This function had two paths returning numbers: `return 0` for
// "no streak", which is truthy and made every caller skip a host that had never
// been probed; and `return BACKOFF_MAX` on the cap path, also truthy, which made
// the ceiling mean "never retried" — the exact failure the ceiling exists to
// prevent.
//
// Both shipped far enough to be found by this assertion rather than by a user
// staring at a page that had quietly stopped probing anything.
func TestQuotaBackoffReturnsOnlyBooleans(t *testing.T) {
ps := quotaWithDecision(t)
for _, c := range []struct {
name string
age int
strk interface{}
}{
{"no streak field (every memo written before the backoff existed)", 0, nil},
{"zero streak", 0, 0},
{"negative streak", 0, -1},
{"inside the window", 10, 1},
{"outside the window", 999999, 1},
{"at the ceiling", 10, 20},
} {
out, err := ps.Call("quota", "__decision", map[string]interface{}{
"at": float64(time.Now().Unix() - int64(c.age)),
"streak": c.strk,
})
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
// A bare boolean is a valid answer: when backoffSeconds returns false the
// shim's table round-trips as just `false`, because a Lua table holding a
// single falsy field collapses on encode.
switch v := out.(type) {
case bool:
_ = v // a boolean is exactly what this asserts
case map[string]interface{}:
if _, ok := v["r"].(bool); !ok {
t.Errorf("%s: backoffSeconds returned %T (%v) — in Lua a number is "+
"TRUTHY, so this caller would skip the host", c.name, v["r"], v["r"])
}
default:
t.Errorf("%s: decision returned %T (%v); want a boolean", c.name, out, out)
}
}
}
// TestQuotaBackoffRecordsConsecutiveFailures covers the recording guard through
// the running plugin, because that part IS observable from outside.
//
// The guard used to be `not HOST_MEMO[host]`, true only on a host's first
// failure. After that the memo held a table, the guard was false, nothing was
// written, and the streak stayed pinned at 1 forever — measured by reading back
// {at:... streak:1} after four consecutive failures.
//
// Only two things are asserted, because only two are reachable in one process:
// a first failure records streak 1, and a SKIP does not grow it. A skipped probe
// is not a failure, and a skip that counted would let a never-probed host grow
// without end and never be retried.
func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) {
srv := newCountingServer(t)
ps := newQuotaPlugins(t, srv.URL)
// Configure every vendor that needs its own credentials, so no adapter is
// UNABLE to judge the host. That is a precondition, not decoration: a host
// judged while an adapter is unconfigured gets a PROVISIONAL memo, and a
// provisional memo deliberately never backs off (it would refuse the very
// probe that succeeds once the credential arrives). Without this the test
// would be measuring that rule instead of the ordinary backoff.
if err := ps.SetState("quota", map[string]interface{}{
"prices": map[string]interface{}{"control_key": "ctrl"},
}); err != nil {
t.Fatal(err)
}
args := map[string]interface{}{
"sources": []interface{}{map[string]interface{}{
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
}},
"name": "dead",
}
streak := func() float64 {
st, _ := ps.State("quota").(map[string]interface{})
memo, _ := st["probe_memo"].(map[string]interface{})
for _, v := range memo {
if e, ok := v.(map[string]interface{}); ok {
if f, ok := e["streak"].(float64); ok {
return f
}
}
}
t.Fatalf("no negative memo recorded: %#v", memo)
return 0
}
if _, err := ps.Call("quota", "collect_source", args); err != nil {
t.Fatal(err)
}
if got := streak(); got != 1 {
t.Fatalf("streak after the first failure = %.0f, want 1", got)
}
before := srv.Hits()
if _, err := ps.Call("quota", "collect_source", args); err != nil {
t.Fatal(err)
}
if served := srv.Hits() - before; served != 0 {
t.Errorf("a host inside its backoff window still made %d requests — the "+
"whole feature is to not make any", served)
}
if got := streak(); got != 1 {
t.Errorf("a SKIP changed the streak to %.0f — a skipped probe is not a failure", got)
}
}
// TestQuotaMemoKeyIsTheHost pins the key the memo is written and read under.
//
// This is the foundation the whole memoisation rests on, and it was broken in the
// most total way possible: hostOf was written with string.gmatch and a capture
// group, gmatch yields whole matches rather than captures, and with `^` the
// iterator never advanced — so it returned nil and every lookup and every write
// went to HOST_MEMO[nil]. The memo therefore never returned anything for any
// host, and the negative memo, the TTL and the backoff on top of them were all
// dead code that looked alive.
//
// Measured on this gateway before the fix: hostOf(key) matched 0 of the 19
// entries in production state.
//
// The check is on the shipped source plus its runtime behaviour, because the
// failure mode is invisible from outside — a memo that never matches anything
// behaves exactly like a memo that has nothing to remember.
func TestQuotaMemoKeyIsTheHost(t *testing.T) {
src := readShippedPlugin(t, "quota.lua")
if !strings.Contains(src, "string.match(u,") {
t.Error("hostOf does not use string.match — string.gmatch yields whole " +
"matches, so the capture group is ignored and the function returns nil")
}
ps := quotaWithDecision(t)
for _, c := range []struct{ in, want string }{
{"https://qijiar.top/v1", "qijiar.top"},
{"https://api.deepseek.com", "api.deepseek.com"},
{"http://127.0.0.1:19900", "127.0.0.1:19900"},
{"https://s2a.frankhan.top:14514/v1", "s2a.frankhan.top:14514"},
} {
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": c.in})
if err != nil {
t.Fatalf("hostOf(%s): %v", c.in, err)
}
mm, _ := out.(map[string]interface{})
got, _ := mm["h"].(string)
if got != c.want {
t.Errorf("hostOf(%q) = %q, want %q", c.in, got, c.want)
}
if got == "" {
t.Errorf("hostOf(%q) returned nothing — every lookup would be "+
"HOST_MEMO[nil]", c.in)
}
}
// The same host under two paths must produce the SAME key, which is the whole
// reason the memo is keyed this way.
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/a/v1"})
if err != nil {
t.Fatal(err)
}
out2, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/b/v1"})
if err != nil {
t.Fatal(err)
}
m1, _ := out.(map[string]interface{})
m2, _ := out2.(map[string]interface{})
if m1["h"] != m2["h"] {
t.Errorf("two paths on one host produced different keys: %v and %v — "+
"they would never share a memo", m1["h"], m2["h"])
}
}
// --- helpers ---
// readShippedPlugin returns the bundled source, so the tests exercise the file
// that actually ships rather than a copy that can drift from it.
func readShippedPlugin(t *testing.T, name string) string {
t.Helper()
b, err := bundledAdapters.ReadFile("plugins/" + name)
if err != nil {
t.Fatalf("read embedded %s: %v", name, err)
}
return string(b)
}
// quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an
// export. The plugin's own exports list is appended to, so the export allowlist is
// satisfied exactly as it is in production.
func quotaWithDecision(t *testing.T) *Plugins {
t.Helper()
src := readShippedPlugin(t, "quota.lua")
shim := strings.Replace(src, "return PLUGIN",
"PLUGIN.exports[#PLUGIN.exports+1] = '__decision'\n"+
// Wrapped, not exported raw: backoffSeconds takes (at, streak)
// positionally, and the test calls it with a table because that is the
// shape a Go caller can pass. Exporting the raw function made every
// assertion read `at = <the argument table>, streak = nil`, so the
// function took its "no streak" path and returned false for every input
// — which looked exactly like a backoff that never fires.
"PLUGIN.__decision = function(q) return { r = backoffSeconds(q.at, q.streak) } end\n"+
"PLUGIN.exports[#PLUGIN.exports+1] = '__hostof'\n"+
"PLUGIN.__hostof = function(q) return { h = hostOf(q.u) } end\n"+
"return PLUGIN", 1)
if shim == src {
t.Fatal("could not inject the decision shim — quota.lua no longer ends with 'return PLUGIN'")
}
return newQuotaPluginsWith(t, shim, "127.0.0.1")
}
// quotaSkip asks the plugin whether a host that failed `streak` times and was
// last heard from `age` seconds ago should be skipped.
func quotaSkip(t *testing.T, ps *Plugins, streak, age int) bool {
out, err := ps.Call("quota", "__decision", map[string]interface{}{
"at": float64(time.Now().Unix() - int64(age)),
"streak": float64(streak),
})
if err != nil {
t.Fatalf("decision: %v", err)
}
// A single-field table collapses to the bare value on encode, so both shapes
// mean the same thing here.
switch v := out.(type) {
case bool:
return v
case map[string]interface{}:
b, ok := v["r"].(bool)
if !ok {
t.Fatalf("decision returned %T, not a boolean: %#v", v["r"], v)
}
return b
default:
t.Fatalf("decision returned %T (%v), not a boolean", out, out)
return false
}
}
// newQuotaPlugins boots the shipped quota.lua against a temp plugin dir.
func newQuotaPlugins(t *testing.T, baseURL string) *Plugins {
t.Helper()
return newQuotaPluginsWith(t, readShippedPlugin(t, "quota.lua"), baseURL)
}
func newQuotaPluginsWith(t *testing.T, src, baseURL string) *Plugins {
t.Helper()
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, pluginDir)
t.Cleanup(ps.Close)
ps.SetOutboundHosts([]string{"127.0.0.1"})
if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
p := ps.find("quota")
if p == nil {
t.Fatal("quota plugin did not load")
}
if p.LoadError != "" {
t.Fatalf("quota plugin failed to compile: %s", p.LoadError)
}
_ = baseURL
return ps
}
// countingServer answers 404 on every path and counts what it is asked, so a test
// can tell "the plugin skipped" (no requests) from "the plugin probed and found
// nothing" (requests, no data).
//
// It used to answer 200 with a non-balance body, and the commandcode adapter
// accepted that — so the host was memoised as a SUCCESS and the negative path was
// never reached. The test then failed for the right reason, and would have
// passed for the wrong one had the assertion been looser.
type countingServer struct {
*httptest.Server
hits int
}
func (c *countingServer) Hits() int { return c.hits }
func newCountingServer(t *testing.T) *countingServer {
t.Helper()
cs := &countingServer{}
cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cs.hits++
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"not found"}`))
}))
t.Cleanup(cs.Close)
return cs
}
// TestQuotaDueSourcesAndMemoRecordCoverTheShardGap pins the split that makes the
// backoff work at all under sharding.
//
// The conflict, measured on a live 20-source gateway:
// - probing is network-bound and Plugins.Call holds the plugin mutex for the
// whole Lua call, so /call/ serialises (29s)
// - so the page uses ShardCall — and a shard has its own PLUGIN.state
// - and the backoff lives in PLUGIN.state: fanout left the memo at 0 entries,
// so dead hosts were re-probed forever
//
// The resolution is to put the DECISION and the RECORDING back on the shared VM
// (microseconds, no network) and shard only the network wait. These two exports
// are that seam, and without them the fanout path silently defeats the backoff.
func TestQuotaDueSourcesAndMemoRecordCoverTheShardGap(t *testing.T) {
src := readShippedPlugin(t, "quota.lua")
for _, want := range []string{"due_sources", "memo_record"} {
if !strings.Contains(src, want) {
t.Errorf("quota.lua does not export %s — a sharded pass cannot see the "+
"backoff state, nor leave any behind", want)
}
if !strings.Contains(src, `"`+want+`"`) {
t.Errorf("%s is not listed in PLUGIN.exports, so the page cannot call it", want)
}
}
// due_sources must not probe. If it did, the whole point of asking it first
// would be lost: the cost would be paid before any shard runs.
if strings.Contains(src, "due_sources = function(payload)") &&
strings.Contains(src, "due_sources") {
// The body is small; check it does not call collectSource.
i := strings.Index(src, "due_sources = function(payload)")
j := strings.Index(src[i:], "\nend\n")
if j > 0 {
body := src[i : i+j]
if strings.Contains(body, "collectSource(") {
t.Error("due_sources calls collectSource — the filter must make no " +
"network request, or it costs the same as probing everything")
}
}
}
}
// TestQuotaMemoRecordGrowsTheStreakFromShards covers the recording half: a host
// that failed inside a shard must end up in the SHARED memo with a streak, or the
// next refresh has nothing to back off from.
func TestQuotaMemoRecordGrowsTheStreakFromShards(t *testing.T) {
srv := newCountingServer(t)
ps := newQuotaPlugins(t, srv.URL)
host := hostOnly(srv.URL)
read := func() map[string]interface{} {
st, _ := ps.State("quota").(map[string]interface{})
memo, _ := st["probe_memo"].(map[string]interface{})
return memo
}
// A shard reports a host that answered with nothing.
for i := 1; i <= 3; i++ {
out, err := ps.Call("quota", "memo_record", map[string]interface{}{
"results": []interface{}{
map[string]interface{}{"host": srv.URL + "/v1"},
},
})
if err != nil {
t.Fatalf("memo_record %d: %v", i, err)
}
if out != true {
t.Fatalf("memo_record returned %#v", out)
}
}
memo := read()
e, ok := memo[host].(map[string]interface{})
if !ok {
t.Fatalf("memo has no entry for %s: %#v", host, memo)
}
if got, _ := e["streak"].(float64); got != 3 {
t.Errorf("streak after three shard failures = %.0f, want 3", got)
}
// A host that answers is recorded as a positive memo, which resets the streak.
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
"results": []interface{}{
map[string]interface{}{"host": srv.URL + "/v1", "vendor": "deepseek"},
},
}); err != nil {
t.Fatal(err)
}
if got := read()[host]; got != "deepseek" {
t.Errorf("a host that answered is memoised as %#v, want the vendor name — "+
"the streak must not survive a success", got)
}
}
// TestQuotaDueSourcesSkipsBackedOffHosts is the behaviour the page depends on:
// the filter must keep a backed-off host out of the sharded set.
func TestQuotaDueSourcesSkipsBackedOffHosts(t *testing.T) {
srv := newCountingServer(t)
ps := newQuotaPlugins(t, srv.URL)
host := hostOnly(srv.URL)
// One failure, which is enough to put the host inside its window.
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
}); err != nil {
t.Fatal(err)
}
// A SECOND host, so the two cases are genuinely independent. The memo is keyed
// by host, so two paths on one host are one entry — using the same server here
// would have asserted that a per-host memo behaves per-path.
other := newCountingServer(t)
srcs := []interface{}{
map[string]interface{}{"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k"},
map[string]interface{}{"name": "fresh", "base_url": other.URL + "/v1", "api_key": "k"},
}
out, err := ps.Call("quota", "due_sources", map[string]interface{}{"sources": srcs})
if err != nil {
t.Fatal(err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("due_sources returned %#v", out)
}
due, _ := m["due"].([]interface{})
backing, _ := m["backing_off"].([]interface{})
if len(backing) != 1 {
t.Errorf("backing_off = %#v, want exactly the host that failed", backing)
} else if b, _ := backing[0].(map[string]interface{}); b["host"] != host {
t.Errorf("backing off %v, want %s — the key must be the host, or it will "+
"never match on the next refresh", b["host"], host)
}
if len(due) != 1 {
t.Errorf("due = %#v, want only the unprobed host", due)
} else if d, _ := due[0].(map[string]interface{}); d["name"] != "fresh" {
t.Errorf("due contains %v, want fresh", d["name"])
}
// And it must make no network request — the whole saving depends on it.
if n := srv.Hits(); n != 0 {
t.Errorf("due_sources made %d requests; it must decide without probing", n)
}
}
// hostOnly mirrors the plugin's own hostOf. Used only to CHECK what the plugin
// wrote — seeding a memo under a different key produces a memo the plugin never
// looks up, which cost this file a couple of rounds before it was noticed.
func hostOnly(baseURL string) string {
u := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")
if i := strings.Index(u, "/"); i >= 0 {
u = u[:i]
}
return u
}
// TestQuotaBackoffRecordIsSkippedAndFlagged pins the shape the page depends on.
//
// Two things went wrong in the same field, and both were silent:
//
// 1. The back-off record had no `skipped` flag, so the page's
// `filter(r => !r.skipped)` let it through to the card renderer. That
// renderer matches on known fields, and a record carrying only a reason
// matches none — so it fell through to a default that said "有数据".
// Thirteen dead upstreams were rendered as though they had answered.
// 2. Once it did carry `skipped`, the page grouped skipped sources by reading
// the REASON TEXT. Translating that string would have silently merged
// "backing off" into "exposes no balance endpoint" — a different claim
// about the upstream. The grouping keys on an explicit flag.
func TestQuotaBackoffRecordIsSkippedAndFlagged(t *testing.T) {
srv := newCountingServer(t)
ps := newQuotaPlugins(t, srv.URL)
// One failure, enough to put the host inside its window.
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
}); err != nil {
t.Fatal(err)
}
out, err := ps.Call("quota", "due_sources", map[string]interface{}{
"sources": []interface{}{map[string]interface{}{
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
}},
})
if err != nil {
t.Fatal(err)
}
m, _ := out.(map[string]interface{})
backing, _ := m["backing_off"].([]interface{})
if len(backing) != 1 {
t.Fatalf("backing_off = %#v, want one record", m["backing_off"])
}
rec, _ := backing[0].(map[string]interface{})
if rec["skipped"] != true {
t.Errorf("back-off record has skipped=%v — the page filters cards on this "+
"field, and without it the record is rendered as a source with data",
rec["skipped"])
}
if rec["backing_off"] != true {
t.Errorf("back-off record has no explicit backing_off flag; the page groups " +
"by it instead of by reason text, so translating the message cannot " +
"merge this with 'no balance endpoint'")
}
if s, _ := rec["reason"].(string); s == "" {
t.Error("back-off record carries no reason; the card renders empty")
}
}
// TestQuotaRenderCallSitesPassRecordsNotNames guards a bug that only appeared
// because ONE of two identical call sites was fixed.
//
// render() groups skipped sources by the backing_off flag. A call site that maps
// the records to their names first ("skipped.map(r => r.name)") therefore hands
// render an array of strings: the flag is gone, every entry falls into the
// "exposes no balance endpoint" bucket, and the footer lists N unnamed sources.
//
// The first fix changed only the refresh path. The snapshot path kept mapping to
// names, and the symptom was a footer reading "未列出 13 个源:、、、、…" — which
// reads like a rendering bug rather than a contract mismatch between two call
// sites of one function.
func TestQuotaRenderCallSitesPassRecordsNotNames(t *testing.T) {
src := readShippedPlugin(t, "quota.lua")
if strings.Contains(src, ".map(function(r){ return r.name; })") ||
strings.Contains(src, ".map(function(r){return r.name;})") {
// A benign occurrence would be fine, but there is no other reason for a
// skipped list to be reduced to names, so treat any as the bug.
for _, line := range strings.Split(src, "\n") {
if strings.Contains(line, "skipped") && strings.Contains(line, ".map(") &&
strings.Contains(line, "r.name") {
t.Errorf("a skipped list is mapped to names before reaching render(): %s\n"+
"render() groups on the backing_off flag, which a name cannot carry",
strings.TrimSpace(line))
}
}
}
// Both call sites must exist and both must hand over records.
if n := strings.Count(src, ".filter(function(r){return r.skipped;})") +
strings.Count(src, ".filter(function(r){ return r && r.skipped; })"); n < 2 {
t.Errorf("found %d skipped-list filters, want 2 (snapshot and refresh paths)", n)
}
}

View File

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

View File

@ -1,107 +0,0 @@
package provider
import (
"encoding/json"
"testing"
"llmsproxy/internal/config"
)
// TestSourceStatus_CarriesModelKind is the fix for a UI bug where the chat
// test page listed image models next to chat ones.
//
// The root cause was structural, not a missing filter: SourceStatus.Models was
// a flat []string, so by the time the UI had the list there was nothing left to
// tell "gpt-image-2" from "claude-sonnet-5.5". Picking the former sent the
// request to /api/chat, which cannot serve it, and the failure said nothing
// about the real reason.
//
// Models stays a []string for callers that only need names; ModelKinds is the
// additive field that carries the missing dimension.
func TestSourceStatus_CarriesModelKind(t *testing.T) {
src := config.Source{
Name: "vela",
BaseURL: "https://example.invalid",
Adapter: "openai",
APIKey: "sk-test",
Endpoint: "/chat/completions",
Models: []config.Model{
{ID: "claude-sonnet-5.5"}, // kind omitted -> chat default
{ID: "gpt-image-2", Kind: "image"}, // image
{ID: "seedream-5.0", Kind: "image"},
},
}
p := newTestProvider(t, src)
kinds := p.ModelsWithKind()
if len(kinds) != 3 {
t.Fatalf("want 3 entries, got %d", len(kinds))
}
// Order must mirror Models() so the UI can zip the two lists.
names := p.Models()
for i := range kinds {
if kinds[i].ID != names[i] {
t.Errorf("index %d: ModelKinds has %q but Models has %q — the lists must stay aligned",
i, kinds[i].ID, names[i])
}
}
want := map[string]string{
"claude-sonnet-5.5": "chat",
"gpt-image-2": "image",
"seedream-5.0": "image",
}
for _, k := range kinds {
if got := want[k.ID]; k.Kind != got {
t.Errorf("%s: kind = %q, want %q", k.ID, k.Kind, got)
}
}
}
// TestModelKinds_CountsImageModelsInARealCatalog: the number of image models is
// what decides whether the UI needs the split at all, so assert against a
// catalog with both kinds rather than a hand-picked pair.
func TestModelKinds_CountsImageModelsInARealCatalog(t *testing.T) {
src := config.Source{
Name: "vela", BaseURL: "https://example.invalid", Adapter: "openai", APIKey: "k",
Models: []config.Model{
{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"},
{ID: "img1", Kind: "image"}, {ID: "img2", Kind: "image"},
},
}
p := newTestProvider(t, src)
var images int
for _, k := range p.ModelsWithKind() {
if k.Kind == "image" {
images++
}
}
if images != 2 {
t.Errorf("image models = %d, want 2", images)
}
}
// TestSourceStatusJSON_ModelKindsOmittedWhenEmpty pins the backward-compat
// promise: an older UI parsing this JSON must not break on a new field, and a
// source with no model list must not gain a "model_kinds": null.
func TestSourceStatusJSON_ModelKindsOmittedWhenEmpty(t *testing.T) {
b, err := json.Marshal(SourceStatus{Name: "x", Models: []string{"a"}})
if err != nil {
t.Fatal(err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if _, present := m["model_kinds"]; present {
t.Errorf("model_kinds present when empty: %s — should be omitted for older UIs", b)
}
if _, present := m["models"]; !present {
t.Errorf("models field disappeared: %s", b)
}
}
// newTestProvider (defined in provider_test.go) spins up a real Lua VM, which
// is heavier than these tests need, but reusing it keeps one construction path
// for the whole package.

View File

@ -451,25 +451,6 @@ func (p *Provider) Models() []string {
return out
}
// ModelsWithKind returns the same ids as Models but paired with each model's
// kind, in the same order.
//
// Models() cannot answer "which of these are image models", and that question
// has to be answerable: the chat test page lists models from one source list,
// and an image model offered there fails on every call because /api/chat is the
// chat endpoint.
func (p *Provider) ModelsWithKind() []ModelKindInfo {
out := make([]ModelKindInfo, 0, len(p.cfg.Models))
for _, m := range p.cfg.Models {
kind := m.Kind
if kind == "" {
kind = "chat" // config.Model documents "" as the chat default
}
out = append(out, ModelKindInfo{ID: m.ID, Kind: kind})
}
return out
}
// ModelByID returns the model definition if owned by this source.
func (p *Provider) ModelByID(id string) *config.Model {
for i := range p.cfg.Models {

View File

@ -196,27 +196,12 @@ func (r *Registry) ProviderForSlot(model, source string) *Provider {
return nil
}
// ModelKindInfo carries the per-model fields the chat test UI needs to tell a
// chat model from an image one.
type ModelKindInfo struct {
ID string `json:"id"`
Kind string `json:"kind"` // "chat" (default) | "image"
}
// ModelStatus is a web-UI friendly snapshot per source.
type SourceStatus struct {
Name string `json:"name"`
Adapter string `json:"adapter"`
BaseURL string `json:"base_url"`
Models []string `json:"models"`
// ModelKinds parallels Models: Models[i] is ModelKinds[i].ID.
//
// It exists because a flat []string cannot answer "is gpt-image-2
// reachable from /api/chat?" — it cannot, and offering it there produced
// a guaranteed failure with no explanation. Models stays unchanged for
// callers that only need names; this is purely additive and omitted when
// empty, so an older UI keeps working.
ModelKinds []ModelKindInfo `json:"model_kinds,omitempty"`
Available bool `json:"available"`
Healthy bool `json:"healthy"`
MaxConcurrent int `json:"max_concurrent"`
@ -274,7 +259,6 @@ func (r *Registry) Status() []SourceStatus {
Adapter: p.Adapter(),
BaseURL: p.Config().BaseURL,
Models: p.Models(),
ModelKinds: p.ModelsWithKind(),
Available: p.Available(),
Healthy: p.Available(),
MaxConcurrent: p.MaxConcurrent(),

View File

@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Upload release assets to a GitCode release.
#
# The endpoint shapes are asymmetric and both differ from the "obvious" guess:
#
# GET /releases/tags/{tag} → read a release
# GET /releases/{tag}/upload_url → get a presigned PUT (NO "tags/")
# PUT {presigned url} → upload, REPLAYING the headers verbatim
#
# Two mistakes cost real time on v1.9.5 before this script existed:
#
# 1. POSTing straight to /releases/{tag}/assets returns 404 — that endpoint
# does not exist. It reads as "authentication failed" if you only check
# the status code.
# 2. PUTting without the headers fails with an opaque storage error, not a
# permission one, so it looks like a size limit or a corrupt file.
#
# The x-obs-* headers are part of the signature: dropping any one of them turns
# the request into an unsigned one.
set -uo pipefail
TOKEN="$(tr -d '\n\r' < /tmp/gc_token)"
REPO="JianFeeeee/ModelRouter"
TAG="${1:?usage: upload-release.sh <tag> <file>...}"
shift
API="https://gitcode.com/api/v5/repos/${REPO}"
for f in "$@"; do
name="$(basename "$f")"
size="$(stat -c%s "$f")"
printf ' %-46s %10s bytes ' "$name" "$size"
# 1) presigned upload location. The file name must be URL-encoded; the v1.9.4
# assets show "~" surviving in a stored name, so encoding is per-component.
enc="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$name")"
resp="$(curl -sS -m 60 -H "Authorization: Bearer ${TOKEN}" \
"${API}/releases/${TAG}/upload_url?file_name=${enc}" 2>&1)"
url="$(printf '%s' "$resp" | python3 -c 'import json,sys
try:
d = json.load(sys.stdin)
print(d.get("url") or d.get("uploadUrl") or "")
except Exception:
print("")' 2>/dev/null)"
if [ -z "$url" ]; then
echo "✗ 取得上传地址失败: ${resp:0:140}"
continue
fi
# 2) PUT with the headers replayed exactly as returned.
code="$(printf '%s' "$resp" | python3 -c 'import json,sys
try:
print(json.dumps(json.load(sys.stdin).get("headers") or {}))
except Exception:
print("{}")' 2>/dev/null | python3 -c '
import json, subprocess, sys
headers = json.load(sys.stdin)
args = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT"]
for k, v in headers.items():
args += ["-H", f"{k}: {v}"]
args += ["--data-binary", "@" + sys.argv[1], sys.argv[2]]
print(subprocess.run(args, capture_output=True, text=True).stdout)
' "$f" "$url" 2>/dev/null)"
if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then
echo "✓ HTTP=$code"
else
echo "✗ HTTP=$code"
fi
sleep 1
done