Compare commits

...

84 Commits

Author SHA1 Message Date
c96e6051ab chore(gui): 版本号 1.9.6 → 1.9.7 2026-10-06 14:53:59 +08:00
82424564b6 feat(ui): 生图二级页面 + 图像模型不再混进对话页
## ① 图像模型曾与对话模型不可区分

根因是结构性的,不是漏了个过滤:

    Provider.Models()   → []string   ← 只有 ID,Kind 被丢掉
    SourceStatus.Models → []string   ← 扁平列表

于是 /api/status 给前端的 34 个模型(29 对话 + 5 图像)长得一模一样。选了
gpt-image-2 就 POST 到 /api/chat —— 那个端点服务不了图像模型,必然失败,
而且报错完全不提「这是图像模型」。

修法:新增 ModelKinds []ModelKindInfo([{id,kind}]),纯增量、omitempty,
老 UI 不受影响;前端按 optgroup 分组,且这些 option 是 **disabled** 的。

分组还不够 —— 分组只是视觉分隔,operator 照样能选,选了照样失败。
disabled 的 option 会自解释(「请到生图页」),可选的只会失败。

★ 写这段时踩了一次 String.replace 的坑:`imgOpts.join("").replace(...)`
只替换**第一个**匹配,于是 6 个图像模型里只有第 1 个被禁用。浏览器实测
逐条读 e.disabled 才抓到(siliconflow 禁用了、vela 的 5 个没有)。
判据只看 value 里有没有 "image" 的话,第一次跑就会永远报警——那是判据
自己错了,比没判据更糟。

## ② 生图二级页面

侧栏新增 tab,与「对话」并列。网格展示 + 提示词输入 + 数量/尺寸,
调用 /v1/images/generations(不是 /api/chat)。

按钮显示已用秒数:实测该上游 15.7s~112.5s,波动 7.6 倍,不显示进度
看起来就像卡死了。

浏览器实测(几何测量,非仅 DOM 断言):
    tab-image 容器   986×523  visible
    img-grid         840×374  visible
    提示词输入框      634×64   visible
    生图页 6 个模型,全部 kind=image,全部可选
    对话页 6 个图像模型,全部 disabled;97 个对话模型全部可选

## ③ base_url 引导不足

文案直接给出截断位置和具体后果,而不是泛泛地说「填 URL」:

    填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:
    若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。
    不确定就用上方「自动探测模型」。

/v1/v1 不是假设:ImageURL 是 BaseURL + ImageEndpoint(),而图像默认路径
是**绝对**的 /v1/images/generations,chat 的默认是相对的 /chat/completions。
两者对「版本段归谁管」的假设相反。今天在 vela 上为此排查了两次。

## 判据

新增 6 个契约测试:
- 对话页必须读 model_kinds(不读就会退回扁平列表)
- 禁用图像模型不能用 joined-HTML 的 String.replace(只改第一个)
- 生图页存在且注册进 TABS(漏了则 pane 永不可见,症状是「页面空白」)
- sendImage 必须打 /v1/images/generations
- urlHint 必须同时提到 /v1 和 404
- TestImageEndpointComposesUnderRealConditions 用真实形态验证:
  相对路径拼出 /v1/images/generations 得 200,默认路径拼出 /v1/v1/… 得 404
2026-10-06 14:53:15 +08:00
d1e7fd3e77 test(discovery): 真实变异注入 11/11,并给 harness 加三道防线
前几版 harness 先后报过「4 个变异存活」「11 个一个没跑」「9 个存活」,
查清后**全部是判据或变异本身失效,没有一个是代码问题**。这一版把三类
失效都变成显式失败,而不是静默给出错误结论。

**防线一:先证明 filter 选中了东西**
`go test -run <完全不存在>` **不打印** `[no tests to run]`(那句话只在
「包内有其他匹配项但目标没跑」时出现)。我原先据此写的检查方向是反的,
harness 第一步就自我否决。现在改成对比数字:空 filter 必须选中 0 个测试,
真实 filter 必须选中 >= 25 个。本次运行:32 / 0。

**防线二:编译失败 ≠ 变异被杀**
嵌套调用漏 `-tags luajit` ⇒ 纯 Lua 5.1 ⇒ billing.lua 的 goto 编译失败
⇒ 整包 build failed ⇒ **每个变异都「被杀」,给出满分**。这比假绿更隐蔽。
现在 [build failed] 直接 t.Fatalf。

**防线三:变异没改变行为 ≠ 判据漏放**
两次「存活」查清后都是**恒等变换**:
  `&& out.BaseURL != ""`  — 判据用零值结构体,BaseURL 为空,条件恒假
  `&& len(out.BaseURL) >= 0` — len 恒 >= 0,条件恒真
正确写法是把赋值换成 `_ =`:读了变量让编译器满意,行为真的变了。
检测方式:变异后若结果行(剥掉测试名、排序后)与 baseline 完全一致,
报 no-op 而不是记成存活。
no-op 检测本身也修过:原来保留 === RUN 行(含测试名),两次运行测试顺序
不同 ⇒ 每个变异都「看起来变了行为」,检测形同虚设。

11 个变异覆盖:kind 判定、metadata 透传、非目录拒绝、URL 回退、
版本段搬移(chat 与 image 两条)、版本段识别、nil 切片、鉴权分类、
候选去重。

★ 判据的期望值不写死数字:候选去重修好后 len(cands) 从 3 变 2,
连改两次判据都是在追当前列表。改成断言「该覆盖的 URL 都在」。
2026-10-06 14:53:15 +08:00
8909096d80 fix(discovery): 裸 host 也必须有回退候选
补 image_endpoint 那一轮的测试暴露了一个覆盖缺口:候选构造是

    if !hasVersion { add(raw, "/v1/models", 0) }
    else           { add(raw, "/models",   0) }

