From 31090fec818f2a1b2dce6904b0832c409bf96993 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 11:22:17 +0800 Subject: [PATCH 1/8] =?UTF-8?q?chore(gui):=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=201.9.0=20=E2=86=92=201.9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/gui/package-lock.json | 4 ++-- cmd/gui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index b233b9f..c8629f5 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.9.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.9.0", + "version": "1.9.1", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 2f1d068..b0e8d31 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.9.0", + "version": "1.9.1", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From d547bed4572a8156ea8b3310b12751da22eace1a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 12:48:17 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(ui):=20=E7=BB=9F=E8=AE=A1=E5=8C=BA?= =?UTF-8?q?=E9=97=B4=E6=96=87=E6=A1=88=E5=9C=A8=E6=97=A5=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=81=92=E4=B8=BA=E7=A9=BA=E3=80=81=E5=A4=A9=E6=95=B0=E6=8C=89?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E6=A1=B6=E8=AF=AF=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/gateway/ui/index.html | 45 +++++++++++++++++++++------- internal/gateway/ui_key_auto_test.go | 21 +++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 9bd187d..611d9a5 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -2016,16 +2016,41 @@ 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) { - 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)" : ""); + // 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; } else if (prange) { prange.textContent = ""; } diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go index ba72a15..1caee4c 100644 --- a/internal/gateway/ui_key_auto_test.go +++ b/internal/gateway/ui_key_auto_test.go @@ -266,3 +266,24 @@ 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") + } +} From a9d1bdeafbd848d5c7697245f53e90778006b23f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 13:09:45 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(ui):=20per-key=20AUTO=20=E9=93=BE?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=A4=8D=E5=88=B6=E5=85=A8=E5=B1=80=E9=93=BE?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E7=BC=96=E8=BE=91=E8=B5=B7=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前给没有独立链的密钥打开编辑器是空白画布,管理员要从 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 --- internal/gateway/ui/index.html | 102 +++++++++++++++++---------- internal/gateway/ui_key_auto_test.go | 42 +++++++++++ 2 files changed, 106 insertions(+), 38 deletions(-) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 611d9a5..42328f1 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -826,12 +826,15 @@ kAutoChainOwn: "独立链", kAutoChainGlobal: "跟随全局", kAutoChainTitle: "独立 AUTO 链", - kAutoChainHint: "为空则跟随全局 AUTO 链", + kAutoChainHint: "默认复制全局链供你增删;清空并保存即恢复跟随全局", kAutoChainInherit: "使用全局链", kAutoChainAdd: "+ 添加槽位", kAutoChainSave: "保存", kAutoChainTier: "层级", kAutoChainEmpty: "该密钥使用全局 AUTO 链", + kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链", + kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链", + kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链", kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败", kName: "名称", kRole: "角色", @@ -1097,12 +1100,15 @@ kAutoChainOwn: "own", kAutoChainGlobal: "global", 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", 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", kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail", kName: "Name", kRole: "Role", @@ -5272,48 +5278,66 @@ function afterChainEdit() { // 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)); - } +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(); + } 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 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 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) { + 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) { if (n) n.remove(); return; } @@ -5323,12 +5347,13 @@ function afterChainEdit() { n.style.margin = "0 0 10px"; host.appendChild(n); } - n.textContent = t("kAutoChainEmpty"); + n.textContent = msg; } // "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"); @@ -5345,6 +5370,7 @@ function afterChainEdit() { // 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; diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go index 1caee4c..7ada43d 100644 --- a/internal/gateway/ui_key_auto_test.go +++ b/internal/gateway/ui_key_auto_test.go @@ -287,3 +287,45 @@ func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) { "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) + } + } +} From 5a3b5d357c2f3787d5d3fbf5bb14514e108a0bd4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 13:33:42 +0800 Subject: [PATCH 4/8] =?UTF-8?q?chore(gui):=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=201.9.1=20=E2=86=92=201.9.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.9.2 收两处 UI 修复: - 统计区间文案在日视图恒为空(bucket 是小时粒度,盲目拼 T00:00:00Z 使 toISOString 抛 RangeError,整句赋值丢失),且天数按小时桶数误报 - per-key AUTO 链默认复制全局链作为编辑起点,而不是空白画布 Co-Authored-By: ModelRouter --- cmd/gui/package-lock.json | 4 ++-- cmd/gui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index c8629f5..245c936 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.9.1", + "version": "1.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.9.1", + "version": "1.9.2", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", diff --git a/cmd/gui/package.json b/cmd/gui/package.json index b0e8d31..bbb37f6 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.9.1", + "version": "1.9.2", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From c5079747b7079d03423a66aa208e0be4617a5e75 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 21:16:56 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(gateway):=20AUTO=20=E8=A2=AB=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=A8=A1=E5=9E=8B=E8=8C=83=E5=9B=B4=E6=8B=A6=E4=B8=8B?= =?UTF-8?q?=E6=97=B6=E6=8A=A5=E9=94=99=E8=AF=AF=E5=AF=BC=20+=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E7=BB=99=E5=87=BA=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一个密钥的模型范围不含 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 --- internal/gateway/chat.go | 13 ++++++++ internal/gateway/ui/index.html | 49 ++++++++++++++++++++++++++-- internal/gateway/ui_key_auto_test.go | 33 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 2e3af12..5a70914 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -452,6 +452,19 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { return } } + // A key whose model scope does not include AUTO cannot use AUTO at all, + // even when it has its own AUTO chain configured. That combination is + // easy to set up by accident and produced a misleading error: the request + // fell through to the generic "model %q is not configured" below, which + // claims AUTO does not exist — it does, this key just may not use it. Name + // the actual reason so the admin can fix the scope. + if isAuto(model) { + if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) { + writeError(w, http.StatusForbidden, "model_not_allowed", + 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 + } + } 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)) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 42328f1..f35a998 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -517,7 +517,20 @@ #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; + } +