Compare commits

..

8 Commits

Author SHA1 Message Date
0938a45ced fix(ui): per-key 弹窗复用共享弹窗ID、编辑即落盘、空画布显示226个模型
截图看渲染 + 交互实测后发现的四个问题,都出在我自己新写的代码里。

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

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

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

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

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

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

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

两处数据源也不一致:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 07:45:54 +08:00
19 changed files with 2133 additions and 127 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -384,15 +384,25 @@ type SourceTemplate struct {
// (full management) or "user" (sees only its own key); Models is the allowed
// model scope with per-model token quota (0 = unlimited).
type GWKey struct {
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
// Auto is this key's own AUTO scheduling chain. It overrides the global
// cfg.Auto for requests authenticated with this key; nil (or an empty
// list) means "inherit the global chain", so keys created before per-key
// chains existed keep working untouched.
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
}
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
// inheriting the global one. An explicitly empty list still counts as "no own
// chain" so a user can clear an override back to the global default.
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
//
// Profiles are the "let the user pick" axis: the same upstream URL can be

View File

@ -37,6 +37,12 @@ type Core struct {
registry *provider.Registry
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
// keyAutoChains caches per-key AUTO chains, keyed by the gateway key.
// Entries are rebuilt by rebuildKeyAutoChains whenever sources or key
// config change; AutoChainFor reads them without taking c.mu so request
// handling stays lock-free like the global chain lookup.
keyAutoChains atomic.Pointer[map[string]*scheduler.Chain]
}
// New builds the core from a config file plus runtime overlay.
@ -257,6 +263,68 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
// AutoChain returns the current AUTO scheduling chain.
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
// AutoChainFor returns the AUTO chain to use for a request authenticated with
// key. A key that declares its own chain gets that chain; every other key
// inherits the global chain, so keys created before per-key chains existed
// keep their current behaviour with no configuration change.
//
// It returns (chain, ok). ok is false only when the key has its own chain but
// it compiled to nothing usable (e.g. every slot names a model that no longer
// exists) — the caller must surface that rather than silently downgrading the
// user to the global chain, which would be a confusing, invisible switch.
func (c *Core) AutoChainFor(key string) (*scheduler.Chain, bool) {
if m := c.keyAutoChains.Load(); m != nil {
if ch, ok := (*m)[key]; ok {
return ch, true
}
}
return c.autoChain.Load(), true
}
// rebuildKeyAutoChains recompiles every per-key AUTO chain from the current
// config and provider registry, then swaps the cache in one shot so requests
// never observe a half-built map. Keys without their own chain are absent
// from the map and fall back to the global chain.
func (c *Core) rebuildKeyAutoChains() {
m := make(map[string]*scheduler.Chain)
for _, k := range c.cfg.Keys {
if !k.HasOwnAuto() {
continue
}
m[k.Key] = c.chainForRules(k.Auto, false)
}
c.keyAutoChains.Store(&m)
}
// SaveKeyAuto persists one key's own AUTO chain and recompiles the per-key
// chain cache. Passing an empty list clears the override so the key inherits
// the global chain again.
func (c *Core) SaveKeyAuto(key string, entries []config.ModelScope) error {
c.mu.Lock()
defer c.mu.Unlock()
idx := -1
for i, k := range c.cfg.Keys {
if k.Key == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("key not found")
}
if err := ValidateScopeQuotas(entries); err != nil {
return err
}
c.cfg.Keys[idx].Auto = cleanScopes(entries)
if err := c.saveConfig(); err != nil {
return err
}
// Rebuild the whole cache so a cleared override stops shadowing the
// global chain for this key.
c.rebuildKeyAutoChains()
return nil
}
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
@ -546,6 +614,7 @@ func (c *Core) rebuildRegistry() error {
}
c.buildAutoChain()
c.buildAutoImageChain()
c.rebuildKeyAutoChains()
return nil
}
@ -555,17 +624,28 @@ func (c *Core) rebuildRegistry() error {
// back to the source's best chat model and cooldown/quota bookkeeping would
// key on a name that never matches.
func (c *Core) buildAutoChain() {
c.autoChain.Store(c.chainForRules(c.cfg.Auto, false))
}
// chainForRules compiles a chat AUTO chain from a rule list. kindFilter is
// false for the chat chain (image models are skipped) and true for the image
// chain (only image models participate). Per-key chains always compile as
// chat chains: a user's key configures the models *they* talk to.
func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *scheduler.Chain {
prov := func(model, source string) scheduler.Provider {
p := c.registry.ProviderForSlot(model, source)
if p == nil {
return nil
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
m := p.ModelByID(model)
if imageOnly && (m == nil || m.Kind != "image") {
return nil
}
if !imageOnly && m != nil && m.Kind == "image" {
return nil
}
return p
}
rules := c.cfg.Auto
sr := make([]scheduler.Rule, 0, len(rules))
for _, e := range rules {
model, source := e.Model, e.Source
@ -574,7 +654,11 @@ func (c *Core) buildAutoChain() {
model = exact
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
continue // image-kind models never join the chat AUTO chain
if !imageOnly {
continue // image-kind models never join the chat AUTO chain
}
} else if imageOnly {
continue
}
if source == "" {
source = p.Name()
@ -589,7 +673,7 @@ func (c *Core) buildAutoChain() {
Hours: e.Hours,
})
}
c.autoChain.Store(scheduler.BuildChain(sr, prov))
return scheduler.BuildChain(sr, prov)
}
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.

View File

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

View File

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

View File

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

View File

@ -400,7 +400,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
// TestHooksFireOnRealDirectChat, not by reading the code.
g.fireStart(r.Context(), &req, "chat", model, len(req.Messages), len(req.Tools))
if isAuto(model) {
chain := g.core.AutoChain()
// The key's own chain wins; keys without one inherit the global chain.
chain, _ := g.core.AutoChainFor(reqKey(r.Context()))
if chain == nil || len(chain.Tiers) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
return

View File

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

View File

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

View File

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

View File

@ -319,3 +319,85 @@ func TestPeriodFromQuery(t *testing.T) {
}
}
}
// TestPeriodWeekMayStartBeforeMonth pins a fact that looks like a bug and
// gets reported as one: the calendar periods are NOT nested.
//
// On 2026-10-03 (a Saturday) the ISO week runs Mon 2026-09-28 .. Sat
// 10-03, while the month window is 10-01 .. 10-03. The week therefore reaches
// back into the previous month and its total is legitimately LARGER than the
// month's. Anyone reading only the totals will conclude the aggregator is
// broken; it is not, and this test exists so a future "fix" that makes the
// windows nested (e.g. clamping the week to the month) is caught as the
// regression it would be.
func TestPeriodWeekMayStartBeforeMonth(t *testing.T) {
// Saturday 2026-10-03, UTC.
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
weekFrom := periodStart(PeriodWeek, now)
monthFrom := periodStart(PeriodMonth, now)
weekFromD := time.UnixMilli(weekFrom).UTC()
monthFromD := time.UnixMilli(monthFrom).UTC()
if got := weekFromD.Format("2006-01-02"); got != "2026-09-28" {
t.Fatalf("week window start = %s, want 2026-09-28 (the Monday)", got)
}
if got := monthFromD.Format("2006-01-02"); got != "2026-10-01" {
t.Fatalf("month window start = %s, want 2026-10-01", got)
}
if !weekFromD.Before(monthFromD) {
t.Fatal("this test only means anything when the week starts before the month")
}
// The week must reach strictly further back than the month window does,
// otherwise the nesting intuition would hold and the UI note would be noise.
if back := monthFromD.Sub(weekFromD).Hours() / 24; back <= 0 {
t.Fatalf("week should start at least a day before the month window, got %v days back", back)
}
}
// The same overlap must be visible in the aggregate numbers, not just in the
// window bounds: a week total larger than a month total is the exact symptom
// an operator reported.
func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
dir := t.TempDir()
audit := filepath.Join(dir, "audit.jsonl")
// Two records before the month window (inside this ISO week) and one
// inside October.
pre := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC).UnixMilli()
in1 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
in2 := time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC).UnixMilli()
auditPeriod(t, audit,
Req{Time: pre, OK: true, Prompt: 100, Compl: 10},
Req{Time: pre + 1000, OK: true, Prompt: 100, Compl: 10},
Req{Time: in1, OK: true, Prompt: 50, Compl: 5},
Req{Time: in2, OK: true, Prompt: 50, Compl: 5},
)
s := periodStats(t, audit)
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
week := s.PeriodSnapshot(PeriodWeek, "", now)
month := s.PeriodSnapshot(PeriodMonth, "", now)
if week.Total.Reqs != 4 {
t.Fatalf("week reqs = %d, want 4 (both September records fall in this ISO week)", week.Total.Reqs)
}
if month.Total.Reqs != 2 {
t.Fatalf("month reqs = %d, want 2", month.Total.Reqs)
}
if week.Total.Tokens <= month.Total.Tokens {
t.Fatalf("week tokens (%d) must exceed month tokens (%d): the week reaches into September",
week.Total.Tokens, month.Total.Tokens)
}
// Day must be a strict subset of both, which is the only nesting that holds.
day := s.PeriodSnapshot(PeriodDay, "", now)
if day.Total.Reqs != 1 {
t.Fatalf("day reqs = %d, want 1", day.Total.Reqs)
}
if day.Total.Tokens >= month.Total.Tokens || day.Total.Tokens >= week.Total.Tokens {
t.Fatal("today must be contained in both the week and the month totals")
}
}

