mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user