Compare commits

..

3 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
5 changed files with 54 additions and 292 deletions

View File

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

View File

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

View File

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

View File

@ -517,20 +517,7 @@
#toast{left:12px;right:12px;bottom:12px;text-align:center}
th,td{padding:8px 10px}
}
/* 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>
</style>
</head>
<body>
<div id="bgfx" aria-hidden="true">
@ -839,16 +826,12 @@
kAutoChainOwn: "独立链",
kAutoChainGlobal: "跟随全局",
kAutoChainTitle: "独立 AUTO 链",
kAutoChainHint: "默认复制全局链供你增删;清空并保存即恢复跟随全局",
kAutoChainHint: "为空则跟随全局 AUTO 链",
kAutoChainInherit: "使用全局链",
kAutoChainAdd: "+ 添加槽位",
kAutoChainSave: "保存",
kAutoChainTier: "层级",
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链",
kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链",
kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链",
kAutoChainScopeWarn: "警告:该密钥的模型范围不包含 AUTO,配了独立 AUTO 链也用不了。当前范围:",
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称",
kRole: "角色",
@ -1114,16 +1097,12 @@
kAutoChainOwn: "own",
kAutoChainGlobal: "global",
kAutoChainTitle: "Per-key AUTO chain",
kAutoChainHint: "Starts from a copy of the global chain; clear it and save to follow the global chain again",
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",
kAutoChainSeeded: "Copied the global AUTO chain as a starting point — saving gives this key its own chain",
kAutoChainNewOwn: "This key currently follows the global chain; saving switches it to the chain configured here",
kAutoChainWillInherit: "Saving makes this key follow the global AUTO chain again",
kAutoChainScopeWarn: "Warning: this key\u2019s model scope does not include AUTO, so a per-key AUTO chain will still be rejected. Current scope: ",
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name",
kRole: "Role",
@ -2037,41 +2016,16 @@ const pnote = $("#period-note");
// 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) {
// Bucket labels are hourly for a day ("2026-10-03T09") and daily
// for a week or month ("2026-10-01"). Appending "T00:00:00Z" to
// the hourly form builds "2026-10-03T09T00:00:00Z", which Date
// rejects — toISOString() then throws and the whole assignment is
// lost, leaving the range line silently empty on the day view (the
// one people look at most).
const f = (d) => d.toISOString().slice(0, 10);
const label = st.buckets && st.buckets.length
? st.buckets[st.buckets.length - 1].bucket
: "";
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;
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 = "";
}
@ -4826,11 +4780,8 @@ function afterChainEdit() {
async function loadKeys() {
const el = $("#k-list");
if (!el) return;
const j = await api("/api/keys");
const j = await api("/api/keys");
const ks = j.keys || [];
// Cached so the per-key AUTO editor can read a key's model scope
// without a second request (keyAutoScopeWarning).
window._keyRows = ks;
el.innerHTML = ks.length
? ks.map((k) => keyCanvasHtml(k)).join("")
: `<div class="muted">${t("kEmpty")}</div>`;
@ -5286,7 +5237,6 @@ const j = await api("/api/keys");
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2>
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
<div id="ka-scope-warn"></div>
<div id="ka-canvas-host"></div>
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
@ -5297,67 +5247,48 @@ const j = await api("/api/keys");
// 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 chain of its own starts from a COPY OF THE GLOBAL
// CHAIN, not from an empty canvas. The realistic task is "give this
// user the global chain minus the two sources they must not hit",
// and starting blank forces the admin to retype every tier from a
// list of 226 models. This is NOT the discovery fallback (which
// dumps every model of every source): it is the same 10 rules the
// global chain actually runs.
let seededFromGlobal = false;
let rules = cur.auto || [];
if (!rules.length) {
try {
rules = ((await api("/api/auto")).rules) || [];
seededFromGlobal = rules.length > 0;
} catch (e) {
rules = [];
}
}
sortState.scope = key;
sortState.kind = "chat";
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));
}
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, seeded: false };
// The notice states what the canvas content MEANS relative to the server, which
// is the one thing the dialog cannot show visually: a full canvas can be
// either "this key's own chain (already saved)" or "a copy of the global
// chain that only becomes this key's chain on Save". Those two look
// identical and behave very differently if the admin hits Cancel.
function keyAutoInheritNotice() {
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,
);
let msg = "";
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) {
const want = showing === undefined ? !hasSlots : showing && !hasSlots;
if (!want) {
if (n) n.remove();
return;
}
@ -5367,13 +5298,12 @@ try {
n.style.margin = "0 0 10px";
host.appendChild(n);
}
n.textContent = msg;
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;
keyAutoState.seeded = false;
sortState.lanes = [];
sortState.origin = "[]";
const host = $("#ka-canvas-host");
@ -5384,38 +5314,12 @@ try {
paintSort();
keyAutoInheritNotice();
}
// keyAutoScopeWarning tells the admin when the key's model scope blocks
// AUTO outright. Configuring a per-key AUTO chain for such a key looks
// like it works, yet every request 403s — the scope filter runs before
// the chain is consulted. We deliberately do NOT widen the scope
// automatically: that would silently grant access nobody asked for.
function keyAutoScopeWarning(key) {
const host = $("#ka-scope-warn");
if (!host) return;
const rec = (window._keyRows || []).find((k) => k.key === key);
const models = (rec && rec.models) || [];
if (!models.length) {
host.innerHTML = "";
return;
}
const hasAuto = models.some(
(m) => (m.model || "").toUpperCase() === "AUTO",
);
host.innerHTML = hasAuto
? ""
: '<div class="warn-box">' +
esc(t("kAutoChainScopeWarn")) +
" " +
esc(models.map((m) => m.model).join(", ")) +
"</div>";
}
function keyAutoClose() {
const m = document.getElementById("key-auto-modal");
if (m) m.remove();
// Leaving scope set would make the global editor save to this key.
sortState.scope = null;
keyAutoState.key = null;
keyAutoState.seeded = false;
}
async function keyAutoSave(btn) {
const key = keyAutoState.key;

View File

@ -1,7 +1,6 @@
package gateway
import (
"os"
"strings"
"testing"
)
@ -267,129 +266,3 @@ 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")
}
}