裸 host(https://api.vendor.com)因此只生成**一个**候选,没有任何回退。
catalog 挂在裸 /models 上的厂商会被报成不可达。

为什么一直没发现:所有测试用的 base 都以 /v1 结尾。生产里唯一已配
kind:image 的 siliconflow(base_url https://api.siliconflow.cn)它的
/v1/models 恰好能应答——**一个能工作的巧合把这个洞盖住了**。

现在两个 catalog 路径无条件都试(偏好只决定「多个都成功时报告哪个」,
不决定「试哪些」)。同时修正优先级:base 已带版本段时第一候选必须是
相对的 /models,否则会先试 …/v1/v1/models——一个必然 404 的 URL,白花
一个来回,还让错误信息以无意义的 URL 开头。

判据同步改了:原本断言 len(cands)==2,那是**在测量当前的列表而不是
覆盖性质**,去重修一次漂一次。现在断言「该覆盖的 URL 都在」
(base+"/v1/models" 与 base+"/models"),实现怎么变都不会漂。

新增 discover_endpoint_test.go:
- 裸 base 不带版本段时 image_endpoint 必须留空,否则会写坏今天正常工作的
  siliconflow 配置
- 裸 host + catalog 在 /v1 响应时 endpoint 必须是 /v1/chat/completions
  (这正是我第一次把测试期望写反的地方:catalog 路径是上游要不要 /v1 的
  唯一证据)
- 两种 base 形态下最终拼出的 chat/image URL 逐字断言,且都不含 /v1/v1
2026-10-06 14:53:15 +08:00
f6561a3809 fix(discovery): 错误分类 + 去掉重复请求 URL
演练错误路径时发现两个真问题。

**① 密钥错误被报成 502(上游故障)。** 这是最常见的失败,却和「厂商挂了」
给出完全相同的错误。两者需要的动作正好相反:重新输 key vs 去查厂商状态页。
操作员只能猜。现在 401/403 会得到 401 并在信息里点名 api key;
DNS 不解析、TLS 失败、连接被拒也各自带一句可执行的说明。

**② 错误信息里的 tried 列表有重复。** base "https://h/v1" + path "/models"
和 base "https://h" + path "/v1/models" 请求的是同一个地址,但候选去重只按
(base, path) 对,于是同一条 URL 在错误信息里出现两次——读起来像「试了两个
地方」,其实只试了一个。改成按最终 URL 去重。

去重后候选从 3 个变成 2 个,两个判据的期望值需要跟着改:
TestDiscoveryCandidates_DedupesAndPrefersTyped 和变异测试的
「fallback search」原本断言 >= 3,那是在测量 bug 而不是覆盖度。覆盖没有丢失:
剩下的两条是 https://h/v1/models 和 https://h/models,两个不同地址。

新增判据:
  TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway
  TestDiscoverModels_UnreachableStaysBadGateway(防止过度纠正)
  TestDiscoveryCandidates_NoDuplicateRequests
  TestDiscoverModels_FailureListsEachURLOnce(对操作员真正读到的文本断言)
2026-10-06 14:53:15 +08:00
2e6cc60da6 docs(discovery): 用实测数据替换注释里的猜测
composeImageEndpoint 原注释写着「两种形式都 404,无法区分」,那是我写代码时
的推测,不是观测。后来真实打了一次上游就分清了:

  /v1/images/generations    → 200(图像生成本身要几分钟,是上游耗时)
  /v1/v1/images/generations → 404
  /images/generations      → 404

注释里的假话比没有注释更糟——下一个人会拿它当「已知的模糊地带」而不敢
收紧判据。
2026-10-06 14:53:15 +08:00
92aa3d6a9b fix(gateway): 探测同时算出 image_endpoint,修 /v1/v1 404
上一版只算了 endpoint,图像生成仍然 404。根因是网关两条拼接路径的默认值
规则相反:

    ChatURL  = BaseURL + Endpoint()       默认 "/chat/completions"      相对
    ImageURL = BaseURL + ImageEndpoint()  默认 "/v1/images/generations" 绝对

base_url 带 /v1 时 chat 拼成 /v1/chat/completions 正常,image 拼成
/v1/v1/images/generations 就 404。之前归一化 base_url 时修正了版本段的位置,
但没意识到图像这条路径的默认值自带 /v1,它跟 base 是各管一段。

探测现在额外返回 image_endpoint:base 末尾有版本段时给
/images/generations,否则留空用默认值。判据是 TestComposeImageEndpoint ——
它断言 base+结果绝不含 /v1/v1,这是那个 404 的字面形态。

apply 的继承逻辑相应改了:image_endpoint 不再从旧源继承。旧值是对旧
base_url 正确的,而 base_url 恰恰是这次可能变的东西,沿用它会留下一个对着
新 base 404 的陈旧值。前端 srcDiscover() 同步写入该字段。

端到端(隔离实例 18099,真实 amr-link 上游):
  探测     http 200,1.4s,29 chat + 5 image
  apply    http 200,落库 34 个模型
  文本     http 200,2.0s
  视觉     http 200,1.5s
  图像     路径修正为 /v1/images/generations(不再 404;上游本身 120s 超时,
           属其自身耗时,非路由问题)

探测对已存在的源返回 409 要求显式 replace_models=true,这个保护在验证中
确实生效了。
2026-10-06 14:53:15 +08:00
8aa06574fc feat(gateway): 源自动探测模型列表
只需填 URL 和 key 即可自动扫描上游模型并配置,不用再手抄模型 id。

端点
  POST /api/sources/discover        只探测,不写任何东西
  POST /api/sources/discover/apply  探测后落库

核心难点是 URL 形态而不是 /models 调用本身。网关按 base_url + adapter
endpoint 拼路径(openai 提供 "/chat/completions"),而各家厂商对版本段
放在 base 还是 path 上没有共识,也没有哪个响应头能说明。所以探测不信任
调用方给的 URL,而是按偏好顺序试若干候选,返回答的那个,并给出归一化
后的 base_url + endpoint 组合。这正是第一次配 vela 时 /v1/images/
generations 返回 404 的成因:base_url 里已有 /v1,默认图像路径再拼一次。

归一化的不变量是「版本段在 base_url + endpoint 里恰好出现一次」。留在两边
会重复;两边都没有则 chat 请求在要求版本段的厂商上会打错路径。

kind 只信上游自己声明的 modality,绝不从模型名猜。"gpt-image-2" 这类
id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持图像
生成的 id,其后每一层都被它拖垮。

不做的事:
- 不逐模型发单 token 探活。34 个模型的目录跑一轮要花钱且耗时数分钟,
  /models 是足够便宜的信号;模型是否真能应答留给正常请求路径。
- 不猜能力。「vision」出现在 id 里不等于该模型接受图片。
- 不自动写盘。探测只读,保存仍需操作员按按钮——错误的 base_url 会产出
  一个看起来合理却无法使用的源。

已存在的源必须显式 replace_models=true 才会覆盖模型列表:重新探测顺手
抹掉为 AUTO 链手工调过的 priority 是最难排查的一类数据丢失。

测试用真实抓取的 amr-link /models 响应做夹具,它把 modality 嵌在
architecture 下、metadata 下还有 cost/context_limit,34 个模型里混着 5 个
图像生成 id——朴素解析器正好在这三处出错。

变异测试 9/9 全杀(TestRealMutants,baseline 43 个测试)。过程中发现并
修掉四个真缺陷:嵌套 metadata 丢失、null 被当空目录、候选盲目拼接出
/v1/v1、以及 discoveryResult 零值序列化成 null 会让前端渲染出
"undefined"。早先的 shell 版变异脚本因 -run 转义损坏报出 4 个"存活",
实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。
2026-10-06 14:53:15 +08:00
cdd1fcb529 fix(gateway): 流式请求从未记录 reasoning_tokens
## 症状

接了一个 reasoning-heavy 的源之后,读生产审计文件发现:它的**流式**请求全部
只有 completion、没有 reasoning。实测对照:

    流式   compl=8166  reasoning=—      ← 网关记的
    非流式 compl=751   reasoning=167    ← 同一个 adapter,非流式正常

客户端两种情况的响应都正确,所以**没有任何报错**。只有审计记录更安静。

## 根因

两条记账路径,只有一条读了它:

    recordChatUsage(非流式)         读 resp.TokenUsage.CompletionTokensDetails
    流式的 usage 合并(内联在 handler) 只读 Prompt 和 Completion

cache 记账当时被镜像过去了,**reasoning 单纯没加**。所以一个花了几百 token
思考的模型,在流式下看起来像什么都没想。

这不是 qwen38 的问题 —— 是任何流式 reasoning 模型的问题,之前没有源暴露它。

## 为什么是「读审计文件」发现的,而不是失败的测试

现有判据 `TestRecordChatUsageKeepsReasoningTokens` 只覆盖非流式那条路径。
流式那段内联在 handler 里,测不到。抽出 `applyStreamUsageDetails` 之后
才有地方挂判据 —— 抽取不是为了好看,是因为不抽就没法测。

## 判据(四条)

- 流式记录 reasoning,且精确值(512)
- **reasoning 绝不被加进 completion** —— 关系式断言,不是「字段存在」检查。
  写成 `rec.Compl += reasoning` 会让每个思考 token 被收两次费。
- 上游没报 reasoning 时不写字段(0 在审计里表示「未上报」,与「没思考」不同)
- cache 记账未被顺带破坏 —— 三种输入形态各测一遍

变异自证三次变红:删掉 reasoning、加到 completion 上、破坏 cache 块。

第四次变异(`d.ReasoningTokens > 0` 改成无条件赋值)没变红 —— 那一版与原版
**行为完全相同**:rec.Reasoning 初值就是 0,且 json tag 带 omitempty,两种情况下
客户端都看不到这个字段。这是无行为差异的变异,不是判据漏放。

## 生产实测

    部署前(13 条):compl=204/277/1822/1264/740/…  reasoning 全部未记录
    部署后( 1 条):compl=213  reasoning=42  status=200

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-06 14:53:15 +08:00
0d9fb26244 feat(adapters): qwen38 —— tokenplan/discovery-api 适配器
接 https://discovery-api.intern-ai.org.cn/v1(qwen3.8-27b 等 10 个模型,5 个支持
视觉)。这是一个 OpenAI 兼容端点,但有 Stock OpenAI 适配器处理不了的地方。

## 为什么需要专用适配器

**① 思考内容占绝大部分输出,且是独立字段**

实测视觉问答:completion=303,其中 reasoning=295 —— 97% 的 token 预算用在
思考上,只有 8 token 变成正文。

后果是**静默失败**:直接透传上游的 content,调用方看到的是空字符串,看起来
像模型坏了。实测 max_tokens=60 时上游返回 HTTP 200 + content=null +
finish_reason="length" —— 一个健康的请求,什么都没产出。

所以思考内容走自己的字段,绝不折进 content:折进去比丢掉更糟,页面会把模型
的内心戏当成答案显示。

**② `finish_reason: "length"` 配 `content: null` 是正常的**

不是上游故障。上报真实的 finish_reason 和已产生的部分思考,让调用方能区分
「预算用完了」和「上游挂了」。

**③ 服务端会改写模型名**

请求 `qwen3.8-27b`,返回 `model: "qwen3.8-27b-fp8"`。响应里的 model 才是
「谁真的服务了这次请求」,计费按它记账。适配器**不改回去** —— 改回去会让审计
记录撒谎。

## 踩到并修正的一个真 bug

reasoning_tokens 最初放在 `token_usage.reasoning_tokens`(顶层)。类型检查
通过,然后被丢弃。

internal/gateway/chat.go 只在一个地方读 reasoning:
    resp.TokenUsage.CompletionTokensDetails.ReasoningTokens

实测证据:模型报 completion=88,网关响应里 reasoning=null。字段在正确的地方、
错误的层级,等于没有。

判据因此改成断言**路径**而不是断言「某处有个数字」,变异把顶层放回去立刻变红。

(顺带发现:deepseek adapter 根本没发这个字段,所以线上 reasoning 计费一直
是 0。那是另一个缺口,本适配器不依赖它被修。)

## 配置时要知道的一件事

远程图片 URL 由**上游自己去拉**,会稳定超时(实测 Wikimedia 图片返回
media_fetch_failed)。图片必须用 data: 内联。

## 判据(9 条,夹具全是真实响应)

- 思考与正文分离,且互不污染
- 截断响应上报 finish_reason 与部分思考
- reasoning_tokens 走正确路径,且是 completion 的子集
- tool_calls 的 arguments 从 JSON 字符串解码
- 流式:思考 chunk 不漏、正文 chunk 不丢、末尾 usage 带 reasoning
- 媒体失败带 code(media_fetch_failed),invalid_api_key 不带(免得重复)
- 带 tool_calls 的 assistant 轮补 reasoning_content(否则下一轮被拒)
- 多模态 content 数组原样透传

变异自证九次全部变红,其中三次是本次专门补的:reasoning 折进 content、
顶层 vs 嵌套路径、设成 0。

## 隔离实例实测(生产配置 + 真实上游)

    非流式  completion=252  reasoning=167  ✓ 透出
    流式    26 chunk / 714ms,正文 37 字符,思考 55 字符  ✓ 分离
    视觉    base64 图片 → "白色。"  reasoning=44  ✓ 无损

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-06 14:53:15 +08:00
42eef98395 chore(gui): 版本号 1.9.5 → 1.9.6 2026-10-05 10:34:30 +08:00
86ba4f57a0 fix(quota): 十三个退避中的源被渲染成「有数据」
## 症状

生产 Quota 页面上 17 个源里 13 个显示「有数据」徽标 —— 而它们实际上什么都
没返回,只是刚刚被退避跳过。

## 根因(三层,逐层都静默)

**① 退避记录缺 `skipped` 标记**
`due_sources` 与 `collectSource` 返回退避记录时没有 `skipped = true`,页面的
`filter(r => !r.skipped)` 就把它们当正常记录放进卡片。卡片渲染匹配已知字段,
一条只有 `reason` 的记录什么都匹配不上,于是落到兜底分支。

**② 兜底分支文案是「有数据」**
那是个断言 —— 它在说「探测有应答」。落到这里的记录其实是:一条退避通知、
一种我们不认识的错误形状、或供应商改了响应格式。把「没有证据」说成「有数据」,
十三个死上游就被渲染成健康的了。改成「未探测」。

**③ 退避与「没有余额接口」被混为一谈**
页脚原本统一说「未暴露余额接口」。这是对上游的另一种断言,而且对死上游是错的
—— 它有接口,只是暂时没探到。

## 「只改了一处」这个坑

`render()` 改成按 `backing_off` 标记分组之后,两个调用点都必须传**记录**而不是
名字。我先改了刷新路径就部署,结果是页脚显示「未列出 13 个源:、、、……」——
症状看起来像渲染 bug,实际是两个调用点与同一个函数之间的契约不一致。

快照路径那行 `.map(function(r){ return r.name; })` 是后来才补的。
判据 `TestQuotaRenderCallSitesPassRecordsNotNames` 现在要求两处都在。

## 顺带修掉的两个真 bug

**空 Lua 表在 Go 侧解码成 `{}` 而不是 `[]`**
`plan.backing_off || []` 里 `{}` 是 truthy,空表会原样进入 `out.concat()` 并抛错,
整次刷新报失败。改用 `Array.isArray` —— 它与插件实际返回的类型匹配。

**分组靠文案匹配是个定时炸弹**
原先用 `reason.indexOf("backing off") === 0` 判断退避。理由字符串一旦本地化,
退避就会被并进「未暴露余额接口」。改用显式 `backing_off: true` 标记,
判据里也改成按标记匹配 —— 变异把文案改回英文立刻变红。

## 判据

新增两条:
- `TestQuotaBackoffRecordIsSkippedAndFlagged` —— 退避记录必须带 `skipped` 和
  `backing_off` 标记,且必须有 reason(否则卡片是空的)
- `TestQuotaRenderCallSitesPassRecordsNotNames` —— 两个调用点都不得把跳过列表
  映射成名字

变异自证四次全部变红:去掉 `skipped`、去掉 `backing_off`、文案改回英文、
快照路径改回 map-to-name。

## 生产实测(浏览器真实点击)

```
第1轮 1694ms: 4 个源 · 0 个读取失败
        13 个源正在退避(连续探测失败,等下次重试):trae、qijiar、toter、…
第2轮 2488ms: 4 个源 · 0 个读取失败
        13 个源正在退避(…):trae、qijiar、toter、…
```

页面现在只列 4 个真有数据的源,13 个退避中的归到页脚并说明原因。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-05 10:27:04 +08:00
4826837f9c chore(packaging): 归档 Release 附件上传脚本
v1.9.5 上传时踩了两个坑,都不好从错误信息看出来:

1. 端点形状不对称且反直觉
     GET  /releases/tags/{tag}/upload_url   → 404(tags/ 只用于读取)
     POST /releases/{tag}/assets            → 404(该端点不存在)
   看起来像认证失败,实际是路径不对。

2. PUT 必须逐字回放 upload_url 响应里的 headers
   (x-obs-meta-project-id / x-obs-acl / x-obs-callback / Content-Type)。
   少一个就变成未签名请求,报错是存储层的模糊错误 —— 看着像大小超限或文件损坏。

当时先试了三个直觉端点全 404,之后 grep 自己的记忆日志才发现 9-26 就记着
「upload_url 路径不带 tags、PUT 必须回放 headers」。路径形状有疑问时先查历史,
比继续试快。

用法: packaging/upload-release.sh <tag> <file>...
2026-10-05 08:21:34 +08:00
2cf7808e63 chore(gui): 版本号 1.9.4 → 1.9.5 2026-10-05 07:49:02 +08:00
aaad52acc1 Merge feature/plugin-api-v2 into release/v1.9.x(v1.9.5)
quota 探测从 29s 收敛到 1.3s:
- hostOf 用 gmatch 而非 match,memo 表从未命中过任何东西(负 memo/TTL/退避全是死代码)
- 探测预算用 os.clock(CPU 时间),12 秒预算从未生效
- Promise.all 是假并发,Plugins.Call 持锁含全部网络等待
- 退避改指数 + 判定/记录留在共享 VM,只有网络等待才分片

插件 API:声明式解码器(未识别字段不再静默丢弃)+ AccessRule 统一授权词汇

冲突取 feature 侧:release 上是 rebase 前的旧 hash,20 个提交已逐一 patch-id
核对内容等价,删除的都是被替换的旧实现。
2026-10-05 07:48:06 +08:00
06f8c5dba4 feat(quota): 判定与记录回到共享 VM,只有网络等待才分片
## 我引入的自相矛盾

分片是为解决这个量出来的:

    单源探测(无竞争)      1.4 – 3.3 s
    20 源 Promise.all       29 s
    20 源串行理论值        ≈ 29 s   ← 吻合

根因是 Plugins.Call 持 p.mu 到 Lua 函数返回,**含全部网络等待**;一个 Lua VM
只有一个栈,锁不能去掉。于是每个并发调用用自己的 VM 跑同一份源码(实测 8 分片
2.407s → 0.304s)。

但分片 VM 有自己的 PLUGIN.state —— 而**退避状态就存在 state 里**:

    fanout(分片)  →  memo 0 条   →  退避永不生效,每轮都付满 12s
    call(共享 VM) →  memo 17 条  →  13.1s → 4.8s

我设计分片时写了「分片不碰 state」这条硬约束(防 billing 的累加在各分片分叉、
saver 写出随机一份),却没意识到探测的退避状态恰好住在那里。约束和功能打架。

## 解法:按职责切开,不搬家

把退避表移到网关(B)能解决,但 quota 的 adapter memo 也在 state 里,而且
「插件自包含」是这个插件机制的前提。搬一半比不搬更糟。

所以判定与记录留在插件里,共享 VM 负责;只有网络等待分片:

  due_sources   共享 VM,纯判定,不发请求(微秒级)
  fanout        只对 due 里的源分片探测
  memo_record   共享 VM,把这轮结果(成功和失败都记)写回

一个分片也不碰 state —— 约束保住了。

## 实测(隔离实例,18 源,与生产同配置)

    第1轮  10163 ms   退避跳过 0   分片 18   答了 4
    第2轮   1299 ms   退避跳过 14  分片 4    答了 4
    第3轮   1288 ms   退避跳过 14  分片 4    答了 4
    第4轮   1316 ms   退避跳过 14  分片 4    答了 4

**29s → 1.3s,且 4 个有余额的源每次都探到** —— 不是靠少显示换来的快。

## 几个细节

- due_sources 的 body 里不能出现 collectSource:一旦它自己探测,过滤就没有意义
  了(成本在分片之前就付掉了)。有判据守着。
- memo_record 忽略 reason 以 "backing off" 开头的行 —— 那是被跳过的探���,
  把它记成失败会让 streak 在一次没发生的探测上增长。
- 成功写成 vendor 字符串,streak 自然重置,不需要显式清零。

## 判据(新增 4 条,共 11 条读实际发布的 quota.lua)

- 两个导出存在且在 exports 里
- due_sources 不调 collectSource
- memo_record 让 streak 从分片失败里累加,成功后重置
- due_sources 把退避中的 host 挡在分片之外,且零网络请求

写判据时踩了一次:两个源用了同一台 httptest server,于是被判为同一个 host,
两个都退避 —— 这是按 host 记忆的**正确**行为,是我的判据写错了。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-05 07:36:40 +08:00
4a0a1f96e4 fix(quota): hostOf 用了 gmatch 而不是 match —— memo 表从未命中过任何东西
## 这是退避失效的真正原因,而且比 TTL 严重

查生产 state 时发现的:19 条 memo,`hostOf(key)` 命中 **0** 条。

    hostOf = function(u)
      return (string.gmatch(u, "^https?://([^/]+)")()) or u
    end

`string.gmatch` 返回**整个匹配**,不是捕获组;而带 `^` 锚定时迭代器不会前进,
所以第一次 `()` 调用返回 **nil**,函数走 `or u` 分支返回完整 URL。

后果是彻底的、而且完全静默:

  - 每次查表都是 HOST_MEMO[nil],每次写入也是 HOST_MEMO[nil]
  - memo 表**对任何 host 从未命中过**
  - 负 memo、PROBE_MEMO_TTL、以及刚加的指数退避,**全是看起来活着的死代码**
  - 生产那 19 条历史 memo(key 是完整 URL,来自 ccquota 时代)从来没有被读过

所以「两个死上游每 30 分钟重付一次探测」的真实原因不是 TTL 到期,
而是压根没命中过 memo。

## 为什么这么久没人发现

失败模式与「memo 里没有可记的东西」完全不可区分:页面照常显示,卡片照常
skipped,没有任何报错。唯一线索是 state 里那 19 条条目 —— 它们看起来像是
在正常工作留下的痕迹。

## 修法

    string.match(u, "^https?://([^/]+)")

判据 `TestQuotaMemoKeyIsTheHost` 钉住它:读**实际发布的** quota.lua,检查
用 string.match,并在运行时验四个 URL 的 host 抽取,以及同一 host 的两个不同
路径必须产出同一个 key(这正是按 host 做 memo 的意义)。

变异把 match 改回 gmatch 立刻变红,并打印出实际返回的完整 URL。
2026-10-05 07:27:13 +08:00
ac9746ec83 chore(plugins): 移除分片诊断日志
SHARD_DEBUG 只在排查「18 个分片为什么还是 12 秒」时用过一次,结论是
17 个 VM 真的在并行(17 VMs live),慢的是其中一个源本身。留着它就是
给热路径留一个 os.Getenv。
2026-10-05 07:22:09 +08:00
2272b4ecbd feat(quota): 探测退避改指数 —— 死上游不再每 30 分钟重付一遍
## 为什么 30 分钟 TTL 治不了死上游

线上有两个 host 已经**不可达十个小时**,而 PROBE_MEMO_TTL 是 1800 秒。
于是每次刷新它们的负 memo 都已经过期,每次都重付三个 adapter 的超时:
每个源 24s(预算修好前)、16s(修好后)。固定 TTL 对「已经死了很久」这个
形状完全无效 —— 它只会一直重复付。

指数退避:连续失败 streak 次,等待 1800 × 2^(streak-1),封顶 6 小时。
成功会把 memo 覆盖成 vendor 字符串,streak 自然重置。

封顶不是可选项 —— 没有它,下线一周的 host 就不再被探测,页面会**永久**
把它误报成「没有余额接口」。

## 过程中判据抓到的三个真 bug

**① 记录守卫让 streak 永远是 1**
`if rep == nil and not HOST_MEMO[host]` 只在 host **第一次**失败时为真。
之后 memo 已是 table,守卫为假,什么都不写。实测连读四次都是
`{at:... streak:1}` —— 退避退化成固定 30 分钟,正是它要解决的问题。

**② `return 0` 在 Lua 里是真值**
`if streak == nil or streak <= 0 then return 0 end` —— 只有 nil 和 false
是假值。调用点写的是 `if backoffSeconds(...) then 跳过`,于是**所有没有
streak 字段的 memo 会被永久跳过**(而那正是本改动之前写的每一个 memo)。

**③ 上限路径 `return BACKOFF_MAX` 也是真值**
21600 会被判成「仍在等待」,封顶反而变成「永远不重试」—— 恰好是封顶要防的
那个故障。现在所有路径只返回布尔。

三个都是静默的:症状只是页面少了几张卡片,没有任何报错。

## 判据

五条,全部读**实际发布的 quota.lua**(go:embed 那份),不是副本:

- 退避确实被调用(变异把调用点换成 `if false` 时会红)
- 窗口表:每个 streak 的窗口长度、窗口内跳过、窗口过期后重试、
  荒谬 streak 也必须重试
- 只返回布尔(六种输入,含无 streak 字段的老 memo)
- 记录守卫会替换 memo 而非合并
- 通过真实插件:首次失败记 streak=1;**跳过不算失败**(否则一个从不被探测的
  host 会无限增长 streak,再也不会被重试)

变异自证六次全部变红:`return 0`、上限返回数字、守卫不替换、调用点去掉、
去掉上限、倍数改成 1。头一次跑「守卫不替换」漏放了 —— 因为我把集成判据缩到
只验首次记录,补了一条源码断言才抓住。

## 这个文件的判据绕了远路,如实记下

`HOST_MEMO` 是进程内缓存,只在首次 collect 时从 state 恢复,之后只往回写。
所以从 Go 侧改 state 对插件不可见 —— 我先是不自知地这么试了两轮,把一个
**正确工作的退避**看成坏的。现在窗口策略直接验函数(经 shim 导出插件自己的
backoffSeconds,不是重新实现),集成判据只留真正能从外部观察的两条。

shim 也踩过:直接把 `backoffSeconds` 当 export,而 Go 侧传的是 table,
于是 at=table、streak=nil,每次都走「无 streak」分支返回 false —— 看起来
和「退避永不触发」一模一样。包一层就对了。
2026-10-05 07:21:43 +08:00
ff8afaa43d feat(plugins): 分片调用 —— 让阻塞型导出函数真正并行
## 问题

quota 页面 20 个源,刷新要 29 秒。页面用 `Promise.all` 并发发 20 个请求,
**看起来是并发的,实际不是**。

`Plugins.Call` 持 `p.mu.Lock()`,锁一直持有到 `L.Call` 返回 —— **包含 Lua 函数
内部的全部网络等待**。20 个请求到了网关后排队,串行累加。

生产实测:

    单源探测(无竞争)      1.4 – 3.3 s
    20 源 Promise.all       29 s 墙钟
    串行理论值              ≈ 29 s   ← 与实测吻合

代码里其实已经知道这个问题:quota.lua 第 104 行写着「the whole page shares ONE
Lua state under a write lock」,并为此把超时从 10s 压到 4s。但那只把 29s 变成
约 12s,没有解决「刷新不可用」。

而当初改成并发的理由(页面注释)是错的 —— 它假设并发能绕开串行。锁让它退化
回串行了。

## 不能去掉锁

一个 Lua VM 只有一个栈。两个并发调用会破坏栈。锁是必要的,答案只能是**让每个
并发调用有自己的 VM,跑同一份插件源码**。

先量,再写实现(8 分片 × 300ms 本地 HTTP 端点):

    共享 VM,8 次串行      2.407 s
    8 个独立 VM 并行       0.304 s    → 7.9x

顺带一个测量教训:这个 spike 第一版用**忙等**而不是网络 I/O,报出 2.3x。
那是量到了 12 核机器上的 CPU 争抢(负载 8.95),和锁无关。插件做的是网络等待,
spike 就必须做网络等待 —— 和上一条提交里 os.clock 那个错误是同一类。

## 实现

- `callOn(w, fn, args)`:从 `Call` 里抽出的「在某 VM 上调一个导出函数」。`Call`
  从 66 行缩到 18 行,仍负责查找、export 白名单和共享 VM 的锁。
- `shardPool`:私有 VM 池,上限 32。开机约 0.1ms,池化省不了什么但省两次刷新
  的重复开销。
- `ShardCall(name, fn, argsList)`:结果与输入**按位置对齐**。
- `POST /api/plugins/{name}/fanout/{fn}`:`{"args": [...]}`,上限 64 条。
- `pluginAPI.fanout()`:页面一次请求。
- quota.lua 改用 fanout。

## 分片的硬约束(写进注释,不是靠约定)

分片 VM **不能碰 PLUGIN.state** —— state 在 VM 里,每个分片一份副本,billing
的累加会各分叉,saver 写出去的是随机某一份。分片只能用于只读、幂等的工作。
所以 `callOn` 既不查 export 白名单(`Call` 那层才是安全边界)也不落盘。

## 一个分片失败不拖垮整批

一个上游连不上,不该让 20 张卡片变成一个错误。失败的分片在自己的位置留
null,其余照常返回。**但全部分片失败时报错** —— 那不是「某个上游挂了」,
是结构性问题,报成功会把它藏在满屏红卡片后面。

## 顺手修掉一个既有缺陷:热重载是坏的

写分片源码来源时,判据直接把它顶了出来:`LoadSource` 无条件 append,而
`findLocked` 返回**第一个**匹配。实测:

    LoadDir 后 1 条 → LoadSource 后 2 条
    [0] version=1   [1] version=2
    find 返回 version=1

也就是说,**编辑插件并保存看起来什么都没发生** —— 旧版本一直被返回,新版本
躺在旁边不生效。修法是先 Unload 再加载。这不是为分片做的,是分片正好照到了
这条路径,已单独补判据 `TestReloadReplacesRatherThanAppends`。

## 判据

七条:并行性(有界而非计时断言,8×200ms 串行是 1.6s,超过 1.0s 即红)、
位置对齐、未导出与未知插件被拒、单个失败不拖批、全失败报错、用已安装源码而
非磁盘、以及热重载替换而非追加。

全量 9 包通过。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 21:56:45 +08:00
e5af4ec34c fix(billing/quota): 探测预算用 os.clock 是 CPU 时间,12 秒预算从未生效
## 现象

线上 quota 页面点刷新后卡在「探测中…」,13 秒仍未完成,按钮一直 disabled。

## 实测根因(不是猜测,是量出来的)

单源探测,隔离实例,逐个测:

    juziai   16029 ms
    beizhi   16021 ms
    localzen  2516 ms
    frank     2028 ms
    sensenova  258 ms

而插件自己写着 `PROBE_BUDGET_MS = 12000`。**修复前**这两个源各耗 **24013 ms**
—— 精确等于 4s 超时 × 2 次重试 × 3 个 adapter。预算形同虚设。

## 原因

`exhausted()` 用的是 `os.clock()`,它返回 **CPU 时间**。探测几乎全耗在网络等待上,
CPU 几乎不动,于是预算永远不触发。

判据 `TestPluginClockDuringNetworkWait` 直接量这件事:

    3s blocked on HTTP: os.clock advanced 0.001s, os.time advanced 3s

**001 秒。** 一个用 CPU 时间写的预算,在 I/O 等待下永远不会响。

这个陷阱和「用忙等测锁竞争」是同一个,只是下沉了一层 —— 而且我第一版判据
正是这么写错的:用自旋循环当「网络等待」,结果 os.clock 走了 1.624s / 2s,
看起来 os.clock 跟的是墙钟,差点得出「不是这个原因」的错误结论。必须用真实
HTTP 请求重测才能拿到真相。

代码里两处同样的写法(第 434 和 466 行),一起改掉。

## 改完的效果

    串行 20 源(/call/ 逐个)    84391 ms
    fanout 20 源(并行)          16022 ms
    ⇒ 5.27x

单个死上游从 24s 降到 16s(12s 预算 + 收尾)。

## 还没解决的

16 秒仍然偏长,因为它等于**最慢那一个源**的预算。这不是并行失效,是并行
之后墙钟的必然下界。要再往下走只有两条路:把预算压到 4~6s,或者给已知死掉
的上游走缓存而不是每次重探。都是参数与策略选择,不是结构缺陷,留给下一步。
2026-10-04 21:56:10 +08:00
360a2a8644 refactor(plugins): 统一授权词汇 —— AccessRule
## 问题

同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里:

    audienceAllows(want, role):   "" / "all" → 全开      页面可见性
    HTTPHandler.allows(role,key): ""         → 仅 admin   注册端点

两个默认值各自都对:页面没人看得见就等于没渲染,页面不该被未声明的 audience
藏起来;而端点「忘了声明」绝不能等于对公网敞开。

危害不在默认值本身,在于**同一个问题有两套词汇**。运维配「谁能看 billing」和
「谁能调 /balance」得先学会两套规则,才能回答同一个问题。

## 改动

新增 internal/lua/plugin_access.go,把两套规则收敛成一个 AccessRule,
并把**仍然不同的那一点——默认值——写成数据而不是留成隐含**:

- AllowsCapability:空 = 拒绝(端点、函数)
- AllowsVisibility:空 = 继承(页面、元素)

语法也统一成一种,`ParseAccessRule` / `Rule` 双向:

    "admin" / "admin, user" / "@alice, @bob" / "user, @alice" / "public"

`@name` 是密钥、其余是角色,一��字符串表达得完规则编辑器要提供的全部选项。

## 抽取时差点弄丢一个真实语义

`"all"` 是既有插件文件和**已保存的运维 override** 里的写法。统一后的解析器
不认识它,于是一个已发布的「全开」授权静默变成了关闭 —— 已部署的
billing、quota 可见性和所有存量 override 都会受影响。

是既有的 TestAudienceAllows 抓到的,它当场变红。这正是抽取的实质收益:
两个函数并排住着的时候,没人会想到去动那个共用字符串。

`TestAllIsAnAliasForPublic` 把它写成新类型的性质,而不是留给碰巧跑到的那
个套件去守着。

## 顺手加的东西我又撤了

accessRuleFor 第一版把 roles 里写成 `@alice` 的项重分类成密钥,理由是
「这大概是个自然的笔误」。那是在重构里偷带行为变更:会把写了莫名其妙内容的
端点的可达范围悄悄放宽。已撤,注释里写明为什么这个笔误值得修是另一件事、
需要它自己的理由和判据。

## 判据

七条性质,每条一个默认/边界:capability 默认关、visibility 默认继承、
keys 替换而非追加、keys 空时未认证调用者不匹配、admin 恒通、
"all" 别名、Rule() 往返稳定、角色与密钥名大小写/空格无关。

变异自证四次,全部变红:keys 变 OR、capability 默认全开、visibility 默认
拒绝、丢掉 "all" 别名。

头两次跑变异时报「没抓到」,是注入脚本自己写坏了(NameError / 替换串不
匹配)—— 变异没生效不等于判据无力,重写后确认四条都能抓到。

## 一条被记录下来的意外

`TestRoleAndKeyUnionAdmitsBoth` 最初断言「同时列出 role 和 key 时两者都放行」。
debug 跑出来是相反的:keys 非空就整体替换掉 role,carol 被拒。

这个意外正是不把它放在注释里的理由。判据现在断言实际语义(替换),与既有
HTTPHandler.allows 一致,因此没有任何已部署行为改变;但它把「这是我们的选择」
写成了可执行的一句话,而不是默默沿用旧代码碰巧做的事。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 20:37:09 +08:00
2fd536e99b refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃
## 问题

三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:

    ui = { visibilty = "admin" }   -- 一个字母之差
    ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分

今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。

## 改动

新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。

readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。

## 顺带修掉一个真 bug:handler 必须是名字而非函数值

`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。

## 更正一个我写了但从未核实的说法

之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:

    setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
    json global present when readPlugin* runs: true
    luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages

真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。

一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。

## 判据

- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了

变异自证:strictKeys 短路 → 变红。

已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 20:37:09 +08:00
700f4179fa chore(gui): 版本号 1.9.3 → 1.9.4
(cherry picked from commit 37545b6dbb)
2026-10-04 14:50:47 +08:00
91e3013cb5 feat(billing): 长上下文阶梯定价(边际费率,与缓存折扣/峰段可叠加)
## 影响面实测

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

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

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

## 边际而非混合平均

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

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

## DSL 两种写法

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

## 判据

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

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

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

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

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

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

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

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

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit b93799a919)
2026-10-04 14:50:47 +08:00
d119d09929 feat(billing): 记录 reasoning token,并支持按规则单独定价(默认不启用)
## 断掉的链路

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

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

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

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

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

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

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

## 遗留

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

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

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

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

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

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

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

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

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

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

## 修掉的三个真缺陷

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Two bugs the page found

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

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

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

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

## Why the gate is an explicit export list

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

## ccquota

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

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

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

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

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

## Verified on production

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

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

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

所以这个桥刻意做窄:

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

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

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

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

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 0fbb8e5e88)
(cherry picked from commit e76e1623e4)
2026-10-04 14:50:46 +08:00
6e63e4761d Merge origin/release/v1.9.x into release/v1.9.x(v1.9.3 已发布历史并入 v1.9.4 线)
release 分支被我用 -B 重置过,丢掉了 v1.9.0–v1.9.3 已发布的提交历史。
并回远端历史,保留发布线的可追溯性。

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

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

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

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

## 边际而非混合平均

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

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

## DSL 两种写法

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

## 判据

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 遗留

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

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

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

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

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

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

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

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

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

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

## 修掉的三个真缺陷

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Two bugs the page found

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

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

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

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

## Why the gate is an explicit export list

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

## ccquota

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

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

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

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

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

## Verified on production

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

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

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

所以这个桥刻意做窄:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

两处数据源也不一致:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 语义变更

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

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

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

## 连带改动

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

## 判据

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

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

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

View File

@ -150,6 +150,47 @@ 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.8.0",
"version": "1.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.8.0",
"version": "1.9.3",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -189,6 +189,20 @@ 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,6 +256,16 @@ 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)}
}
@ -400,7 +410,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
// TestHooksFireOnRealDirectChat, not by reading the code.
g.fireStart(r.Context(), &req, "chat", model, len(req.Messages), len(req.Tools))
if isAuto(model) {
chain := g.core.AutoChain()
// The key's own chain wins; keys without one inherit the global chain.
chain, _ := g.core.AutoChainFor(reqKey(r.Context()))
if chain == nil || len(chain.Tiers) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
return
@ -451,6 +462,11 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
}
// Note: a key whose scope excludes AUTO is rejected earlier, by
// checkQuota(ctx, "AUTO") above, which produces the same message. That
// ordering matters — checkQuota runs before the chain is consulted, so it
// is the check an AUTO request actually hits. Duplicating it here would be
// dead code.
cands, effective := g.resolveCands(r.Context(), &req)
if len(cands) == 0 {
writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model))
@ -773,6 +789,18 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
// Cache accounting: whichever source format the adapter normalized into
// (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens),
// read it back so the request record carries the hit/miss split.
// reasoning_tokens is a SUBSET of completion_tokens and is billed at the
// completion rate by every provider measured, so it must NOT be added to
// Compl. Recording it separately is what lets a price rule split thinking
// from output for the providers that do price them differently, and what
// keeps the billing plugin from guessing.
//
// Leaving it unrecorded is worse than useless: the adapters already parse it
// and forward it (opencodego/gemini/anthropic all do), so the number exists
// in the response and is thrown away at the one place that could keep it.
if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
rec.Reasoning = int64(d.ReasoningTokens)
}
if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 {
rec.CacheHit = int64(d.CachedTokens)
}
@ -1028,9 +1056,12 @@ 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,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,
// 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,
// 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
@ -1184,20 +1215,7 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type
var tut *types.TokenUsage
if lastUsage != nil {
tut = 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)
}
applyStreamUsageDetails(rec, lastUsage)
} else if rec.Prompt+rec.Compl > 0 {
u := types.TokenUsage{
Prompt: int(rec.Prompt),
@ -1428,3 +1446,39 @@ 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

@ -0,0 +1,160 @@
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

@ -0,0 +1,749 @@
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

@ -0,0 +1,403 @@
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

@ -0,0 +1,314 @@
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

@ -0,0 +1,153 @@
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

@ -0,0 +1,303 @@
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.
//
// 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)
}
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)
}
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

@ -0,0 +1,171 @@
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

@ -0,0 +1,648 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,195 @@
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

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

View File

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

View File

@ -0,0 +1,323 @@
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

@ -0,0 +1,235 @@
-- 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

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

View File

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

View File

@ -0,0 +1,180 @@
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

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

View File

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

View File

@ -0,0 +1,152 @@
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

@ -0,0 +1,95 @@
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

@ -0,0 +1,244 @@
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

@ -0,0 +1,227 @@
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)
}
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,210 @@
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()
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
}
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

@ -0,0 +1,366 @@
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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,650 @@
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)
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

@ -0,0 +1,107 @@
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,6 +451,25 @@ 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,12 +196,27 @@ 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"`
@ -259,6 +274,7 @@ 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(),

71
packaging/upload-release.sh Executable file
View File

@ -0,0 +1,71 @@
#!/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