refactor(quota): 配额改为按模型,删除整钥总配额

用户明确要求:配额应当是密钥对应的**每个模型的单独配额**,而非整体配额。

## 语义变更

删除 GWKey.TokenQuota / ReqQuota / Period / Hours(整钥总额)。
ModelScope 新增 ReqQuota —— 请求数配额下沉到每条模型范围。

现在:每条 models[] 各自带 token 配额 + 请求数配额 + 重置周期,
彼此独立。一个模型用满只影响该模型。

★ 为什么不保留整钥总额:它会让「把 A 模型的额度挪给 B」变成一次全局
重分配;按模型独立计费则每个模型各自可控,运维能直接看出哪个模型在吃预算。

## 连带改动

- checkQuota 合并 key 级与 scope 级判定;checkKeyQuotaRetry 整体删除
  (顺带修掉上轮遗留的双重判定:入口不再先判空再重算)
- core:CreateKeyWithQuota / UpdateKeyWithQuota / ApplyQuota 全部删除,
  改由 ValidateScopeQuotas 校验每条 scope 的配额
- admin key:scope 上的配额不强制(admin 的 scope 仍限制模型范围,
  但不强制配额)—— 否则管理员会把自己锁在门外
- /api/v1/keys 不再回显 key 级配额字段(scope 里已含)
- WebUI:删除整钥配额徽标 / 「配额」按钮 / 创建表单的配额组 /
  putScope 的整钥回传;模型砖块与范围编辑器新增「请求数配额」输入,
  徽标显示 `1.0K 77×·1h`(未设配额显示 ∞)

## 判据

- TestOneModelsQuotaDoesNotBlockAnother 是本次核心保证。
  ★ 它第一版是**假判据**:m2 从不消耗,key-wide 计数器与 m1 自己的计数器
  读数恰好相同,退回 key-wide 仍通过。变异测试抓到后改为「先用 m2 花掉
  远超 m1 配额的量,再验证 m1 仍可用」—— 这样两种设计才可区分。
- TestUncappedModelNeverBlocked / TestAdminKeyScopesAreNotEnforced 新增
- UI 契约判据重写:整钥配额界面必须彻底消失(13 个符号)、
  scope 编辑器必须往返 req_quota、putScope 只发 scope 列表
- 错误消息点名具体模型(TestKeyAPIRejectionNamesTheModel)
- 3/3 变异全被抓

实测(真实进程 + 浏览器):m2 配额 500000 连打 25 次全成功,
m1 配额 1000 立即 429「token quota exceeded for "m1" (4315/1000)」,
此后 m2/m3 仍 200。UI:整钥配额元素全为 0,砖块各显配额,
编辑器预填/保存正确,零 JS 异常。
This commit is contained in:
JianFeeeee
2026-09-27 19:02:13 +08:00
parent 5530912d32
commit c51066f0b6
11 changed files with 515 additions and 645 deletions

View File

