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>
This commit is contained in:
JianFeeeee
2026-10-03 07:45:54 +08:00
parent ba01da937b
commit 22decf2bd3
8 changed files with 897 additions and 12 deletions

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: "管理员",
@ -1081,6 +1092,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: "Slot does not resolve (unknown model or source); AUTO requests would fail",
kName: "Name",
kRole: "Role",
kRoleAdmin: "Admin",
@ -4674,6 +4696,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 +5109,108 @@
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.
async function openKeyAutoModal(key, name) {
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
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:720px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} · ${esc(name || key)}</h2>
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
<div id="ka-rows"></div>
<p><button class="ghost" onclick="keyAutoAddRow()">${esc(t("kAutoChainAdd"))}</button>
<button class="ghost" onclick="keyAutoClear(this)">${esc(t("kAutoChainInherit"))}</button></p>
<p><button onclick="keyAutoSave(this,'${escAttr(key)}')">${esc(t("kAutoChainSave"))}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${esc(t("mCancel"))}</button></p>
</div>`;
document.body.appendChild(wrap);
try {
const j = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
const rows = $("#ka-rows");
if (!j.auto || !j.auto.length) {
rows.innerHTML = `<div class="muted" style="padding:8px 0">${esc(
t("kAutoChainEmpty")
)}</div>`;
} else {
j.auto.forEach((r) => keyAutoRow(r));
}
} catch (e) {
toast(String(e));
}
}
function keyAutoRow(r) {
const el = document.createElement("div");
el.className = "ka-row";
el.style.cssText =
"display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap";
const opts = (sel) =>
(allModels || [])
.map((p) => {
// loadModelPairs builds {key,label}; the /api/status fallback
// only guarantees an id, so derive a comb key from whatever is
// present rather than emitting an empty option value.
const val = p.key || (p.id ? p.id + (p.source ? "|" + p.source : "") : "");
const lab = p.label || p.id || val;
return `<option value="${escAttr(val)}"${
val === sel ? " selected" : ""
}>${esc(lab)}</option>`;
})
.join("");
el.innerHTML = `<select class="ka-model" style="flex:2 1 220px;min-width:0">${opts(
r ? scopeComb(r) : ""
)}</select>
<label class="muted" style="flex:0 0 auto">${esc(
t("kAutoChainTier")
)}<input class="ka-tier" type="number" min="1" value="${
(r && r.tier) || 1
}" style="width:64px;margin-left:6px"></label>
<button class="ghost small errc" onclick="this.closest('.ka-row').remove()">×</button>`;
$("#ka-rows").appendChild(el);
}
function keyAutoAddRow() {
keyAutoRow(null);
}
function keyAutoClear() {
$("#ka-rows").innerHTML = "";
}
async function keyAutoSave(btn, key) {
const rules = [];
document.querySelectorAll("#ka-rows .ka-row").forEach((row) => {
const comb = row.querySelector(".ka-model").value;
if (!comb) return;
const { model, source } = splitCombKey(comb);
const tier = parseInt(row.querySelector(".ka-tier").value, 10) || 1;
rules.push({ model, source, tier });
});
btn.disabled = true;
try {
// An empty rule set means "inherit global" server-side, so one PUT
// covers both clearing the override and saving a chain.
const j = await api("/api/keys/" + encodeURIComponent(key) + "/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auto: rules }),
});
if (j.inherits) {
toast(t("kAutoChainGlobal"));
} else if (!j.slots) {
// Persisted, but nothing resolves — say so instead of letting the
// user discover it as a 503 on their next AUTO request.
toast(t("kAutoChainBad"));
} else {
toast(t("kAutoChainSave") + " (" + j.slots + ")");
}
document.getElementById("modal-wrap").remove();
loadKeys();
} catch (e) {
toast(String(e));
btn.disabled = false;
}
}
async function createKey(btn) {
const name = $("#kc-name").value.trim();
if (!name) {