Compare commits

...

58 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
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
57 changed files with 12398 additions and 86 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.9.2",
"version": "1.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.9.2",
"version": "1.9.3",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

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

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
@ -444,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.
@ -455,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"
@ -68,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)
}
@ -615,9 +639,35 @@ 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

View File

@ -464,6 +464,13 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
"by_status": out.Status,
"buckets": out.Bucket,
"truncated": out.Truncated,
// "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

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)}
}
@ -452,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))
@ -774,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)
}
@ -1029,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
@ -1185,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),
@ -1429,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,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

@ -401,3 +401,34 @@ func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
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)
}
}
}

View File

@ -366,6 +366,22 @@
/* ---- chat page ---- */
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
.tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column;
height:calc(100vh - 190px)}
.img-wrap{flex:1;display:flex;flex-direction:column;min-height:0}
.img-grid{flex:1;overflow-y:auto;padding:12px 2px;display:grid;
grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;align-content:start}
.img-grid .chat-empty{grid-column:1/-1}
.img-cell{border:1px solid var(--line);border-radius:10px;overflow:hidden;
background:var(--bg2)}
.img-cell img{display:block;width:100%;height:auto;background:#fff}
.img-meta{padding:6px 8px;font-size:11px;color:var(--muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.img-composer{border-top:1px solid var(--line);padding:10px 2px 12px}
.img-composer .chat-box{display:flex;gap:8px;align-items:flex-end}
.img-composer textarea{flex:1;resize:vertical;min-height:52px}
.img-composer .empty{padding:8px 0}
.chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:18px;box-shadow:var(--sh-lg);
display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;backdrop-filter:blur(var(--glass)) saturate(1.5)}
.chat-log{flex:1;overflow-y:auto;padding:20px 20px 8px;display:flex;flex-direction:column;gap:18px}
@ -517,7 +533,20 @@
#toast{left:12px;right:12px;bottom:12px;text-align:center}
th,td{padding:8px 10px}
}
</style>
/* Shown when a key's model scope blocks AUTO: the chain editor can look
fine while every request 403s, so the conflict must be visible here. */
.warn-box {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(224, 108, 51, 0.45);
background: rgba(224, 108, 51, 0.1);
color: #b45309;
font-size: 12.5px;
line-height: 1.5;
}
</style>
</head>
<body>
<div id="bgfx" aria-hidden="true">
@ -560,6 +589,21 @@
/></svg
><span data-i="navChat">对话</span>
</button>
<button class="sb-i" data-tab="image">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 5h18v14H3z" />
<path d="m3 15 5-5 4 4 3-3 6 6" />
<circle cx="8.5" cy="8.5" r="1.5" />
></svg
><span data-i="navImage">生图</span>
</button>
<button class="sb-i" data-tab="keys">
<svg
viewBox="0 0 24 24"
@ -778,6 +822,7 @@
<main id="main">
<div id="tab-status" class="tab-pane"></div>
<div id="tab-chat" class="tab-pane hidden"></div>
<div id="tab-image" class="tab-pane hidden"></div>
<div id="tab-keys" class="tab-pane hidden"></div>
<div id="tab-sort" class="tab-pane hidden"></div>
<div id="tab-sources" class="tab-pane hidden"></div>
@ -802,8 +847,21 @@
plTitle: "插件",
plState: "状态",
plStages: "阶段",
plActive: "启用中",
plDisabled: "已禁用",
plVisibility: "可见性",
plEndpoints: "端点",
plAccessTitle: "访问控制",
plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。",
plWholePlugin: "以下端点",
plKeyHint: "可用密钥名",
plAccessSave: "保存访问控制", plActive: "启用中",
tVisDefault: "跟随插件",
tVisAdmin: "仅 admin",
tVisRoles: "admin + user",
tVisAll: "所有人",
plOverridden: "已覆盖插件声明",
plEpsN: "%d 个端点", plDisabled: "已禁用",
plBroken: "加载失败",
plBuiltin: "内置",
plHooks: "个阶段",
@ -835,6 +893,7 @@
kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链",
kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链",
kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链",
kAutoChainScopeWarn: "警告:该密钥的模型范围不包含 AUTO,配了独立 AUTO 链也用不了。当前范围:",
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称",
kRole: "角色",
@ -914,6 +973,20 @@
adPoolPeak: "峰值",
adPoolSteps: "步长 (扩/缩)",
cModel: "模型",
cImageModels: "🖼 图像模型(不可选,请到「生图」页测试)",
urlHint: "填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。不确定就用上方「自动探测模型」,它会探测并填好正确形式。",
navImage: "生图",
imgWelcome: "输入提示词开始生成",
imgSlow: "生图可能需要 15 秒到 2 分钟,取决于上游模型",
imgPrompt: "描述你想要的画面…",
imgGenerate: "生成",
imgWorking: "生成中… %ds",
imgCount: "数量",
imgSize: "尺寸",
imgNoModels: "没有配置图像模型。在「源」里为某个源添加 kind: image 的模型后即可在此生成。",
imgNoData: "上游没有返回图片数据",
imgNeedPrompt: "请输入提示词",
imgFailed: "生成失败",
cMsg: "Enter 发送,Shift+Enter 换行",
send: "发送",
cClear: "清空",
@ -962,6 +1035,16 @@
mModels: "模型列表",
mAddModel: "+ 模型",
mMeta: "Meta",
discScan: "自动探测模型",
discScanning: "探测中…",
discHint: "填好 URL 和密钥后点「自动探测模型」,网关会拉取上游的模型列表并填入下方;仍需点保存才会生效。",
discNeedUrl: "请先填写 URL",
discEmpty: "上游返回的模型列表为空",
discFound: "已找到 %d 个模型",
discChatN: "对话 %d 个",
discImageN: "图像 %d 个",
discUnsaved: "尚未保存,点保存后生效",
discFailed: "探测失败",
mSave: "保存",
mCancel: "取消",
toastCopied: "已复制",
@ -1076,8 +1159,21 @@
plTitle: "Plugins",
plState: "State",
plStages: "Stages",
plActive: "Active",
plDisabled: "Disabled",
plVisibility: "Visibility",
plEndpoints: "Endpoints",
plAccessTitle: "Access control",
plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.",
plWholePlugin: "endpoints below",
plKeyHint: "Key names",
plAccessSave: "Save access", plActive: "Active",
tVisDefault: "follow plugin",
tVisAdmin: "admin only",
tVisRoles: "admin + user",
tVisAll: "everyone",
plOverridden: "overrides plugin",
plEpsN: "%d endpoints", plDisabled: "Disabled",
plBroken: "Failed to load",
plBuiltin: "Built-in",
plHooks: "stages",
@ -1109,6 +1205,7 @@
kAutoChainSeeded: "Copied the global AUTO chain as a starting point — saving gives this key its own chain",
kAutoChainNewOwn: "This key currently follows the global chain; saving switches it to the chain configured here",
kAutoChainWillInherit: "Saving makes this key follow the global AUTO chain again",
kAutoChainScopeWarn: "Warning: this key\u2019s model scope does not include AUTO, so a per-key AUTO chain will still be rejected. Current scope: ",
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name",
kRole: "Role",
@ -1191,6 +1288,20 @@
adPoolPeak: "Peak",
adPoolSteps: "Steps (grow/shrink)",
cModel: "Model",
cImageModels: "🖼 Image models (not selectable — use the Image tab)",
urlHint: "Host address, usually WITHOUT /v1 — the gateway appends the path. Vendors differ: with https://x.com/v1 chat works but image generation becomes /v1/v1/… and 404s. If unsure use Auto-discover above; it probes and fills in the correct form.",
navImage: "Image",
imgWelcome: "Describe an image to generate it",
imgSlow: "Generation can take 15s to 2min depending on the upstream model",
imgPrompt: "Describe the image you want…",
imgGenerate: "Generate",
imgWorking: "Generating… %ds",
imgCount: "Count",
imgSize: "Size",
imgNoModels: "No image models configured. Add a model with kind: image on the Sources page.",
imgNoData: "The upstream returned no image data",
imgNeedPrompt: "Enter a prompt first",
imgFailed: "Generation failed",
cMsg: "Enter to send, Shift+Enter for newline",
send: "Send",
cClear: "Clear",
@ -1240,6 +1351,16 @@
mModels: "Models",
mAddModel: "+ model",
mMeta: "Meta",
discScan: "Auto-discover",
discScanning: "Scanning…",
discHint: "Fill in the URL and key, then press Auto-discover: the gateway fetches the upstream catalog and fills the list below. You still have to save.",
discNeedUrl: "Enter the base URL first",
discEmpty: "The upstream returned an empty model list",
discFound: "Found %d models",
discChatN: "%d chat",
discImageN: "%d image",
discUnsaved: "not saved yet — press Save",
discFailed: "Discovery failed",
mSave: "Save",
mCancel: "Cancel",
toastCopied: "Copied",
@ -1430,7 +1551,7 @@
// keep in sync, and adding a tab meant finding all three. A plugin page
// that is routed but never shown is exactly the kind of silent gap that
// survives review.
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
const TABS = ["status", "chat", "image", "keys", "sort", "sources", "adapters", "plugins"];
// Declared HERE, with var, because goTab() below reads it. It used to be a
// `const` further down the file next to the other plugin state, which put
// goTab's read inside the temporal dead zone: the first click on a plugin
@ -1979,7 +2100,12 @@
const e = $(sel);
if (e) e.textContent = txt;
};
kset('[data-kpi="active"]', st.active || 0);
// No `|| 0` fallback: that fallback is what turned a MISSING field
// into a confident "0 active" on three of four views. If the server
// ever stops sending it again, an em dash says "unknown" and the
// regression is visible, instead of the gauge quietly claiming the
// gateway is idle while it serves traffic.
kset('[data-kpi="active"]', st.active === undefined ? "—" : fmtN(st.active));
kset('[data-kpi="reqs"]', fmtN(tot.reqs));
const rsub = $('[data-kpi="reqs-sub"]');
if (rsub)
@ -2673,28 +2799,65 @@ recsState.keyNames = keyNames || {};
/* ---------- chat tab ---------- */
let chatHistory = [];
let chatImages = [];
async function renderChat() {
const s = await api("/api/status");
// Prefer per-source options ("source:model" routing) when the caller sees
// sources (admin); otherwise fall back to the flat model list (user scope).
let optList;
if (Array.isArray(s.sources) && s.sources.length) {
optList = ["AUTO"];
s.sources.forEach((src) =>
(src.models || []).forEach((m) => {
optList.push(
`<option value="${escAttr(`${src.name}:${m}`)}">${esc(`${src.name} · ${m}`)}</option>`,
);
}),
);
} else {
optList = ["AUTO"].concat(
(s.models || []).map(
(m) => `<option value="${escAttr(m)}">${esc(m)}</option>`,
),
);
}
const finalOpts = optList.join("");
async function renderChat() {
const s = await api("/api/status");
// Prefer per-source options ("source:model" routing) when the caller sees
// sources (admin); otherwise fall back to the flat model list (user scope).
//
// Chat and image models are listed in SEPARATE groups. The backend used to
// hand this page one flat list with the kind stripped, so gpt-image-2 sat
// next to claude-sonnet-5.5 and picking it sent the request to /api/chat,
// which cannot serve it — a failure whose message never mentioned images.
//
// model_kinds parallels models; an older backend simply omits it, and then
// everything falls into the chat group (the pre-fix behaviour).
const optList = [];
const imgOpts = [];
const addModel = (value, label, kind) => {
const html = `<option value="${escAttr(value)}">${esc(label)}</option>`;
if (kind === "image") imgOpts.push(html);
else optList.push(html);
};
if (Array.isArray(s.sources) && s.sources.length) {
optList.push(`<option value="AUTO">AUTO</option>`);
s.sources.forEach((src) => {
// Prefer the kind-carrying list; fall back to the flat one.
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
? src.model_kinds
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
kinds.forEach((m) => {
const id = typeof m === "string" ? m : m.id;
addModel(
`${src.name}:${id}`,
`${src.name} · ${id}`,
typeof m === "string" ? "chat" : m.kind,
);
});
});
} else {
optList.push(`<option value="AUTO">AUTO</option>`);
(s.models || []).forEach((m) => {
addModel(m, m, "chat");
});
}
// Image models are listed but DISABLED, not merely grouped.
//
// Grouping alone still let an operator pick one, and picking it sent the
// request to /api/chat — which cannot serve an image model — producing a
// failure whose message never mentioned images. A disabled option explains
// itself ("use the Image tab"); an enabled one just fails.
//
// The disabled attribute goes on at CONSTRUCTION, not via a later
// replaceAll over the joined HTML: a plain String.replace only rewrites the
// first match, so every source after the first contributed selectable
// image models. Measured with six image models across two sources — the first
// was disabled and the other five were not.
const imgHtml = () => imgOpts.map((h) => h.replace("<option ", "<option disabled ")).join("");
const finalOpts = optList.join("") +
(imgOpts.length
? `<optgroup label="${escAttr(t("cImageModels"))}">${imgHtml()}</optgroup>`
: "");
$("#tab-chat").innerHTML = `
<div class="tab-chat">
<div class="chat-wrap">
@ -2738,6 +2901,133 @@ recsState.keyNames = keyNames || {};
$("#ct-imgs").style.display = "none";
$("#ct-msg").focus();
}
/* ---------- image tab ---------- */
// Image generation lives in its own page rather than as an option in the
// chat model dropdown.
//
// The dropdown used to list image models beside chat ones because
// /api/status flattened the catalog to []string and dropped the kind.
// Picking one sent the request to /api/chat, which cannot serve it. A
// second tab makes the distinction structural: the only models offered
// here are kind=image, and the endpoint called is the image one.
let imgBusy = false;
function imgModels() {
const out = [];
document.querySelectorAll("#it-model option").forEach((o) => {
if (o.value) out.push(o.value);
});
return out;
}
async function renderImage() {
const s = await api("/api/status");
const opts = [];
if (Array.isArray(s.sources) && s.sources.length) {
s.sources.forEach((src) => {
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
? src.model_kinds
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
kinds.forEach((m) => {
const id = typeof m === "string" ? m : m.id;
const kind = typeof m === "string" ? "chat" : m.kind;
if (kind !== "image") return;
opts.push(
`<option value="${escAttr(`${src.name}:${id}`)}">${esc(`${src.name} · ${id}`)}</option>`,
);
});
});
}
const body = opts.length
? ""
: `<div class="empty">${esc(t("imgNoModels"))}</div>`;
$("#tab-image").innerHTML = `
<div class="tab-image">
<div class="img-wrap">
<div class="img-grid" id="it-grid">
<div class="chat-empty" id="it-empty">
<div class="ring">M</div>
<p>${esc(t("imgWelcome"))}</p>
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
</div>
</div>
<div class="img-composer">
<div class="chat-tools">
<span class="tl">${t("cModel")}</span>
<select id="it-model" ${opts.length ? "" : "disabled"}>${opts.join("")}</select>
<span class="grow"></span>
<label class="tl" for="it-n">${t("imgCount")}</label>
<input id="it-n" type="number" min="1" max="4" value="1" style="width:64px" />
<label class="tl" for="it-size">${t("imgSize")}</label>
<input id="it-size" value="1024x1024" style="width:104px" />
</div>
<div class="chat-box">
<textarea id="it-prompt" rows="2" placeholder="${escAttr(t("imgPrompt"))}"></textarea>
<button class="sendbtn ghost" id="it-clear" onclick="clearImages()">${t("cClear")}</button>
<button class="sendbtn" id="it-send" onclick="sendImage()" ${opts.length ? "" : "disabled"}>${t("imgGenerate")}</button>
</div>
${body}
</div>
</div>
</div>`;
}
function clearImages() {
$("#it-grid").innerHTML = `<div class="chat-empty" id="it-empty">
<div class="ring">M</div>
<p>${esc(t("imgWelcome"))}</p>
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
</div>`;
}
// Image generation is slow — measured 16s to 2min on the one upstream
// tested — so the button reports elapsed time instead of looking hung.
async function sendImage() {
if (imgBusy) return;
const prompt = ($("#it-prompt").value || "").trim();
const model = $("#it-model").value;
if (!prompt) {
toast(t("imgNeedPrompt"));
return;
}
if (!model) {
toast(t("imgNoModels"));
return;
}
const n = Math.max(1, Math.min(4, parseInt($("#it-n").value, 10) || 1));
const size = ($("#it-size").value || "").trim();
imgBusy = true;
const btn = $("#it-send");
btn.disabled = true;
const t0 = Date.now();
const tick = setInterval(() => {
btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000)));
}, 1000);
try {
const r = await api("/v1/images/generations", {
method: "POST",
body: size ? { model: model, prompt: prompt, n: n, size: size } : { model: model, prompt: prompt, n: n },
});
const empty = $("#it-empty");
if (empty) empty.remove();
const grid = $("#it-grid");
const items = Array.isArray(r.data) ? r.data : [];
items.forEach((it) => {
const src = it.url || (it.b64_json ? "data:image/png;base64," + it.b64_json : "");
if (!src) return;
grid.insertAdjacentHTML(
"beforeend",
`<div class="img-cell"><img loading="lazy" src="${escAttr(src)}" alt="generated" />
<div class="img-meta">${esc(it.revised_prompt || model)}</div></div>`,
);
});
if (!items.length) toast(t("imgNoData"));
} catch (e) {
toast(t("imgFailed") + (e && e.message ? ": " + e.message : ""));
} finally {
clearInterval(tick);
imgBusy = false;
btn.disabled = false;
btn.textContent = t("imgGenerate");
}
}
function updateGrow() {
const ta = $("#ct-msg");
if (!ta) return;
@ -3088,13 +3378,15 @@ recsState.keyNames = keyNames || {};
.join("") +
"</select>";
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
<button class="ghost small" onclick="srcDiscover()">${t("discScan")}</button>
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
<div class="row">
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
<div class="hint">${t("urlHint")}</div>
<label>${t("mKey")}</label><input id="s-key" type="password" value="${escAttr(s.api_key || "")}">
<div class="row">
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
@ -3114,6 +3406,7 @@ recsState.keyNames = keyNames || {};
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<div class="hint" id="s-disc-hint">${t("discHint")}</div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
@ -3162,6 +3455,89 @@ recsState.keyNames = keyNames || {};
modelRow({ id: "", priority: 0, kind: "chat" }, div.children.length),
);
}
// srcDiscover asks the gateway to probe the upstream for its catalog and
// fills the model list from the answer. It never writes anything: the
// operator still has to press Save, because a wrong base_url produces a
// plausible-looking but useless source, and writing automatically would
// overwrite a working configuration with it.
async function srcDiscover() {
const urlEl = $("#s-url");
const keyEl = $("#s-key");
const baseUrl = (urlEl && urlEl.value.trim()) || "";
const apiKey = (keyEl && keyEl.value.trim()) || "";
const hint = $("#s-disc-hint");
const say = (msg) => {
if (hint) hint.textContent = msg;
};
if (!baseUrl) {
toast(t("discNeedUrl"));
return;
}
const btn = document.querySelector('button[onclick="srcDiscover()"]');
const restore = btn ? btn.textContent : null;
if (btn) {
btn.disabled = true;
btn.textContent = t("discScanning");
}
say(t("discScanning"));
try {
const r = await api("/api/sources/discover", {
method: "POST",
body: { base_url: baseUrl, api_key: apiKey },
});
// The server normalized the URL form. Showing the operator exactly
// what will be saved is the whole point of probing server-side
// instead of guessing in the browser.
if (r.base_url && urlEl) urlEl.value = r.base_url;
if (r.endpoint && $("#s-ep")) $("#s-ep").value = r.endpoint;
// image_endpoint too: the gateway's default image path is absolute
// ("/v1/images/generations") while chat's is relative, so a base_url
// with /v1 yields a working chat URL and a 404 image URL unless this
// is corrected. Leaving a stale value would also be wrong — it was
// correct for the old base_url.
if (r.image_endpoint && $("#s-img")) $("#s-img").value = r.image_endpoint;
// Array.isArray, not a truthy check: a JSON null here would make
// .concat fail and leave the form looking empty with no error.
const chat = Array.isArray(r.models) ? r.models : [];
const image = Array.isArray(r.image_models) ? r.image_models : [];
const all = chat.concat(image);
if (!all.length) {
say(t("discEmpty"));
return;
}
const box = $("#s-models");
if (box) {
box.innerHTML = all
.map((id, i) =>
modelRow(
{
id: id,
priority: image.indexOf(id) >= 0 ? 70 : 50,
kind: image.indexOf(id) >= 0 ? "image" : "chat",
},
i,
),
)
.join("");
}
const bits = [t("discFound").replace("%d", String(all.length))];
if (chat.length) bits.push(t("discChatN").replace("%d", String(chat.length)));
if (image.length) bits.push(t("discImageN").replace("%d", String(image.length)));
if (r.note) bits.push(r.note);
for (const w of Array.isArray(r.warnings) ? r.warnings : []) bits.push(w);
bits.push(t("discUnsaved"));
say(bits.join(" · "));
} catch (e) {
say(t("discFailed") + (e && e.message ? ": " + e.message : ""));
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = restore;
}
}
}
async function saveSource(btn) {
const models = [...document.querySelectorAll("#s-models .model-row")]
.map((row) => ({
@ -3420,7 +3796,8 @@ recsState.keyNames = keyNames || {};
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
<div class="hint">${t("urlHint")}</div>
<div class="row">
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
@ -3435,6 +3812,7 @@ recsState.keyNames = keyNames || {};
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<div class="hint" id="s-disc-hint-tpl">${t("discHint")}</div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
@ -3633,13 +4011,20 @@ recsState.keyNames = keyNames || {};
sortState.origin = JSON.stringify(sortState.lanes);
const kindToggle =
`<div class="kind-toggle"><button class="${sortState.kind === "chat" ? "" : "ghost"} small" onclick="switchSortKind('chat')">${t("kChat") || "聊天"}</button><button class="${sortState.kind === "image" ? "" : "ghost"} small" onclick="switchSortKind('image')">${t("kImage") || "生图"}</button></div>`;
// renderSortEditor replaces its mount wholesale, so the mount must be
// the canvas slot ALONE. Passing the .card (which holds the h2 with the
// save/reset/add buttons) used to wipe the toolbar on every open — the
// lane canvas survived, the buttons did not. The per-key modal never hit
// this because its mount only ever contained the canvas.
$("#tab-sort").innerHTML = `
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<div class="card">
<h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
<div id="sort-mount"></div>
</div>`;
renderSortEditor($("#tab-sort").querySelector(".card"), {
renderSortEditor($("#sort-mount"), {
canvasId: "scr-canvas",
});
}
@ -4451,6 +4836,8 @@ function afterChainEdit() {
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
<td>${state}</td>
<td>${hooks}</td>
<td>${plVisCell(j, p)}</td>
<td>${plEpsCell(j, p)}</td>
<td style="white-space:nowrap">
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
${toggle}
@ -4472,8 +4859,9 @@ function afterChainEdit() {
$("#tab-plugins").innerHTML = `
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
<div class="tbl-wrap"><table><tr>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th></th>
</tr>${rows || `<tr><td colspan="6" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
<div id="pl-access"></div>
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
</div>
@ -4486,6 +4874,161 @@ function afterChainEdit() {
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
</div>`;
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
renderPluginAccess(j);
}
const PL_VIS = [
["", "tVisDefault"],
["admin", "tVisAdmin"],
["user,admin", "tVisRoles"],
["all", "tVisAll"],
];
// plVisCell shows the plugin's own declaration next to any operator
// override, because "admin-only" means something different depending on
// whether the plugin chose it or the deployment imposed it. Editing goes
// through the same access API as endpoints so there is one place where
// visibility is stored.
function plVisCell(j, p) {
const decl = (p.ui && p.ui.visibility) || "";
const ov = (j.visibility || {})[p.name] || "";
const cur = ov || decl;
// Compare as SETS. The server stores whatever order the operator typed
// and we round-trip it verbatim, so "admin, user" must still light up a
// choice — otherwise the selector silently shows "follow plugin", and
// saving from there writes an empty override that WIDENS access back to
// the plugin's own declaration.
const norm = (v) =>
(v || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.sort()
.join(",");
const curN = norm(cur);
// An audience with no matching choice (one naming a @key, say) is offered
// as itself so the value in force is never hidden from the operator.
const opts = PL_VIS.some((c) => norm(c[0]) === curN)
? PL_VIS
: PL_VIS.concat([[cur, "plCustomVis"]]);
return `<select data-plvis="${escAttr(p.name)}" style="font-size:11.5px;max-width:180px">
${opts
.map(
(c) =>
`<option value="${c[0]}"${norm(c[0]) === curN ? " selected" : ""}>${
c[1] === "plCustomVis" ? "自定义: " + cur : t(c[1])
}</option>`,
)
.join("")}
</select>${
ov && decl && ov !== decl
? `<div class="muted" style="font-size:10.5px" title="${esc(decl)}">${t("plOverridden")}</div>`
: ""
}`;
}
function plEpsCell(j, p) {
const n = (j.http_access || []).filter((e) => e.plugin === p.name).length;
return n
? `<a href="#" onclick="document.getElementById('pl-access').scrollIntoView({behavior:'smooth'});return false">${t("plEpsN").replace("%d", n)}</a>`
: `<span class="muted">—</span>`;
}
// ---- visibility & endpoint access ----
//
// The plugin page is where an operator edits who can see what. The
// alternatives are editing a bundled .lua — overwritten on the next
// upgrade — or hand-editing config.yaml, with no feedback about what is
// actually in force. Each row shows the rule next to whether it was
// DECLARED by the plugin or OVERRIDDEN here, because "admin-only" means
// something different depending on which.
function renderPluginAccess(j) {
const host = $("#pl-access");
if (!host) return;
const eps = j.http_access || [];
if (!eps.length) { host.innerHTML = ""; return; }
const keys = (window.__gwKeyRows || []).map((k) => k.name).filter(Boolean);
const byPlugin = {};
eps.forEach((e) => { (byPlugin[e.plugin] = byPlugin[e.plugin] || []).push(e); });
let h = `<div class="card" style="margin-top:12px"><h2>${t("plAccessTitle")}</h2>
<p class="muted" style="font-size:12px">${t("plAccessHint")}</p>`;
Object.keys(byPlugin).forEach((pn) => {
h += `<div style="margin:0 0 14px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px">
<b style="font-size:13px">${esc(pn)}</b>
<span class="muted" style="font-size:11.5px">${t("plWholePlugin")}</span>
</div>`;
(byPlugin[pn] || []).forEach((e) => {
const ovr = (e.override_keys && e.override_keys.length) ||
(e.override_roles && e.override_roles.length);
const cur = ovr
? [...(e.override_keys || []).map((k) => "@" + k), ...(e.override_roles || [])].join(",")
: [...(e.keys || []).map((k) => "@" + k), ...(e.roles || []), e.public ? "public" : ""]
.filter(Boolean).join(",") || "admin";
h += `<div style="margin:6px 0 0;padding-left:10px;border-left:2px solid var(--bd)">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<code style="font-size:11.5px">${esc(e.method)} ${esc(e.full_path)}</code>
<span class="muted" style="font-size:11px">&rarr; ${esc(e.handler)}()</span>
${e.description ? `<span class="muted" style="font-size:11px">${esc(e.description)}</span>` : ""}
<span class="grow"></span>
<input data-ep="${esc(pn)}|${esc(e.path)}" value="${esc(cur)}"
style="max-width:340px;font-size:11.5px"
placeholder="admin, user, @homeagent, public">
</div>
${keys.length ? `<div style="font-size:11px;opacity:.7;margin-top:3px">${t("plKeyHint")}: ${esc(keys.join(", "))}</div>` : ""}
</div>`;
});
h += `</div>`;
});
h += `<p><button id="pl-access-save" onclick="savePluginAccess(this)">${t("plAccessSave")}</button></p></div>`;
host.innerHTML = h;
}
async function savePluginAccess(btn) {
btn.disabled = true;
try {
// Whole-plugin visibility first. Sent through the SAME access API as
// endpoints (path "*") so visibility has exactly one storage path.
const viss = Array.prototype.slice.call(
document.querySelectorAll("[data-plvis]"));
for (const sel of viss) {
const pn = sel.getAttribute("data-plvis");
const url =
"/api/plugins/" + encodeURIComponent(pn) + "/http/*/access";
const v = sel.value;
if (v) {
await api(url, { method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access: v }) });
} else {
await api(url, { method: "DELETE" });
}
}
const inputs = Array.prototype.slice.call(
document.querySelectorAll("#pl-access [data-ep]"));
for (const inp of inputs) {
const raw = inp.getAttribute("data-ep");
const sep = raw.indexOf("|");
const pn = raw.slice(0, sep), ep = raw.slice(sep + 1);
const url = "/api/plugins/" + encodeURIComponent(pn) + "/http/" +
encodeURIComponent(ep) + "/access";
const v = inp.value.trim();
if (v) {
await api(url, { method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access: v }) });
} else {
await api(url, { method: "DELETE" });
}
}
toast(t("toastSaved"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
} finally {
btn.disabled = false;
}
}
async function togglePlugin(name, disabled) {
@ -4811,8 +5354,11 @@ function afterChainEdit() {
async function loadKeys() {
const el = $("#k-list");
if (!el) return;
const j = await api("/api/keys");
const j = await api("/api/keys");
const ks = j.keys || [];
// Cached so the per-key AUTO editor can read a key's model scope
// without a second request (keyAutoScopeWarning).
window._keyRows = ks;
el.innerHTML = ks.length
? ks.map((k) => keyCanvasHtml(k)).join("")
: `<div class="muted">${t("kEmpty")}</div>`;
@ -5268,6 +5814,7 @@ function afterChainEdit() {
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2>
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
<div id="ka-scope-warn"></div>
<div id="ka-canvas-host"></div>
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
@ -5309,6 +5856,7 @@ try {
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
keyAutoState.seeded = seededFromGlobal;
keyAutoInheritNotice();
keyAutoScopeWarning(key);
} catch (e) {
toast(String(e));
}
@ -5364,6 +5912,31 @@ try {
paintSort();
keyAutoInheritNotice();
}
// keyAutoScopeWarning tells the admin when the key's model scope blocks
// AUTO outright. Configuring a per-key AUTO chain for such a key looks
// like it works, yet every request 403s — the scope filter runs before
// the chain is consulted. We deliberately do NOT widen the scope
// automatically: that would silently grant access nobody asked for.
function keyAutoScopeWarning(key) {
const host = $("#ka-scope-warn");
if (!host) return;
const rec = (window._keyRows || []).find((k) => k.key === key);
const models = (rec && rec.models) || [];
if (!models.length) {
host.innerHTML = "";
return;
}
const hasAuto = models.some(
(m) => (m.model || "").toUpperCase() === "AUTO",
);
host.innerHTML = hasAuto
? ""
: '<div class="warn-box">' +
esc(t("kAutoChainScopeWarn")) +
" " +
esc(models.map((m) => m.model).join(", ")) +
"</div>";
}
function keyAutoClose() {
const m = document.getElementById("key-auto-modal");
if (m) m.remove();
@ -5467,6 +6040,7 @@ toast(t("kAutoChainGlobal"));
const done = () => remountPluginElements();
if (tab === "status") return renderStatus().then(done, done);
if (tab === "chat") return renderChat().then(done, done);
if (tab === "image") return renderImage().then(done, done);
if (tab === "keys") return renderKeys().then(done, done);
if (tab === "sort") return renderSort().then(done, done);
if (tab === "sources") return renderSources().then(done, done);
@ -5518,6 +6092,60 @@ toast(t("kAutoChainGlobal"));
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error?.message || r.status);
return true;
},
// call invokes one of the plugin's OWN exported functions. Billing never
// needs this — its numbers arrive on hooks while the gateway forwards a
// request — but a plugin that must do work on demand (asking a vendor
// for a balance when the operator clicks Refresh) has no other way to
// reach its own code: no hook fires on a click.
//
// Admin-only server side, and the plugin must have listed the function in
// plugin.exports, so this cannot be used to reach a plugin's internals.
async call(name, fn, args) {
const r = await fetch(
"/api/plugins/" + encodeURIComponent(name) + "/call/" + encodeURIComponent(fn),
{
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args || {}),
},
);
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error?.message || r.status);
return body.result;
},
// fanout runs one of the plugin's exported functions once per argument
// set, IN PARALLEL, from a single request.
//
// `call` in a Promise.all is NOT this. Twenty concurrent /call/ requests
// arrive concurrently and then serialise on the plugin mutex inside the
// kernel — the lock is held across the Lua function, network wait
// included, because one Lua VM has one stack. Measured on a live
// gateway: twenty quota probes took 29s of wall clock through /call/,
// against 1.4–3.3s for a single uncontended probe. The browser-side
// concurrency bought nothing and the page looked simply broken.
//
// The parallelism has to happen where the fan-out is received, so it
// takes one request. Results come back positionally aligned with `argSets`;
// a failed item is null in its slot rather than an error for the batch,
// because one unreachable upstream must not blank a whole page of cards.
//
// Same authorization as call: admin-only, and the function must be in
// the plugin's exports.
async fanout(name, fn, argSets) {
const r = await fetch(
"/api/plugins/" + encodeURIComponent(name) + "/fanout/" + encodeURIComponent(fn),
{
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ args: argSets || [] }),
},
);
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error?.message || r.status);
return body.results || [];
},
onTabShown(fn) {
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
PLUGIN_TAB_CBS.__last.push(fn);

View File

@ -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

@ -1,6 +1,7 @@
package gateway
import (
"os"
"strings"
"testing"
)
@ -329,3 +330,109 @@ func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
}
}
}
// 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) {
@ -938,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
@ -1614,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

@ -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