Compare commits

...

7 Commits
main ... v1.9.3

Author SHA1 Message Date
be30ba01b3 fix(gateway): AUTO 范围冲突的提示要放在 checkQuota,否则是死代码
上一版把消息加在请求路径的范围检查上,实测才发现它永远走不到:
AUTO 请求先经过 checkQuota(ctx, "AUTO")(在链被读取之前),范围不含 AUTO
时它当场返回旧文案「model \"AUTO\" is not allowed for this key」——那句会
把运维引去查配额,而配额并不是原因。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:22:17 +08:00
5 changed files with 292 additions and 54 deletions

View File

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

View File

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

View File

@ -256,7 +256,17 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
} }
return nil return nil
} }
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)} // A scope that simply does not list the model is a different problem from
// a quota being exhausted, and the message has to say which: telling an
// operator "not allowed for this key" when they configured a quota sends
// them to the wrong setting. AUTO is checked here before the chain is even
// consulted, so this is the message that surfaces for AUTO.
if isAuto(model) {
return &quotaRejection{msg: fmt.Sprintf(
"model %q is not in this key's model scope, so it cannot use AUTO; "+
"add %q to the key's models or remove the scope restriction", model, model)}
}
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
} }
// quotaWindowSuffix describes a quota's reset window for an error message, so // quotaWindowSuffix describes a quota's reset window for an error message, so
@ -452,6 +462,11 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// Note: a key whose scope excludes AUTO is rejected earlier, by
// checkQuota(ctx, "AUTO") above, which produces the same message. That
// ordering matters — checkQuota runs before the chain is consulted, so it
// is the check an AUTO request actually hits. Duplicating it here would be
// dead code.
cands, effective := g.resolveCands(r.Context(), &req) cands, effective := g.resolveCands(r.Context(), &req)
if len(cands) == 0 { if len(cands) == 0 {
writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model)) writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model))

View File

