Compare commits

...

3 Commits

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
5 changed files with 130 additions and 6 deletions

View File

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

View File

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

View File

@ -256,7 +256,17 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
}
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
@ -452,6 +462,11 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
}
// Note: a key whose scope excludes AUTO is rejected earlier, by
// checkQuota(ctx, "AUTO") above, which produces the same message. That
// ordering matters — checkQuota runs before the chain is consulted, so it
// is the check an AUTO request actually hits. Duplicating it here would be
// dead code.
cands, effective := g.resolveCands(r.Context(), &req)
if len(cands) == 0 {
writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model))

View File

@ -517,7 +517,20 @@
#toast{left:12px;right:12px;bottom:12px;text-align:center}
th,td{padding:8px 10px}
}
</style>
/* Shown when a key's model scope blocks AUTO: the chain editor can look
fine while every request 403s, so the conflict must be visible here. */
.warn-box {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(224, 108, 51, 0.45);
background: rgba(224, 108, 51, 0.1);
color: #b45309;
font-size: 12.5px;
line-height: 1.5;
}
</style>
</head>
<body>
<div id="bgfx" aria-hidden="true">
@ -835,6 +848,7 @@
kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链",
kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链",
kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链",
kAutoChainScopeWarn: "警告:该密钥的模型范围不包含 AUTO,配了独立 AUTO 链也用不了。当前范围:",
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称",
kRole: "角色",
@ -1109,6 +1123,7 @@
kAutoChainSeeded: "Copied the global AUTO chain as a starting point — saving gives this key its own chain",
kAutoChainNewOwn: "This key currently follows the global chain; saving switches it to the chain configured here",
kAutoChainWillInherit: "Saving makes this key follow the global AUTO chain again",
kAutoChainScopeWarn: "Warning: this key\u2019s model scope does not include AUTO, so a per-key AUTO chain will still be rejected. Current scope: ",
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name",
kRole: "Role",
@ -4811,8 +4826,11 @@ function afterChainEdit() {
async function loadKeys() {
const el = $("#k-list");
if (!el) return;
const j = await api("/api/keys");
const j = await api("/api/keys");
const ks = j.keys || [];
// Cached so the per-key AUTO editor can read a key's model scope
// without a second request (keyAutoScopeWarning).
window._keyRows = ks;
el.innerHTML = ks.length
? ks.map((k) => keyCanvasHtml(k)).join("")
: `<div class="muted">${t("kEmpty")}</div>`;
@ -5268,6 +5286,7 @@ function afterChainEdit() {
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2>
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
<div id="ka-scope-warn"></div>
<div id="ka-canvas-host"></div>
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
@ -5309,6 +5328,7 @@ try {
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
keyAutoState.seeded = seededFromGlobal;
keyAutoInheritNotice();
keyAutoScopeWarning(key);
} catch (e) {
toast(String(e));
}
@ -5364,6 +5384,31 @@ try {
paintSort();
keyAutoInheritNotice();
}
// keyAutoScopeWarning tells the admin when the key's model scope blocks
// AUTO outright. Configuring a per-key AUTO chain for such a key looks
// like it works, yet every request 403s — the scope filter runs before
// the chain is consulted. We deliberately do NOT widen the scope
// automatically: that would silently grant access nobody asked for.
function keyAutoScopeWarning(key) {
const host = $("#ka-scope-warn");
if (!host) return;
const rec = (window._keyRows || []).find((k) => k.key === key);
const models = (rec && rec.models) || [];
if (!models.length) {
host.innerHTML = "";
return;
}
const hasAuto = models.some(
(m) => (m.model || "").toUpperCase() === "AUTO",
);
host.innerHTML = hasAuto
? ""
: '<div class="warn-box">' +
esc(t("kAutoChainScopeWarn")) +
" " +
esc(models.map((m) => m.model).join(", ")) +
"</div>";
}
function keyAutoClose() {
const m = document.getElementById("key-auto-modal");
if (m) m.remove();

View File

@ -1,6 +1,7 @@
package gateway
import (
"os"
"strings"
"testing"
)
@ -329,3 +330,66 @@ func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
}
}
}
// A key whose model scope excludes AUTO cannot use AUTO at all — the scope
// filter runs before the chain — yet the editor happily lets an admin
// configure a per-key chain. That combination is easy to create by accident
// and produces a 403 with no visible cause, so the dialog must say so.
//
// We deliberately do NOT widen the scope automatically: silently granting a
// model the operator did not ask for is worse than a loud warning.
func TestUIKeyAutoWarnsOnScopeConflict(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function keyAutoScopeWarning(") {
t.Fatal("no scope-conflict check in the per-key editor")
}
body := readFuncBody(t, src, "keyAutoScopeWarning")
if !strings.Contains(body, "AUTO") {
t.Fatal("keyAutoScopeWarning must test whether the scope lists AUTO")
}
// It must not write anything back to the key: reporting only.
for _, forbidden := range []string{"PUT", "POST", "fetch(", "api("} {
if strings.Contains(body, forbidden) {
t.Fatalf("keyAutoScopeWarning calls %s — it must only report the "+
"conflict, never widen the scope itself", forbidden)
}
}
// Empty scope means unrestricted, so no warning is correct there.
if !strings.Contains(body, "if (!models.length)") {
t.Fatal("an unrestricted key (no models) must not be warned")
}
// Both languages.
if n := strings.Count(src, "kAutoChainScopeWarn"); n < 2 {
t.Fatalf("i18n key kAutoChainScopeWarn appears %d time(s), want zh+en", n)
}
}
// The scope conflict for AUTO must be reported by checkQuota, because that is
// the check an AUTO request actually reaches first (it runs before the chain is
// consulted). Putting the message on the later path made it dead code — the
// request was rejected one check earlier with the generic "not allowed for this
// key" wording, which sends an operator looking for a quota that isn't the
// problem.
func TestAutoScopeConflictMessageComesFromCheckQuota(t *testing.T) {
b, err := os.ReadFile("chat.go")
if err != nil {
t.Fatalf("read chat.go: %v", err)
}
src := string(b)
i := strings.Index(src, "func (g *Gateway) checkQuota(")
if i < 0 {
t.Fatal("checkQuota not found")
}
// Body = up to the next top-level func.
rest := src[i:]
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
rest = rest[:j+1]
}
if !strings.Contains(rest, "isAuto(model)") {
t.Fatal("checkQuota must special-case AUTO: a scope that omits AUTO is " +
"rejected there before anything else looks at the chain")
}
if !strings.Contains(rest, "not in this key's model scope") {
t.Fatal("checkQuota's AUTO branch must name the scope as the cause")
}
}