View File

@ -822,6 +822,17 @@
keysHint:
"管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。",
kCreate: "新建密钥",
kAutoChain: "AUTO 链",
kAutoChainOwn: "独立链",
kAutoChainGlobal: "跟随全局",
kAutoChainTitle: "独立 AUTO 链",
kAutoChainHint: "为空则跟随全局 AUTO 链",
kAutoChainInherit: "使用全局链",
kAutoChainAdd: "+ 添加槽位",
kAutoChainSave: "保存",
kAutoChainTier: "层级",
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称",
kRole: "角色",
kRoleAdmin: "管理员",
@ -1043,6 +1054,7 @@
periodWeek: "本周",
periodMonth: "本月",
periodAll: "全部",
periodRange: "统计区间:",
periodTruncated:
"统计周期可能不完整(审计日志已轮转),数值为下限。",
recsPartial:
@ -1081,6 +1093,17 @@
keysHint:
"Admin keys can view and manage every key and configure each user key\u0027s allowed models; user keys only see themselves.",
kCreate: "Create key",
kAutoChain: "AUTO chain",
kAutoChainOwn: "own",
kAutoChainGlobal: "global",
kAutoChainTitle: "Per-key AUTO chain",
kAutoChainHint: "Leave empty to inherit the global AUTO chain",
kAutoChainInherit: "Use global chain",
kAutoChainAdd: "+ Add slot",
kAutoChainSave: "Save",
kAutoChainTier: "Tier",
kAutoChainEmpty: "This key uses the global AUTO chain",
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name",
kRole: "Role",
kRoleAdmin: "Admin",
@ -1306,6 +1329,7 @@
periodWeek: "This week",
periodMonth: "This month",
periodAll: "All time",
periodRange: "Window:",
periodTruncated:
"This window may be incomplete (audit log rotated); figures are a lower bound.",
recsPartial:
@ -1717,9 +1741,10 @@
<option value="month">${t("periodMonth")}</option>
<option value="all">${t("periodAll")}</option>
</select>
<span class="grow"></span>
<span class="grow"></span>
<span id="period-note"></span>
</div>
<div class="muted" id="period-range" style="margin:-6px 0 12px"></div>
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
5,
)
@ -1979,11 +2004,31 @@
// window may be incomplete the numbers are a lower bound, and saying
// so next to them is the whole point — a confidently wrong zero for a
// rotated-away week is indistinguishable from a quiet week.
const pnote = $("#period-note");
const pnote = $("#period-note");
if (pnote)
pnote.innerHTML = st.truncated
? `<span class="muted" title="${escAttr(t("periodTruncated"))}">ⓘ ${esc(t("periodTruncated"))}</span>`
: "";
// Show the window the numbers actually cover. The calendar periods
// are not nested: "this week" runs from its Monday, so early in a
// month it legitimately reaches back into the previous month and
// its total EXCEEDS "this month". That is correct, but it looks
// like a bug unless the dates are on screen — so print them.
const prange = $("#period-range");
if (prange && st.period && st.period !== "all" && st.from) {
const d0 = new Date(st.from);
const d1 = st.buckets && st.buckets.length
? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z")
: d0;
const f = (d) =>
d.toISOString().slice(0, 10);
prange.textContent =
t("periodRange") +
" " + f(d0) + " → " + f(d1) +
(st.buckets ? " (" + st.buckets.length + "d)" : "");
} else if (prange) {
prange.textContent = "";
}
const rex = $("#rec-exit");
if (rex)
rex.innerHTML = statsKeyF
@ -2373,8 +2418,14 @@
function paintRecords(records, keyNames) {
const el = $("#tb-recs");
if (!el) return;
recsState.keyNames = keyNames || {};
const incoming = (records || []).slice().reverse(); // API sends oldest-first
recsState.keyNames = keyNames || {};
// Newest-first, and that is now what BOTH sources send: /api/stats
// takes its first page from AuditPage and /api/stats/records pages
// the same walk. The .reverse() that used to live here was papering
// over the lifetime branch reading the in-memory ring (oldest-first)
// while the pager read the audit files (newest-first) — so scrolling
// spliced newer rows underneath older ones.
const incoming = records || [];
if (!recsState.built) {
if (!incoming.length) {
@ -3435,8 +3486,96 @@
// the toggle switches which chain the canvas edits
const sortLanes = { chat: [], image: [] };
let sortStateMap = new Map();
// renderSortEditor paints the shared lane canvas into `mount` for whichever
// chain sortState currently describes. Both the global editor and the
// per-key modal call it, so a key's chain gets the identical canvas
// (drag ordering, per-slot quota/period/hours) rather than a trimmed copy.
function renderSortEditor(mount, opts) {
const o = opts || {};
mount.innerHTML = `
<div class="sort-canvas" id="${o.canvasId}"></div>
<div class="scr-hint">
${
sortState.lanes
.map((l) => l.models[0])
.map((it) => {
const c = srcColor(it.src);
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`;
})
.filter((v, i, a) => a.indexOf(v) === i)
.join("") || ""
}
</div>`;
paintSort();
}
// sortIndex holds the model/source lookups buildLanes needs. It is built once
// per editor open so both the global chain and a per-key chain can compile
// rules into the same lane structure.
function buildSortIndex(sources) {
const byModel = new Map();
const byPair = new Map();
const sourceRows = new Map();
(sources || []).forEach((s) =>
(s.models || []).forEach((m) => {
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
byPair.set(m.id + "|" + s.name, it);
if (!byModel.has(m.id)) byModel.set(m.id, it);
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
}),
);
return { byModel, byPair, sourceRows };
}
// buildLanes compiles saved rules into the tier-lane structure the canvas
// paints. With no rules it falls back to discovery, grouped by source
// priority, so an unconfigured chain still shows every available slot.
// Shared by the global editor and the per-key editor.
function buildLanes(idx, rules, kind) {
const { byModel, byPair, sourceRows } = idx;
if (rules.length) {
const tiers = [];
rules.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair || byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
});
});
return tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
}
return [...sourceRows.entries()]
.sort((a, b) => b[0] - a[0])
.map(([prio, models]) => {
const same = models.filter((m) => {
const it = byPair.get(m.id + "|" + m.src);
const k = it ? it.kind : "chat";
return k === kind;
});
if (!same.length) return null;
same.sort((x, y) => (x.src < y.src ? -1 : x.src > y.src ? 1 : 0));
same.forEach((m) => {
m.uid = nexUid();
m.meta = null;
});
return { prio, models: same };
})
.filter(Boolean);
}
async function renderSort() {
const j = await api("/api/v1/sources");
const j = await api("/api/v1/sources");
let autoR = [];
let autoImg = [];
try {
@ -3450,65 +3589,12 @@
]),
);
} catch (e) {}
const byModel = new Map();
const byPair = new Map();
const sourceRows = new Map();
j.sources.forEach((s) =>
(s.models || []).forEach((m) => {
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
byPair.set(m.id + "|" + s.name, it);
if (!byModel.has(m.id)) byModel.set(m.id, it);
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
}),
);
const sortIdx = buildSortIndex(j.sources);
// build a lane structure for one kind from its saved rules; when
// rules are empty, fall back to discovery from sourceRows filtered by
// kind so an unconfigured chain still shows all available slots
const buildLanes = (rules, kind) => {
if (rules.length) {
const tiers = [];
rules.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair ||
byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
});
});
return tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
}
// discovery: group slots of this kind by their source priority
return [...sourceRows.entries()]
.sort((a, b) => b[0] - a[0])
.map(([prio, models]) => {
const same = models.filter((m) => {
const it = byPair.get(m.id + "|" + m.src);
const k = it ? it.kind : "chat";
return k === kind;
});
if (!same.length) return null;
same.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
same.forEach((m) => { m.uid = nexUid(); m.meta = null; });
return { prio, models: same };
})
.filter(Boolean);
};
sortLanes.chat = buildLanes(autoR, "chat");
sortLanes.image = buildLanes(autoImg, "image");
sortLanes.chat = buildLanes(sortIdx, autoR, "chat");
sortLanes.image = buildLanes(sortIdx, autoImg, "image");
sortState.lanes = sortLanes[sortState.kind] || [];
// model picker for the "add slot" control: one option per (source, model)
// pair so identical model ids on different sources stay distinguishable
@ -3520,22 +3606,11 @@
<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>
<div class="sort-canvas" id="scr-canvas"></div>
<div class="scr-hint">
${
sortState.lanes
.map((l) => l.models[0])
.map((it) => {
const c = srcColor(it.src);
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`;
})
.filter((v, i, a) => a.indexOf(v) === i)
.join("") || ""
}
</div>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
</div>`;
paintSort();
renderSortEditor($("#tab-sort").querySelector(".card"), {
canvasId: "scr-canvas",
});
}
function switchSortKind(k) {
if (sortState.kind === k) return;
@ -3620,9 +3695,26 @@
<span class="scr-grip"><i></i><i></i><i></i></span>
</div>`;
}
// sortCanvasEl resolves the canvas the editor paints into. The global editor
// owns #scr-canvas; the per-key editor renders the same canvas under a
// different id inside its modal, so both share one implementation
// instead of a reduced second copy that would drift.
function sortCanvasEl() {
// The modal editor is mounted on top of the settings tab, so BOTH
// canvases can be in the DOM at once — #scr-canvas belongs to the
// hidden tab behind it. Resolve by scope, not by document order: when
// sortState.scope names a key we are editing THAT key's chain, and
// painting into the background tab's canvas both shows an empty
// dialog and corrupts the global chain the user comes back to.
if (sortState.scope) {
const k = document.getElementById("key-auto-canvas");
if (k) return k;
}
return document.getElementById("scr-canvas");
}
function paintSort(affected) {
const cv = $("#scr-canvas");
if (!cv) return;
const cv = sortCanvasEl();
if (!cv) return;
// compute the next lane-leaders so we can retract the outgoing ones first
const nextFirst = new Set();
sortState.lanes.forEach((lane) => {
@ -3674,8 +3766,8 @@
}
paintSortNow(affected);
}
function paintSortNow(affected) {
const cv = $("#scr-canvas");
function paintSortNow(affected) {
const cv = sortCanvasEl();
if (!cv) return;
// FLIP: capture old positions of lanes and blocks before repaint
const prev = new Map();
@ -3822,9 +3914,9 @@
});
}
function onSortMove(e) {
const d = sortState.drag;
const d = sortState.drag;
if (!d) return;
const cv = document.getElementById("scr-canvas");
const cv = sortCanvasEl();
if (!cv) return;
const laneEls = [...cv.querySelectorAll(".scr-lane")];
const gapEls = [...cv.querySelectorAll(".scr-gap")];
@ -3933,7 +4025,8 @@
if (!d) return;
const line = document.getElementById("scr-blockline");
if (line) line.remove();
const cv = document.getElementById("scr-canvas");
const cv = sortCanvasEl();
if (!cv) return;
cv.querySelectorAll(".scr-gap, .scr-lane").forEach((el) =>
el.classList.remove("hover", "drophere"),
);
@ -4033,10 +4126,61 @@
}
/* ---------- AUTO quota editing on sort blocks ---------- */
// sortState.scope decides where saveSort() persists: null/undefined =
// the global chain, or a gateway key = that key's own chain. The canvas,
// drag/drop and slot metadata all work unchanged in both modes; only the
// persistence endpoint and the chain that gets loaded differ. Keeping one
// editor means a per-key chain gets exactly the features the global one
// has (drag ordering, per-slot quota/period/hours) instead of a reduced
// second implementation that would drift.
// afterChainEdit is the single place an in-canvas edit decides whether to
// write through. On the GLOBAL page edits take effect immediately (that
// page has always behaved that way). In the per-key dialog they must NOT:
// that dialog has Save and Cancel, and auto-persisting meant a slot added
// (or dragged, or deleted) was already committed to the user's chain
// before Save — Cancel could not undo it, and the "Click Save for it to
// take effect" hint was a lie.
function afterChainEdit() {
// Every in-canvas edit lands here, so this is the one place that keeps
// the "uses the global chain" notice honest while the user works.
if (typeof keyAutoInheritNotice === "function") keyAutoInheritNotice();
if (sortState.scope) {
toast(t("sortHintSave"));
return;
}
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
}
async function persistAuto() {
const rules = lanesToRules(sortState.lanes);
if (sortState.scope) {
// Empty rules mean "inherit the global chain" server-side, so the
// same PUT both saves a chain and clears the override.
await api("/api/keys/" + encodeURIComponent(sortState.scope) + "/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auto: rules }),
});
return;
}
// each kind persists to its own chain; the other kind's saved rules
// are left untouched (omitting the field means "no change")
const body = { rules: undefined, image_rules: undefined };
if (sortState.kind === "image") body.image_rules = rules;
else body.rules = rules;
await api("/api/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
// lanesToRules flattens the tier lanes into the wire shape shared by
// /api/auto and /api/keys/{key}/auto.
function lanesToRules(lanes) {
const rules = [];
sortState.lanes.forEach((lane, li) =>
lane.models.forEach((it) => {
(lanes || []).forEach((lane, li) =>
(lane.models || []).forEach((it) => {
const m = it.meta;
rules.push({
model: it.id,
@ -4048,16 +4192,7 @@
});
}),
);
// each kind persists to its own chain; the other kind's saved rules
// are left untouched (omitting the field means "no change")
const body = { rules: undefined, image_rules: undefined };
if (sortState.kind === "image") body.image_rules = rules;
else body.rules = rules;
await api("/api/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return rules;
}
function scrAddModal() {
const cur = { quota: 0, period: "", hours: 0 };
@ -4132,9 +4267,7 @@
if (btn) btn.closest("#modal-wrap")?.remove();
else closeTopModal();
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
afterChainEdit();
}
function sortScopeSave(li, ji, btn) {
const it = sortState.lanes[li].models[ji];
@ -4147,9 +4280,7 @@
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
afterChainEdit();
}
function scrCtx(e, li, ji) {
if (e.button === 2) {
@ -4212,9 +4343,7 @@
meta: it.meta ? { ...it.meta } : null,
});
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
afterChainEdit();
},
},
];
@ -4225,9 +4354,7 @@
fn: () => {
it.meta = null;
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
afterChainEdit();
},
});
items.push({
@ -4243,9 +4370,7 @@
if (!sortState.lanes[li].models.length && sortState.lanes.length > 1)
sortState.lanes.splice(li, 1);
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
afterChainEdit();
}
/* ---------- adapters tab ---------- */
@ -4674,6 +4799,8 @@
<button class="ghost small" onclick="copyText('${escAttr(k.key)}')">${t("kCopy")}</button>
<span class="grow"></span>
<span class="muted">${fmtCreated(k.created_at)}</span>
<button class="ghost small" title="${escAttr(t("kAutoChainTitle"))}"
onclick="openKeyAutoModal('${escAttr(k.key)}','${escAttr(k.name || "")}')">${t("kAutoChain")}</button>
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">${t("kDel")}</button>
<button class="ghost small errc" title="${escAttr(t("kDel"))}" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">×</button>
</div>
@ -5085,6 +5212,146 @@
document.body.appendChild(wrap);
$("#kc-name").focus();
}
// Per-key AUTO chain editor. Kept deliberately simple: a plain table of
// slots rather than a drag canvas, because an admin usually either
// mirrors the global chain or deletes a few tiers, and the canvas layout
// for the model scope list would be overkill here.
// Per-key AUTO chain editor. It renders the SAME lane canvas as the
// global editor (drag ordering, per-slot quota/period/hours) by pointing
// sortState.scope at this key: persistAuto then saves to
// /api/keys/{key}/auto instead of /api/auto. A separate reduced editor
// would drift from the global one and lose features.
async function openKeyAutoModal(key, name) {
// This dialog deliberately does NOT reuse #modal-wrap. That id is
// shared by every dialog in the app and more than one can be open at
// once, so a lookup by id resolves to whichever comes first in the
// document — not the one the user is looking at. That is fine for
// handlers that can use `.closest()` from their own button, but this
// dialog also has to be found from the CANVAS side (sortCanvasEl,
// keyAutoClose, keyAutoInherit), so it needs an identity of its own.
document.getElementById("key-auto-modal")?.remove();
const wrap = document.createElement("div");
wrap.id = "key-auto-modal";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
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-canvas-host"></div>
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
<button class="small" onclick="keyAutoSave(this)">${esc(t("sortSave"))}</button>
<button class="ghost" onclick="keyAutoClose()">${esc(t("mCancel"))}</button></p>
</div>`;
document.body.appendChild(wrap);
// Park the key on the modal so a click that bubbles up cannot hit the
// global editor behind it.
wrap.addEventListener("click", (ev) => ev.stopPropagation());
try {
const j = await api("/api/v1/sources");
const idx = buildSortIndex(j.sources || []);
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
keyAutoState.key = key;
keyAutoState.inherits = !!cur.inherits;
// A key with no own chain shows an EMPTY canvas, never the discovery
// fallback buildLanes uses for an unconfigured global chain: that
// fallback fills every source's models (226 blocks on a real
// deployment), and showing them inside "this key's chain" dialog
// says they already belong to this user. They do not.
sortState.scope = key;
sortState.kind = "chat";
sortState.lanes =
cur.auto && cur.auto.length ? buildLanes(idx, cur.auto, "chat") : [];
sortState.origin = JSON.stringify(sortState.lanes);
await loadModelPairs();
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
// The notice is the only thing telling the user what an empty
// canvas means here. Without it the dialog is just a blank box and
// "Use global chain" looks like a button that already did
// something. It disappears as soon as the chain has slots of its
// own, which keyAutoSave's reload then confirms.
if (keyAutoState.inherits) keyAutoInheritNotice();
} catch (e) {
toast(String(e));
}
}
const keyAutoState = { key: null, inherits: true };
// The notice describes what an EMPTY canvas means, so it must track the
// canvas rather than the state the dialog opened with: adding the first
// slot makes "this key uses the global chain" false, and leaving it up
// reads as a contradiction right next to the slot the user just added.
function keyAutoInheritNotice(showing) {
const host = $("#ka-canvas-host");
if (!host) return;
let n = host.querySelector(".ka-inherit-note");
const hasSlots = (sortState.lanes || []).some(
(l) => (l.models || []).length,
);
const want = showing === undefined ? !hasSlots : showing && !hasSlots;
if (!want) {
if (n) n.remove();
return;
}
if (!n) {
n = document.createElement("div");
n.className = "muted ka-inherit-note";
n.style.margin = "0 0 10px";
host.appendChild(n);
}
n.textContent = t("kAutoChainEmpty");
}
// "Use the global chain" empties the lanes; saving then PUTs an empty
// list, which the server treats as clearing the override.
function keyAutoInherit() {
keyAutoState.inherits = true;
sortState.lanes = [];
sortState.origin = "[]";
const host = $("#ka-canvas-host");
if (host) {
const c = document.getElementById("key-auto-canvas");
if (c) c.innerHTML = "";
}
paintSort();
keyAutoInheritNotice();
}
function keyAutoClose() {
const m = document.getElementById("key-auto-modal");
if (m) m.remove();
// Leaving scope set would make the global editor save to this key.
sortState.scope = null;
keyAutoState.key = null;
}
async function keyAutoSave(btn) {
const key = keyAutoState.key;
if (!key) return;
btn.disabled = true;
try {
// An empty lane list means "inherit global" server-side, so the same
// PUT both saves a chain and clears the override.
const j = await api("/api/keys/" + encodeURIComponent(key) + "/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
});
if (j.inherits) {
toast(t("kAutoChainGlobal"));
} else if (!j.slots) {
// Persisted, but nothing resolves. Still close: the write
// SUCCEEDED, and leaving the dialog open after a successful save
// makes the user click Save repeatedly. The warning carries the
// real problem — the toast survives the modal closing.
toast(t("kAutoChainBad"));
} else {
toast(t("sortSaved") + " (" + j.slots + ")");
}
keyAutoClose();
loadKeys();
} catch (e) {
toast(String(e));
btn.disabled = false;
}
}
async function createKey(btn) {
const name = $("#kc-name").value.trim();
if (!name) {

View File

@ -0,0 +1,268 @@
package gateway
import (
"strings"
"testing"
)
// Per-key AUTO chain UI contract.
//
// The per-key editor deliberately REUSES the global lane canvas instead of
// shipping a second, simpler editor. That is a design choice with teeth: a
// reduced copy would lack drag ordering and per-slot quota/period/hours, and
// the two would drift apart silently. These checks pin the sharing, because
// the only symptom of un-sharing is "the per-key editor got worse" — no error,
// just a smaller feature set that nobody notices until they use it.
func TestUIPerKeyAutoWiring(t *testing.T) {
src := uiSource(t)
// The admin key list must offer the editor, otherwise the whole feature is
// unreachable from the UI. Assert the *call site* inside keyCanvasHtml,
// not just that the functions exist: removing the button leaves every
// definition intact, and that is exactly the regression to catch.
kcStart := strings.Index(src, "function keyCanvasHtml")
if kcStart < 0 {
t.Fatal("keyCanvasHtml is gone — cannot check the AUTO chain button")
}
kcEnd := strings.Index(src[kcStart:], "\n }")
if kcEnd < 0 {
t.Fatal("could not delimit keyCanvasHtml")
}
if !strings.Contains(src[kcStart:kcStart+kcEnd], "openKeyAutoModal(") {
t.Fatal("per-key AUTO chain button is missing from the key card header")
}
for _, fn := range []string{
"function openKeyAutoModal",
"function keyAutoInherit",
"function keyAutoSave",
"function keyAutoClose",
} {
if !strings.Contains(src, fn) {
t.Fatalf("UI is missing %s — the editor cannot function", fn)
}
}
}
// The per-key editor must render the SAME canvas the global editor uses.
// If this regresses, the per-key chain silently loses drag ordering and
// per-slot quota/period/hours while still looking like it works.
func TestUIPerKeyAutoReusesGlobalCanvas(t *testing.T) {
src := uiSource(t)
// One canvas implementation, two mount ids.
if !strings.Contains(src, "function renderSortEditor(") {
t.Fatal("no shared canvas renderer — the per-key editor must reuse renderSortEditor")
}
if !strings.Contains(src, `canvasId: "scr-canvas"`) {
t.Fatal("the global editor no longer mounts the shared canvas")
}
if !strings.Contains(src, `canvasId: "key-auto-canvas"`) {
t.Fatal("the per-key editor does not mount the shared canvas")
}
// Canvas lookup must consider both ids, or drag/drop silently no-ops on
// whichever surface is not #scr-canvas.
if !strings.Contains(src, "function sortCanvasEl(") ||
!strings.Contains(src, `getElementById("key-auto-canvas")`) {
t.Fatal("sortCanvasEl must resolve both canvas ids")
}
// The rules<->lanes conversion is shared too, so the per-key editor sends
// exactly the fields the server understands (quota/period/hours included).
for _, fn := range []string{"function buildLanes(", "function lanesToRules("} {
if !strings.Contains(src, fn) {
t.Fatalf("%s must be shared between the global and per-key editors", fn)
}
}
}
// Saving must route through persistAuto's scope switch, not a hand-rolled
// request, or the two editors will serialize rules differently.
func TestUIPerKeyAutoSavesThroughScope(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "sortState.scope = key") {
t.Fatal("openKeyAutoModal must set sortState.scope so the save targets the key")
}
if !strings.Contains(src, "sortState.scope = null") {
t.Fatal("closing the modal must clear sortState.scope, or the GLOBAL editor would save to this key")
}
if !strings.Contains(src, "if (sortState.scope)") {
t.Fatal("persistAuto must branch on sortState.scope")
}
if !strings.Contains(src, `"/auto"`) {
t.Fatal("per-key editor must call the /api/keys/{key}/auto endpoint")
}
// An empty lane list is how "inherit the global chain" is expressed.
if !strings.Contains(src, "JSON.stringify({ auto: lanesToRules(sortState.lanes) })") {
t.Fatal("keyAutoSave must PUT lanesToRules(sortState.lanes) verbatim")
}
}
// Both languages need the strings, otherwise a non-Chinese admin sees raw
// i18n keys in the modal.
func TestUIPerKeyAutoHasBothLanguages(t *testing.T) {
src := uiSource(t)
for _, k := range []string{
"kAutoChainTitle", "kAutoChainHint", "kAutoChainInherit",
"kAutoChainEmpty", "kAutoChainGlobal", "kAutoChainBad",
} {
n := strings.Count(src, k+`: "`)
if n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// The statistics dashboard must print the window it actually covers. Calendar
// periods are not nested — "this week" starts on its Monday and therefore
// reaches into the previous month early in a month, making its total LARGER
// than "this month". That is correct arithmetic, but without the dates on
// screen it reads as a bug, and it has been reported as one.
func TestUIStatsShowsPeriodWindow(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, `id="period-range"`) {
t.Fatal("the stats dashboard has no element for the period window range")
}
if n := strings.Count(src, "periodRange"); n < 2 {
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
}
}
// Both canvases can exist in the DOM at once: the modal is mounted on top of
// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
// canvas by document order (whichever comes first) painted the per-key chain
// into the hidden background tab and left the dialog empty — and worse,
// overwrote the global chain the user returns to. The lookup must follow
// sortState.scope instead.
func TestUISortCanvasResolvesByScope(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "sortCanvasEl")
if !strings.Contains(el, "if (sortState.scope)") {
t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
}
if strings.Contains(el, `getElementById("scr-canvas")`) &&
strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
"per-key editor paints into the global canvas")
}
}
// A successful save must close the dialog even when the slots do not resolve.
// The write DID succeed; leaving the modal open makes the user click Save
// again and again. This regressed once: the !slots branch returned early.
func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoSave")
// keyAutoClose() must be reached on every success path, not only in the
// happy branch.
iClose := strings.Index(body, "keyAutoClose()")
if iClose < 0 {
t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
}
iToast := strings.Index(body, "toast(")
if iToast >= 0 && iClose < iToast {
t.Fatal("keyAutoClose is called before the toast; the warning must be " +
"shown first or it is lost with the modal")
}
// An early `return` inside a branch bypasses keyAutoClose entirely, so
// merely finding the call is not enough: every `return` that sits between
// entering the try block and the close call is a path that leaves the
// dialog open after a successful write.
tryStart := strings.Index(body, "try {")
seg := body[tryStart:iClose]
for _, line := range strings.Split(seg, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
"a successful save: " + trimmed)
}
}
}
// readFuncBody returns the source of a top-level function by name, using
// brace matching so nested blocks do not truncate it.
func readFuncBody(t *testing.T, src, name string) string {
t.Helper()
idx := strings.Index(src, "function "+name+"(")
if idx < 0 {
t.Fatalf("function %s not found", name)
}
start := strings.Index(src[idx:], "{")
if start < 0 {
t.Fatalf("function %s has no body", name)
}
i := idx + start
depth := 0
for j := i; j < len(src); j++ {
switch src[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[i : j+1]
}
}
}
t.Fatalf("function %s body is unterminated", name)
return ""
}
// The per-key dialog must not share the app-wide #modal-wrap id.
//
// Every dialog in this file uses that id, and more than one can be open at
// once (the "add slot" picker opens ON TOP of this one). Handlers resolve
// their own dialog via `.closest()` from their button, which works — but this
// dialog is also looked up from the CANVAS side (sortCanvasEl, keyAutoClose,
// keyAutoInherit), where there is no button to walk up from.
// getElementById("#modal-wrap") then returns whichever dialog comes first in
// the document, i.e. not the one being edited: Cancel closed the wrong box and
// the canvas could resolve to the picker's.
func TestUIKeyAutoModalHasOwnID(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `wrap.id = "key-auto-modal"`) {
t.Fatal("openKeyAutoModal must give its dialog its own id, not the shared " +
"#modal-wrap (another dialog may be open on top of it)")
}
close := readFuncBody(t, src, "keyAutoClose")
if strings.Contains(close, `"modal-wrap"`) {
t.Fatal("keyAutoClose resolves the shared #modal-wrap; it would close " +
"whichever dialog comes first in the document")
}
if !strings.Contains(close, `"key-auto-modal"`) {
t.Fatal("keyAutoClose must resolve the per-key dialog by its own id")
}
}
// Editing a chain inside the per-key dialog must NOT persist it. That dialog
// has Save and Cancel, so auto-writing on add/drag/delete meant the change was
// already committed before Save and Cancel could not undo it.
//
// The global page is the opposite: it has always applied edits immediately.
func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "afterChainEdit")
if !strings.Contains(body, "if (sortState.scope)") {
t.Fatal("afterChainEdit must branch on sortState.scope")
}
// The scoped branch must return before reaching persistAuto.
iScope := strings.Index(body, "if (sortState.scope)")
iReturn := strings.Index(body[iScope:], "return")
iPersist := strings.Index(body, "persistAuto()")
if iScope < 0 || iReturn < 0 || iPersist < 0 {
t.Fatal("afterChainEdit no longer has the expected shape")
}
if iScope+iReturn > iPersist {
t.Fatal("the scoped branch reaches persistAuto() — a per-key edit would " +
"be written before Save")
}
// And it must not call itself (a regex-driven rewrite did exactly that).
if strings.Contains(body, "afterChainEdit()") {
t.Fatal("afterChainEdit calls itself: infinite recursion")
}
// Every in-canvas edit goes through it, so none of them bypass the check.
for _, fn := range []string{"scrAddFromForm", "sortScopeSave", "scrDelSlot"} {
b := readFuncBody(t, src, fn)
if strings.Contains(b, "persistAuto()") {
t.Fatalf("%s calls persistAuto() directly, bypassing afterChainEdit", fn)
}
}
}