@ -339,9 +339,6 @@
.key-canvas{border:1px solid var(--line);border-radius:16px;padding:14px;margin-bottom:14px;background:var(--card);
backdrop-filter:blur(var(--glass));box-shadow:var(--sh-sm)}
.kc-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
/* key-wide caps sit inline in the head: they belong to the key, not to
any one model brick, and must not be draggable with one. */
.kc-caps{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
.kc-blocks{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;background:var(--card2);
border:1px dashed var(--line);border-radius:12px;padding:14px;min-height:64px}
.kc-blocks.ovh{outline:2px dashed var(--primary);outline-offset:2px}
@ -821,15 +818,10 @@
kAnySrc: "任意源",
kQuotaB: "Token 配额",
kQuotaHintB: "0 / 留空 = 无限",
kKeyQuota: "密钥总配额",
kKeyQuotaHint:
"限制这把密钥在重置周期内的总用量(跳模型)。0 / 留空 = 无限。",
kKeyReqQuota: "请求数配额",
kKeyReqQuotaHint: "限制周期内的请求次数。0 / 留空 = 无限。",
kKeyQuotaAdmin:
"admin 密钥永不受配额限制(避免把管理员锁在门外)。",
kKeyQuotaEdit: "配额",
kKeyQuotaNone: "无限",
kReqQuota: "请求数配额",
kReqQuotaHint: "限制周期内的请求次数。0 / 留空 = 无限。",
kQuotaPerModelHint:
"配额按模型单独设置:创建后点「+ 添加模型」,逐个模型配 token 配额与周期。一个模型用满只影响该模型,同一密钥的其它模型照常。",
kPeriodB: "重置周期",
kPerNothing: "不限",
kPerHour: "每 小时",
@ -1055,16 +1047,10 @@
kAnySrc: "any source",
kQuotaB: "Token quota",
kQuotaHintB: "0 / empty = unlimited",
kKeyQuota: "Key-wide quota",
kKeyQuotaHint:
"Caps this key's total spend per reset window, across every model it may use. 0 / empty = unlimited.",
kKeyReqQuota: "Request quota",
kKeyReqQuotaHint:
"Caps requests per window. 0 / empty = unlimited.",
kKeyQuotaAdmin:
"Admin keys are never capped — a cap could lock the operator out.",
kKeyQuotaEdit: "Quota",
kKeyQuotaNone: "unlimited",
kReqQuota: "Request quota",
kReqQuotaHint: "Caps requests per window. 0 / empty = unlimited.",
kQuotaPerModelHint:
"Quotas are per model: after creating the key, use \"Add model\" to give each model its own token budget and reset period. One model running out affects only that model; the key's other models keep working.",
kPeriodB: "Reset period",
kPerNothing: "Never",
kPerHour: "Every hour",
@ -4266,11 +4252,10 @@
async function renderKeysUser(me) {
$("#tab-keys").innerHTML = `
<div class="card"><h2>${t("kMeTitle")}</h2>
<div class="tbl-wrap"><table><tr><th>${t("kName")}</th><th>${t("kMeRole")}</th><th>${t("kKey")}</th><th>${t("kKeyQuota")}</th><th>${t("kMeModels")}</th></tr>
<div class="tbl-wrap"><table><tr><th>${t("kName")}</th><th>${t("kMeRole")}</th><th>${t("kKey")}</th><th>${t("kMeModels")}</th></tr>
<tr><td><b>${esc(me.name || "—")}</b></td><td>${roleTag(me.role)}</td>
<td><span class="kr-key">${esc(me.key)}</span>
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t("kCopy")}</button></td>
<td>${keyCapBadges(me)}</td>
<td>${
me.models && me.models.length
? me.models
@ -4308,22 +4293,13 @@
}
function keyCanvasHtml(k) {
const scopes = k.models || [];
// Key-wide caps live on the canvas, not on a brick: they are a budget
// the whole key shares, so they must not be dragged around with one
// model. data-* carries them so a quota edit can round-trip them
// through the same PUT that saves the model scope.
const caps = `data-kquota="${k.token_quota || 0}" data-kreqquota="${k.req_quota || 0}"
data-kperiod="${escAttr(k.period || "")}" data-khours="${k.hours || 0}"`;
return `
<div class="key-canvas" data-key="${escAttr(k.key)}" ${caps}>
<div class="key-canvas" data-key="${escAttr(k.key)}">
<div class="kc-head">
<b>${esc(k.name || "—")}</b>
${roleTag(k.role)}
<span class="kr-key">${esc(maskKey(k.key))}</span>
<button class="ghost small" onclick="copyText('${escAttr(k.key)}')">${t("kCopy")}</button>
<span class="kc-caps" title="${escAttr(t("kKeyQuotaHint"))}">${keyCapBadges(k)}</span>
${k.role === "admin" ? "" : `<button class="ghost small" title="${escAttr(t("kKeyQuotaEdit"))}"
onclick="keyQuotaEdit('${escAttr(k.key)}')">${t("kKeyQuotaEdit")}</button>`}
<span class="grow"></span>
<span class="muted">${fmtCreated(k.created_at)}</span>
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">${t("kDel")}</button>
@ -4336,39 +4312,19 @@
</div>
</div>`;
}
// keyCapBadges renders the key-wide caps. A cap with no reset period is
// flagged as such, because "1M tokens, never resets" and "1M tokens per
// hour" are very different promises and the badge must not blur them.
function keyCapBadges(k) {
const out = [];
const suffix = periodText(k.period || "", k.hours || 0);
if (+k.token_quota > 0) {
out.push(
`<span class="mb-quota" title="${escAttr(t("kKeyQuotaHint"))}">${esc(fmtQuota(k.token_quota))}${esc(suffix)}</span>`,
);
}
if (+k.req_quota > 0) {
out.push(
`<span class="mb-quota" title="${escAttr(t("kKeyReqQuotaHint"))}">${esc(fmtQuota(k.req_quota))}×${esc(suffix)}</span>`,
);
}
if (!out.length) {
return `<span class="muted">${t("kKeyQuotaNone")}</span>`;
}
return out.join(" ");
}
function scopeHtml(key, m) {
const qt = fmtQuota(m.token_quota);
const comb = scopeComb(m);
const src = normSrc(m.source);
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(comb)}"
data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || "")}" data-hours="${m.hours || 0}"`;
data-quota="${m.token_quota || 0}" data-reqquota="${m.req_quota || 0}"
data-period="${escAttr(m.period || "")}" data-hours="${m.hours || 0}"`;
return `<span class="mb" draggable="true" ${attrs} title="${escAttr(t("kBrickH"))}"
onclick="scopeEdit('${escAttr(key)}','${escAttr(comb)}')"
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(comb)}')">
<span class="mb-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="10" height="10"><circle cx="12" cy="12" r="10"/></svg></span>
<span class="mb-name">${esc(m.model)}${src ? `<em class="mb-src">${esc(src)}</em>` : ""}</span>
<span class="mb-quota">${esc(quantBadge(m.token_quota, m.period, m.hours))}</span>
<span class="mb-quota">${esc(scopeQuotaBadge(m))}</span>
<span class="copy-b" title="${escAttr(t("kCopyB"))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(comb)}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>
<span class="mb-x" title="${escAttr(t("kDelB"))}" onclick="event.stopPropagation();scopeRm('${escAttr(key)}','${escAttr(comb)}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
</span>`;
@ -4400,6 +4356,16 @@
if (p === "nhour") return "·" + Math.max(1, h) + "h";
return "";
}
// scopeQuotaBadge shows a scope entry's budgets: token quota and, when
// set, the request count. They are per model — a spent budget blocks
// only that model, not the whole key.
function scopeQuotaBadge(m) {
const parts = [];
if (+m.token_quota > 0) parts.push(fmtQuota(m.token_quota));
if (+m.req_quota > 0) parts.push(fmtQuota(m.req_quota) + "\u00d7");
if (!parts.length) return "\u221e";
return parts.join(" ") + periodText(m.period, m.hours);
}
function quantBadge(quota, period, hours) {
quota = +quota || 0;
period = period || "";
@ -4414,123 +4380,23 @@
model: comb[0],
source: src || undefined,
token_quota: parseInt(b.dataset.quota) || 0,
req_quota: parseInt(b.dataset.reqquota) || 0,
period: b.dataset.period || "",
hours: parseInt(b.dataset.hours) || 0,
};
});
}
async function putScope(key, scopes) {
// The key-wide caps ride along with every scope write. The API reads
// them as pointers, so sending them back unchanged is a no-op, while
// omitting them would be indistinguishable from "clear the budget" to
// a future reader. Round-tripping them here means editing a model's
// scope can never silently drop a key's quota.
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
);
// Each scope entry carries its own quotas, so the whole budget travels
// with the models it applies to. There is no separate key-wide total
// that could drift out of sync with the model list.
const body = { models: scopes };
if (canvas) {
body.token_quota = parseInt(canvas.dataset.kquota) || 0;
body.req_quota = parseInt(canvas.dataset.kreqquota) || 0;
body.period = canvas.dataset.kperiod || "";
body.hours = parseInt(canvas.dataset.khours) || 0;
}
await api("/api/keys/" + encodeURIComponent(key), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
// keyQuotaEdit opens the key-wide budget form.
function keyQuotaEdit(key) {
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
);
if (!canvas) return;
const cur = {
token_quota: parseInt(canvas.dataset.kquota) || 0,
req_quota: parseInt(canvas.dataset.kreqquota) || 0,
period: canvas.dataset.kperiod || "",
hours: parseInt(canvas.dataset.khours) || 0,
};
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:400px;max-width:100%"><h2>${t("kKeyQuotaEdit")}</h2>
<label>${t("kKeyQuota")} <span class="muted">${t("kKeyQuotaHint")}</span></label>
<input id="kq-tokens" type="number" min="0" step="1"
placeholder="${escAttr(t("kKeyQuotaNone"))}" value="${cur.token_quota || ""}">
<label>${t("kKeyReqQuota")} <span class="muted">${t("kKeyReqQuotaHint")}</span></label>
<input id="kq-reqs" type="number" min="0" step="1"
placeholder="${escAttr(t("kKeyQuotaNone"))}" value="${cur.req_quota || ""}">
<label>${t("kPeriodB")}</label>
<select id="kq-period">
<option value="" ${!cur.period ? "selected" : ""}>${t("kPerNothing")}</option>
<option value="hour" ${cur.period === "hour" ? "selected" : ""}>${t("kPerHour")}</option>
<option value="week" ${cur.period === "week" ? "selected" : ""}>${t("kPerWeek")}</option>
<option value="month" ${cur.period === "month" ? "selected" : ""}>${t("kPerMonth")}</option>
<option value="nhour" ${cur.period === "nhour" ? "selected" : ""}>${t("kPerHours")}</option>
</select>
<div id="kq-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
<input id="kq-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
<p class="muted" style="font-size:12px">${t("kKeyQuotaAdmin")}</p>
<p><button onclick="keyQuotaSave('${escAttr(key)}', this)">${t("kSaveScope")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
const toggle = () => {
$("#kq-hours-box").style.display =
$("#kq-period").value === "nhour" ? "block" : "none";
};
$("#kq-period").addEventListener("change", toggle);
toggle();
$("#kq-tokens").focus();
}
async function keyQuotaSave(key, btn) {
// Resolve our own dialog from the button that was clicked, so closing
// it can never remove a different #modal-wrap that happens to come
// first in the document.
const wrap = btn ? btn.closest("#modal-wrap") : null;
let tokens = parseInt($("#kq-tokens").value);
if (isNaN(tokens) || tokens < 0) tokens = 0;
let reqs = parseInt($("#kq-reqs").value);
if (isNaN(reqs) || reqs < 0) reqs = 0;
let hours = parseInt($("#kq-hours").value);
if (isNaN(hours) || hours < 1) hours = 1;
const period = $("#kq-period").value;
// Catch the "nhour picked but hours never filled in" case locally: the
// API rejects it too, but a round trip for a form-level mistake is
// needless.
if (period === "nhour" && hours < 1) {
toast(t("kPerNHint"));
return;
}
if (btn) btn.disabled = true;
try {
await api("/api/keys/" + encodeURIComponent(key), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token_quota: tokens,
req_quota: reqs,
period,
hours,
}),
});
// Close THIS modal, not whichever #modal-wrap comes first in the
// document: another dialog (e.g. the seed-key notice) may already be
// open, and a bare $("#modal-wrap") would remove that one and leave
// this form stranded on screen.
if (wrap) wrap.remove();
else closeTopModal();
toast(t("kSaved"));
await loadKeys();
} catch (e) {
toast(e.message);
if (btn) btn.disabled = false;
}
}
async function scopePush(key) {
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
@ -4631,6 +4497,9 @@
<label>${t("kQuotaB")} <span class="muted">${t("kQuotaHintB")}</span></label>
<input id="sc-quota" type="number" min="0" step="1"
placeholder="${escAttr(t("kQuotaHintB"))}" value="${sc.token_quota ? sc.token_quota : ""}">
<label>${t("kReqQuota")} <span class="muted">${t("kReqQuotaHint")}</span></label>
<input id="sc-reqs" type="number" min="0" step="1"
placeholder="${escAttr(t("kReqQuotaHint"))}" value="${sc.req_quota || ""}">
<label>${t("kPeriodB")}</label>
<select id="sc-period">
<option value="" ${!sc.period ? "selected" : ""}>${t("kPerNothing")}</option>
@ -4664,6 +4533,8 @@
const parts = splitCombKey(comb);
let q = parseInt($("#sc-quota").value);
if (isNaN(q) || q < 0) q = 0;
let rq = parseInt($("#sc-reqs").value);
if (isNaN(rq) || rq < 0) rq = 0;
let hours = parseInt($("#sc-hours").value);
if (isNaN(hours) || hours < 1) hours = 1;
const period = $("#sc-period").value;
@ -4672,6 +4543,7 @@
model: parts.model,
source: parts.source || undefined,
token_quota: q,
req_quota: rq,
period,
hours,
};
@ -4832,41 +4704,13 @@
<option value="user">${t("kRoleUser")}</option>
<option value="admin">${t("kRoleAdmin")}</option>
</select>
<label>${t("kKeyQuota")} <span class="muted">${t("kKeyQuotaHint")}</span></label>
<input id="kc-tokens" type="number" min="0" step="1" placeholder="${escAttr(t("kKeyQuotaNone"))}">
<label>${t("kKeyReqQuota")} <span class="muted">${t("kKeyReqQuotaHint")}</span></label>
<input id="kc-reqs" type="number" min="0" step="1" placeholder="${escAttr(t("kKeyQuotaNone"))}">
<label>${t("kPeriodB")}</label>
<select id="kc-period">
<option value="" selected>${t("kPerNothing")}</option>
<option value="hour">${t("kPerHour")}</option>
<option value="week">${t("kPerWeek")}</option>
<option value="month">${t("kPerMonth")}</option>
<option value="nhour">${t("kPerHours")}</option>
</select>
<div id="kc-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
<input id="kc-hours" type="number" min="1" step="1" value="24"></div>
<p class="muted" style="font-size:12px">${t("kQuotaPerModelHint")}</p>
<label>${t("kNote")}</label>
<input id="kc-note">
<p class="muted" style="font-size:12px">${t("kKeyQuotaAdmin")}</p>
<p><button onclick="createKey(this)">${t("kCreateBtn")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
$("#kc-role").addEventListener("change", () => {
const admin = $("#kc-role").value === "admin";
// An admin key ignores its caps server-side; hiding the fields
// avoids the operator setting one and wondering why it never trips.
$("#kc-tokens").disabled = admin;
$("#kc-reqs").disabled = admin;
$("#kc-period").disabled = admin;
$("#kc-hours-box").style.display =
!admin && $("#kc-period").value === "nhour" ? "block" : "none";
});
$("#kc-period").addEventListener("change", () => {
$("#kc-hours-box").style.display =
$("#kc-period").value === "nhour" ? "block" : "none";
});
$("#kc-name").focus();
}
async function createKey(btn) {
@ -4875,17 +4719,6 @@
toast(t("kName"));
return;
}
const role = $("#kc-role").value;
// An admin key is never capped; send the fields anyway (the server
// ignores them) rather than special-casing the request shape.
const readNum = (sel) => {
const el = $(sel);
if (el.disabled) return 0;
const n = parseInt(el.value);
return isNaN(n) || n < 0 ? 0 : n;
};
let hours = parseInt($("#kc-hours").value);
if (isNaN(hours) || hours < 1) hours = 1;
if (btn) btn.disabled = true;
let j;
try {
@ -4894,12 +4727,8 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
role,
role: $("#kc-role").value,
note: $("#kc-note").value.trim(),
token_quota: readNum("#kc-tokens"),
req_quota: readNum("#kc-reqs"),
period: role === "admin" ? "" : $("#kc-period").value,
hours: role === "admin" ? 0 : hours,
}),
});
} catch (e) {