@ -517,7 +517,20 @@
#toast{left:12px;right:12px;bottom:12px;text-align:center} #toast{left:12px;right:12px;bottom:12px;text-align:center}
th,td{padding:8px 10px} th,td{padding:8px 10px}
} }
</style>
/* Shown when a key's model scope blocks AUTO: the chain editor can look
fine while every request 403s, so the conflict must be visible here. */
.warn-box {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(224, 108, 51, 0.45);
background: rgba(224, 108, 51, 0.1);
color: #b45309;
font-size: 12.5px;
line-height: 1.5;
}
</style>
</head> </head>
<body> <body>
<div id="bgfx" aria-hidden="true"> <div id="bgfx" aria-hidden="true">
@ -826,12 +839,16 @@
kAutoChainOwn: "独立链", kAutoChainOwn: "独立链",
kAutoChainGlobal: "跟随全局", kAutoChainGlobal: "跟随全局",
kAutoChainTitle: "独立 AUTO 链", kAutoChainTitle: "独立 AUTO 链",
kAutoChainHint: "为空则跟随全局 AUTO 链", kAutoChainHint: "默认复制全局链供你增删;清空并保存即恢复跟随全局",
kAutoChainInherit: "使用全局链", kAutoChainInherit: "使用全局链",
kAutoChainAdd: "+ 添加槽位", kAutoChainAdd: "+ 添加槽位",
kAutoChainSave: "保存", kAutoChainSave: "保存",
kAutoChainTier: "层级", kAutoChainTier: "层级",
kAutoChainEmpty: "该密钥使用全局 AUTO 链", kAutoChainEmpty: "该密钥使用全局 AUTO 链",
kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链",
kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链",
kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链",
kAutoChainScopeWarn: "警告:该密钥的模型范围不包含 AUTO,配了独立 AUTO 链也用不了。当前范围:",
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败", kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称", kName: "名称",
kRole: "角色", kRole: "角色",
@ -1097,12 +1114,16 @@
kAutoChainOwn: "own", kAutoChainOwn: "own",
kAutoChainGlobal: "global", kAutoChainGlobal: "global",
kAutoChainTitle: "Per-key AUTO chain", kAutoChainTitle: "Per-key AUTO chain",
kAutoChainHint: "Leave empty to inherit the global AUTO chain", kAutoChainHint: "Starts from a copy of the global chain; clear it and save to follow the global chain again",
kAutoChainInherit: "Use global chain", kAutoChainInherit: "Use global chain",
kAutoChainAdd: "+ Add slot", kAutoChainAdd: "+ Add slot",
kAutoChainSave: "Save", kAutoChainSave: "Save",
kAutoChainTier: "Tier", kAutoChainTier: "Tier",
kAutoChainEmpty: "This key uses the global AUTO chain", kAutoChainEmpty: "This key uses the global AUTO chain",
kAutoChainSeeded: "Copied the global AUTO chain as a starting point — saving gives this key its own chain",
kAutoChainNewOwn: "This key currently follows the global chain; saving switches it to the chain configured here",
kAutoChainWillInherit: "Saving makes this key follow the global AUTO chain again",
kAutoChainScopeWarn: "Warning: this key\u2019s model scope does not include AUTO, so a per-key AUTO chain will still be rejected. Current scope: ",
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail", kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name", kName: "Name",
kRole: "Role", kRole: "Role",
@ -2016,16 +2037,41 @@ const pnote = $("#period-note");
// like a bug unless the dates are on screen — so print them. // like a bug unless the dates are on screen — so print them.
const prange = $("#period-range"); const prange = $("#period-range");
if (prange && st.period && st.period !== "all" && st.from) { if (prange && st.period && st.period !== "all" && st.from) {
const d0 = new Date(st.from); // Bucket labels are hourly for a day ("2026-10-03T09") and daily
const d1 = st.buckets && st.buckets.length // for a week or month ("2026-10-01"). Appending "T00:00:00Z" to
? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z") // the hourly form builds "2026-10-03T09T00:00:00Z", which Date
: d0; // rejects — toISOString() then throws and the whole assignment is
const f = (d) => // lost, leaving the range line silently empty on the day view (the
d.toISOString().slice(0, 10); // one people look at most).
prange.textContent = const f = (d) => d.toISOString().slice(0, 10);
t("periodRange") + const label = st.buckets && st.buckets.length
" " + f(d0) + " → " + f(d1) + ? st.buckets[st.buckets.length - 1].bucket
(st.buckets ? " (" + st.buckets.length + "d)" : ""); : "";
let endMs = 0;
if (/^\d{4}-\d{2}-\d{2}$/.test(label)) {
endMs = new Date(label + "T00:00:00Z").getTime();
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(label)) {
// An hourly bucket covers its hour, so the day it belongs to is
// what the range should end on.
endMs = new Date(label + ":00:00Z").getTime();
}
const from = new Date(st.from);
const end = endMs ? new Date(endMs) : from;
// The bucket count is not a day count: the day view buckets by
// HOUR, so "(5d)" for five hours of today was simply wrong. Days
// covered comes from the window itself, which is what the reader
// is trying to establish.
let days = 1;
if (endMs) {
days = Math.max(
1,
Math.round((endMs - from.getTime()) / 86400000) + 1,
);
}
const tail = " (" + days + "d)";
prange.textContent = endMs
? t("periodRange") + " " + f(from) + " → " + f(end) + tail
: t("periodRange") + " " + f(from) + tail;
} else if (prange) { } else if (prange) {
prange.textContent = ""; prange.textContent = "";
} }
@ -4780,8 +4826,11 @@ function afterChainEdit() {
async function loadKeys() { async function loadKeys() {
const el = $("#k-list"); const el = $("#k-list");
if (!el) return; if (!el) return;
const j = await api("/api/keys"); const j = await api("/api/keys");
const ks = j.keys || []; const ks = j.keys || [];
// Cached so the per-key AUTO editor can read a key's model scope
// without a second request (keyAutoScopeWarning).
window._keyRows = ks;
el.innerHTML = ks.length el.innerHTML = ks.length
? ks.map((k) => keyCanvasHtml(k)).join("") ? ks.map((k) => keyCanvasHtml(k)).join("")
: `<div class="muted">${t("kEmpty")}</div>`; : `<div class="muted">${t("kEmpty")}</div>`;
@ -5237,6 +5286,7 @@ function afterChainEdit() {
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%"> wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2> <h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2>
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div> <div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
<div id="ka-scope-warn"></div>
<div id="ka-canvas-host"></div> <div id="ka-canvas-host"></div>
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button> <p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button> <button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
@ -5247,48 +5297,67 @@ function afterChainEdit() {
// Park the key on the modal so a click that bubbles up cannot hit the // Park the key on the modal so a click that bubbles up cannot hit the
// global editor behind it. // global editor behind it.
wrap.addEventListener("click", (ev) => ev.stopPropagation()); wrap.addEventListener("click", (ev) => ev.stopPropagation());
try { try {
const j = await api("/api/v1/sources"); const j = await api("/api/v1/sources");
const idx = buildSortIndex(j.sources || []); const idx = buildSortIndex(j.sources || []);
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto"); const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
keyAutoState.key = key; keyAutoState.key = key;
keyAutoState.inherits = !!cur.inherits; keyAutoState.inherits = !!cur.inherits;
// A key with no own chain shows an EMPTY canvas, never the discovery // A key with no chain of its own starts from a COPY OF THE GLOBAL
// fallback buildLanes uses for an unconfigured global chain: that // CHAIN, not from an empty canvas. The realistic task is "give this
// fallback fills every source's models (226 blocks on a real // user the global chain minus the two sources they must not hit",
// deployment), and showing them inside "this key's chain" dialog // and starting blank forces the admin to retype every tier from a
// says they already belong to this user. They do not. // list of 226 models. This is NOT the discovery fallback (which
sortState.scope = key; // dumps every model of every source): it is the same 10 rules the
sortState.kind = "chat"; // global chain actually runs.
sortState.lanes = let seededFromGlobal = false;
cur.auto && cur.auto.length ? buildLanes(idx, cur.auto, "chat") : []; let rules = cur.auto || [];
sortState.origin = JSON.stringify(sortState.lanes); if (!rules.length) {
await loadModelPairs(); try {
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" }); rules = ((await api("/api/auto")).rules) || [];
// The notice is the only thing telling the user what an empty seededFromGlobal = rules.length > 0;
// canvas means here. Without it the dialog is just a blank box and } catch (e) {
// "Use global chain" looks like a button that already did rules = [];
// something. It disappears as soon as the chain has slots of its }
// own, which keyAutoSave's reload then confirms. }
if (keyAutoState.inherits) keyAutoInheritNotice(); sortState.scope = key;
} catch (e) { sortState.kind = "chat";
toast(String(e)); sortState.lanes = buildLanes(idx, rules, "chat");
} sortState.origin = JSON.stringify(sortState.lanes);
await loadModelPairs();
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
keyAutoState.seeded = seededFromGlobal;
keyAutoInheritNotice();
keyAutoScopeWarning(key);
} catch (e) {
toast(String(e));
}
} }
const keyAutoState = { key: null, inherits: true }; const keyAutoState = { key: null, inherits: true, seeded: false };
// The notice describes what an EMPTY canvas means, so it must track the // The notice states what the canvas content MEANS relative to the server, which
// canvas rather than the state the dialog opened with: adding the first // is the one thing the dialog cannot show visually: a full canvas can be
// slot makes "this key uses the global chain" false, and leaving it up // either "this key's own chain (already saved)" or "a copy of the global
// reads as a contradiction right next to the slot the user just added. // chain that only becomes this key's chain on Save". Those two look
function keyAutoInheritNotice(showing) { // identical and behave very differently if the admin hits Cancel.
function keyAutoInheritNotice() {
const host = $("#ka-canvas-host"); const host = $("#ka-canvas-host");
if (!host) return; if (!host) return;
let n = host.querySelector(".ka-inherit-note"); let n = host.querySelector(".ka-inherit-note");
const hasSlots = (sortState.lanes || []).some( const hasSlots = (sortState.lanes || []).some(
(l) => (l.models || []).length, (l) => (l.models || []).length,
); );
const want = showing === undefined ? !hasSlots : showing && !hasSlots; let msg = "";
if (!want) { if (!hasSlots) {
// Empty canvas: saving restores inheritance.
msg = keyAutoState.inherits
? t("kAutoChainEmpty")
: t("kAutoChainWillInherit");
} else if (keyAutoState.inherits && keyAutoState.seeded) {
msg = t("kAutoChainSeeded");
} else if (keyAutoState.inherits) {
msg = t("kAutoChainNewOwn");
}
if (!msg) {
if (n) n.remove(); if (n) n.remove();
return; return;
} }
@ -5298,12 +5367,13 @@ function afterChainEdit() {
n.style.margin = "0 0 10px"; n.style.margin = "0 0 10px";
host.appendChild(n); host.appendChild(n);
} }
n.textContent = t("kAutoChainEmpty"); n.textContent = msg;
} }
// "Use the global chain" empties the lanes; saving then PUTs an empty // "Use the global chain" empties the lanes; saving then PUTs an empty
// list, which the server treats as clearing the override. // list, which the server treats as clearing the override.
function keyAutoInherit() { function keyAutoInherit() {
keyAutoState.inherits = true; keyAutoState.inherits = true;
keyAutoState.seeded = false;
sortState.lanes = []; sortState.lanes = [];
sortState.origin = "[]"; sortState.origin = "[]";
const host = $("#ka-canvas-host"); const host = $("#ka-canvas-host");
@ -5314,12 +5384,38 @@ function afterChainEdit() {
paintSort(); paintSort();
keyAutoInheritNotice(); keyAutoInheritNotice();
} }
// keyAutoScopeWarning tells the admin when the key's model scope blocks
// AUTO outright. Configuring a per-key AUTO chain for such a key looks
// like it works, yet every request 403s — the scope filter runs before
// the chain is consulted. We deliberately do NOT widen the scope
// automatically: that would silently grant access nobody asked for.
function keyAutoScopeWarning(key) {
const host = $("#ka-scope-warn");
if (!host) return;
const rec = (window._keyRows || []).find((k) => k.key === key);
const models = (rec && rec.models) || [];
if (!models.length) {
host.innerHTML = "";
return;
}
const hasAuto = models.some(
(m) => (m.model || "").toUpperCase() === "AUTO",
);
host.innerHTML = hasAuto
? ""
: '<div class="warn-box">' +
esc(t("kAutoChainScopeWarn")) +
" " +
esc(models.map((m) => m.model).join(", ")) +
"</div>";
}
function keyAutoClose() { function keyAutoClose() {
const m = document.getElementById("key-auto-modal"); const m = document.getElementById("key-auto-modal");
if (m) m.remove(); if (m) m.remove();
// Leaving scope set would make the global editor save to this key. // Leaving scope set would make the global editor save to this key.
sortState.scope = null; sortState.scope = null;
keyAutoState.key = null; keyAutoState.key = null;
keyAutoState.seeded = false;
} }
async function keyAutoSave(btn) { async function keyAutoSave(btn) {
const key = keyAutoState.key; const key = keyAutoState.key;

View File

@ -1,6 +1,7 @@
package gateway package gateway
import ( import (
"os"
"strings" "strings"
"testing" "testing"
) )
@ -266,3 +267,129 @@ func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
} }
} }
} }
// The stats range line formats its end date from the LAST bucket label.
// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
// throws, and the assignment is lost. The symptom is an empty range line on
// exactly the view people look at most, with no error anywhere.
func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "paintStats")
// It must branch on the label shape instead of concatenating blindly.
if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
t.Fatal("paintStats must recognise the hourly bucket label shape " +
"(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
}
// And it must not contain the naive form that broke it.
if strings.Contains(el, `bucket + "T00:00:00Z"`) {
t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
"this throws on the day view and leaves the range line empty")
}
}
// A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not
// on an empty canvas. The realistic admin task is "this user gets the global chain
// minus the sources they must not hit"; starting blank forces retyping every
// tier against a 226-model picker. This is deliberately NOT the discovery
// fallback (which dumps every model of every source) — it is the same rules
// the global chain actually runs.
func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `api("/api/auto")`) {
t.Fatal("openKeyAutoModal must read the global chain to seed the editor")
}
if !strings.Contains(body, "seededFromGlobal") {
t.Fatal("the editor must remember whether its content came from the " +
"global chain, so the notice can say so")
}
// And it must not fall back to the discovery path that fills 226 models.
if strings.Contains(body, `buildLanes(idx, [], `) {
t.Fatal("seeding from an empty rule list triggers buildLanes discovery")
}
}
// The notice must distinguish "already this key's chain" from "a copy that
// only becomes its chain on Save" — those two look identical on the canvas and
// behave very differently if the admin hits Cancel.
func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoInheritNotice")
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if !strings.Contains(body, k) {
t.Fatalf("the notice never uses %s — a copied chain and a saved one "+
"would look the same", k)
}
}
// Both languages, or a non-Chinese admin sees raw keys.
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if n := strings.Count(src, k+`: "`); n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// A key whose model scope excludes AUTO cannot use AUTO at all — the scope
// filter runs before the chain — yet the editor happily lets an admin
// configure a per-key chain. That combination is easy to create by accident
// and produces a 403 with no visible cause, so the dialog must say so.
//
// We deliberately do NOT widen the scope automatically: silently granting a
// model the operator did not ask for is worse than a loud warning.
func TestUIKeyAutoWarnsOnScopeConflict(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function keyAutoScopeWarning(") {
t.Fatal("no scope-conflict check in the per-key editor")
}
body := readFuncBody(t, src, "keyAutoScopeWarning")
if !strings.Contains(body, "AUTO") {
t.Fatal("keyAutoScopeWarning must test whether the scope lists AUTO")
}
// It must not write anything back to the key: reporting only.
for _, forbidden := range []string{"PUT", "POST", "fetch(", "api("} {
if strings.Contains(body, forbidden) {
t.Fatalf("keyAutoScopeWarning calls %s — it must only report the "+
"conflict, never widen the scope itself", forbidden)
}
}
// Empty scope means unrestricted, so no warning is correct there.
if !strings.Contains(body, "if (!models.length)") {
t.Fatal("an unrestricted key (no models) must not be warned")
}
// Both languages.
if n := strings.Count(src, "kAutoChainScopeWarn"); n < 2 {
t.Fatalf("i18n key kAutoChainScopeWarn appears %d time(s), want zh+en", n)
}
}
// The scope conflict for AUTO must be reported by checkQuota, because that is
// the check an AUTO request actually reaches first (it runs before the chain is
// consulted). Putting the message on the later path made it dead code — the
// request was rejected one check earlier with the generic "not allowed for this
// key" wording, which sends an operator looking for a quota that isn't the
// problem.
func TestAutoScopeConflictMessageComesFromCheckQuota(t *testing.T) {
b, err := os.ReadFile("chat.go")
if err != nil {
t.Fatalf("read chat.go: %v", err)
}
src := string(b)
i := strings.Index(src, "func (g *Gateway) checkQuota(")
if i < 0 {
t.Fatal("checkQuota not found")
}
// Body = up to the next top-level func.
rest := src[i:]
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
rest = rest[:j+1]
}
if !strings.Contains(rest, "isAuto(model)") {
t.Fatal("checkQuota must special-case AUTO: a scope that omits AUTO is " +
"rejected there before anything else looks at the chain")
}
if !strings.Contains(rest, "not in this key's model scope") {
t.Fatal("checkQuota's AUTO branch must name the scope as the cause")
}
}