View File

@ -779,6 +779,34 @@ type DiskEntry struct {
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Hooks int `json:"hooks"`
// Pages lists the UI page ids the plugin contributes. The desktop panel
// shows it so an operator can tell at a glance that a plugin actually
// renders something, without opening its source.
Pages []string `json:"pages,omitempty"`
}
// pageNames returns the plugin's contributed UI page ids, de-duplicated.
// A plugin may declare `page` or `pages` or both, and the same id can appear
// twice; the loader's merge keeps the first, so this mirrors that order.
func pageNames(p *Plugin) []string {
if p == nil || p.UI == nil {
return nil
}
declared := make([]*UIPage, 0, len(p.UI.Pages)+1)
if p.UI.Page != nil {
declared = append(declared, p.UI.Page)
}
declared = append(declared, p.UI.Pages...)
var out []string
seen := map[string]bool{}
for _, pg := range declared {
if pg == nil || seen[pg.PageID] {
continue
}
seen[pg.PageID] = true
out = append(out, pg.PageID)
}
return out
}
// OnDisk lists every .lua file in the plugin directory with its load state.
@ -810,6 +838,10 @@ func (ps *Plugins) OnDisk() []DiskEntry {
de.Version = p.Info.Version
de.Description = p.Info.Description
de.Hooks = len(p.Hooks)
// A plugin may declare a single page or several; both forms end
// up in Pages (see the merge at load time), so reporting that one
// field covers either.
de.Pages = pageNames(p)
} else {
// On disk but not in the running set: either it failed so badly
// that LoadSource never produced a record, or the dir was written

View File

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