feat: per-key spend accounting and per-task complexity pinning
API keys page: the table now answers "which department spends what" — per-key requests, tokens in/out and cost for a selectable window (24h/7d/30d) plus a cumulative all-time cost column, joined from stats by the key's account. The stats log stays bounded while the accounting survives: once stats.jsonl exceeds max_file_mb (default 16, 0 disables), records older than the retention window fold into cumulative per-account totals in a small sidecar file (tmp+rename on both) and leave the log. alltime = folded + in-file, a record is never counted twice, verified end-to-end on synthetic data (1.2 MB → 301 KB, totals identical before/after compaction and restarts). Routing: a task rule can now PIN the complexity tier — `[routing.tasks.legal] models=[...] tier="high"` (old plain-array rules parse unchanged). The pinned tier replaces the router's verdict before the escalation check and reports itself as route_source "router+task-rule@high". The Routing page grows a per-task «Complexity» selector (Auto/low/medium/ high); verified live: a code prompt with a pinned high went router+task-rule@high → the expensive model. 13 new strings in all 7 languages; docs/ROUTING(.ru) updated.
This commit is contained in:
Vendored
+3
@@ -592,6 +592,9 @@ body.modal-open { overflow: hidden; }
|
||||
|
||||
.ok { color: var(--good); }
|
||||
|
||||
.task-tier-wrap { display: flex; align-items: center; gap: 6px; margin-left: 10px; white-space: nowrap; }
|
||||
.task-tier { max-width: 130px; padding: 4px 8px; font-size: 12px; }
|
||||
|
||||
/* Benchmark view */
|
||||
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||
tbody tr:last-child { animation: bm-in .25s ease; }
|
||||
|
||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
||||
"bm.cutByCap": "Budget ging fürs Denken drauf — keine Antwort mehr",
|
||||
"shell.uiStale": "Das Gateway wurde aktualisiert — diese Seite zeigt eine ältere Oberfläche.",
|
||||
"shell.reload": "Neu laden",
|
||||
"keys.col.requests": "Anfragen",
|
||||
"keys.col.tokens": "Tokens ein/aus",
|
||||
"keys.col.costAll": "$ gesamt",
|
||||
"routing.tasks.tier": "Komplexität",
|
||||
"routing.tasks.tierAuto": "Auto (Router)",
|
||||
"routing.tasks.tierHint": "Komplexität für diese Aufgabe festlegen: das Router-Urteil wird ersetzt, high eskaliert weiterhin über die Modellliste.",
|
||||
};
|
||||
|
||||
Vendored
+6
@@ -614,4 +614,10 @@ export default {
|
||||
"bm.cutByCap": "budget spent on reasoning — answer never came",
|
||||
"shell.uiStale": "The gateway was updated — this page is running an older interface.",
|
||||
"shell.reload": "Reload",
|
||||
"keys.col.requests": "Requests",
|
||||
"keys.col.tokens": "Tokens in/out",
|
||||
"keys.col.costAll": "$ all-time",
|
||||
"routing.tasks.tier": "Complexity",
|
||||
"routing.tasks.tierAuto": "Auto (router)",
|
||||
"routing.tasks.tierHint": "Pin the complexity tier for this task: the router's verdict is replaced, high still escalates over the model list.",
|
||||
};
|
||||
|
||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
||||
"bm.cutByCap": "presupuesto gastado en razonar — sin respuesta final",
|
||||
"shell.uiStale": "La pasarela se actualizó — esta página usa una interfaz antigua.",
|
||||
"shell.reload": "Recargar",
|
||||
"keys.col.requests": "Peticiones",
|
||||
"keys.col.tokens": "Tokens ent/sal",
|
||||
"keys.col.costAll": "$ total",
|
||||
"routing.tasks.tier": "Complejidad",
|
||||
"routing.tasks.tierAuto": "Auto (router)",
|
||||
"routing.tasks.tierHint": "Fijar la complejidad de esta tarea: el veredicto del router se sustituye, high sigue escalando por encima de la lista de modelos.",
|
||||
};
|
||||
|
||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
||||
"bm.cutByCap": "budget épuisé en raisonnement — pas de réponse",
|
||||
"shell.uiStale": "La passerelle a été mise à jour — cette page utilise une ancienne interface.",
|
||||
"shell.reload": "Recharger",
|
||||
"keys.col.requests": "Requêtes",
|
||||
"keys.col.tokens": "Tokens in/out",
|
||||
"keys.col.costAll": "$ au total",
|
||||
"routing.tasks.tier": "Complexité",
|
||||
"routing.tasks.tierAuto": "Auto (routeur)",
|
||||
"routing.tasks.tierHint": "Fixer la complexité de cette tâche : le verdict du routeur est remplacé, high continue d'escalader au-dessus de la liste de modèles.",
|
||||
};
|
||||
|
||||
Vendored
+6
@@ -613,4 +613,10 @@ export default {
|
||||
"bm.cutByCap": "бюджет ушёл на размышления — ответ не успел",
|
||||
"shell.uiStale": "Шлюз обновился — на этой странице старый интерфейс.",
|
||||
"shell.reload": "Перезагрузить",
|
||||
"keys.col.requests": "Запросы",
|
||||
"keys.col.tokens": "Токены вх/исх",
|
||||
"keys.col.costAll": "$ за всё время",
|
||||
"routing.tasks.tier": "Сложность",
|
||||
"routing.tasks.tierAuto": "Авто (по роутеру)",
|
||||
"routing.tasks.tierHint": "Закрепить уровень сложности для задачи: вердикт роутера заменяется, high по-прежнему эскалирует поверх списка моделей.",
|
||||
};
|
||||
|
||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
||||
"bm.cutByCap": "bütçe düşünmeye gitti — yanıt gelemedi",
|
||||
"shell.uiStale": "Ağ geçidi güncellendi — bu sayfa eski arayüzü çalıştırıyor.",
|
||||
"shell.reload": "Yeniden yükle",
|
||||
"keys.col.requests": "İstekler",
|
||||
"keys.col.tokens": "Token giriş/çıkış",
|
||||
"keys.col.costAll": "$ toplam",
|
||||
"routing.tasks.tier": "Karmaşıklık",
|
||||
"routing.tasks.tierAuto": "Otomatik (yönlendirici)",
|
||||
"routing.tasks.tierHint": "Bu görev için karmaşıklık katmanını sabitle: yönlendiricinin kararı değiştirilir, high yine model listesinin üzerine tırmanır.",
|
||||
};
|
||||
|
||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
||||
"bm.cutByCap": "预算全用于思考——未能给出答案",
|
||||
"shell.uiStale": "网关已更新 — 此页面运行的是旧界面。",
|
||||
"shell.reload": "重新加载",
|
||||
"keys.col.requests": "请求数",
|
||||
"keys.col.tokens": "Token 入/出",
|
||||
"keys.col.costAll": "$ 累计",
|
||||
"routing.tasks.tier": "复杂度",
|
||||
"routing.tasks.tierAuto": "自动(路由器)",
|
||||
"routing.tasks.tierHint": "为该任务固定复杂度层级:替换路由器的判断,high 仍会越过模型列表进行升级。",
|
||||
};
|
||||
|
||||
Vendored
+28
-2
@@ -1,10 +1,11 @@
|
||||
// API keys — list, create (with copy-once), revoke.
|
||||
import { h, mount, modal, toast, confirmDialog } from "../ui.js";
|
||||
import { h, mount, modal, toast, confirmDialog, moneyEl, num } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
|
||||
export async function renderKeys() {
|
||||
const root = h("div");
|
||||
let usageRange = "7d";
|
||||
|
||||
function openCreate(reload) {
|
||||
const accountIn = h("input", { placeholder: "team-a" });
|
||||
@@ -208,6 +209,15 @@ console.log(resp.choices[0].message.content);`,
|
||||
async function reload() {
|
||||
const data = await api.listKeys();
|
||||
const keys = data.keys || [];
|
||||
// per-account usage: window breakdown + cumulative-forever totals — the
|
||||
// "who spends what" view for departments/developers sharing one gateway
|
||||
let usage = {};
|
||||
let alltime = {};
|
||||
try {
|
||||
const st = await api.stats(usageRange, "account");
|
||||
for (const b of st.breakdown || []) usage[b.key] = b;
|
||||
alltime = st.alltime_by_account || {};
|
||||
} catch { /* stats disabled — columns show dashes */ }
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
@@ -217,6 +227,9 @@ console.log(resp.choices[0].message.content);`,
|
||||
"div",
|
||||
{ class: "flex" },
|
||||
h("div", { class: "grow" }, h("h2", {}, t("keys.title")), h("p", {}, t("keys.subtitle"))),
|
||||
h("div", { class: "seg", style: "margin-right:10px" },
|
||||
...["24h", "7d", "30d"].map((r) =>
|
||||
h("button", { class: r === usageRange ? "active" : "", onClick: () => { usageRange = r; reload(); } }, r))),
|
||||
h("button", { class: "btn primary", onClick: () => openCreate(reload) }, "+ " + t("keys.add"))
|
||||
)
|
||||
),
|
||||
@@ -235,7 +248,16 @@ console.log(resp.choices[0].message.content);`,
|
||||
h(
|
||||
"thead",
|
||||
{},
|
||||
h("tr", {}, h("th", {}, t("keys.col.key")), h("th", {}, t("keys.col.account")), h("th", {}, t("keys.col.rate")), h("th", {}, t("keys.col.models")), h("th", { class: "right-align" }, t("common.actions")))
|
||||
h("tr", {},
|
||||
h("th", {}, t("keys.col.key")),
|
||||
h("th", {}, t("keys.col.account")),
|
||||
h("th", {}, t("keys.col.rate")),
|
||||
h("th", {}, t("keys.col.models")),
|
||||
h("th", { class: "right-align" }, t("keys.col.requests") + " (" + usageRange + ")"),
|
||||
h("th", { class: "right-align" }, t("keys.col.tokens") + " (" + usageRange + ")"),
|
||||
h("th", { class: "right-align" }, "$ (" + usageRange + ")"),
|
||||
h("th", { class: "right-align" }, t("keys.col.costAll")),
|
||||
h("th", { class: "right-align" }, t("common.actions")))
|
||||
),
|
||||
h(
|
||||
"tbody",
|
||||
@@ -248,6 +270,10 @@ console.log(resp.choices[0].message.content);`,
|
||||
h("td", {}, k.account),
|
||||
h("td", { class: "mono" }, k.rate_per_min || "—"),
|
||||
h("td", {}, (k.allow_models && k.allow_models.length) ? k.allow_models.join(", ") : h("span", { class: "muted" }, t("keys.allowAll"))),
|
||||
h("td", { class: "right-align mono" }, String((usage[k.account] || {}).requests ?? "—")),
|
||||
h("td", { class: "right-align mono small" }, usage[k.account] ? num(usage[k.account].prompt_tokens) + " / " + num(usage[k.account].completion_tokens) : "—"),
|
||||
h("td", { class: "right-align" }, usage[k.account] ? moneyEl(usage[k.account].cost_usd) : "—"),
|
||||
h("td", { class: "right-align" }, alltime[k.account] ? moneyEl(alltime[k.account].cost_usd) : "—"),
|
||||
h(
|
||||
"td",
|
||||
{ class: "right-align" },
|
||||
|
||||
Vendored
+33
-3
@@ -54,10 +54,17 @@ export async function renderRouting() {
|
||||
// local editable state — ensure standard tiers exist for a friendly editor
|
||||
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
|
||||
const tasks = {};
|
||||
for (const l of taskLabels) tasks[l] = (routing.tasks || {})[l] || [];
|
||||
const taskTiers = {};
|
||||
for (const l of taskLabels) {
|
||||
const v = (routing.tasks || {})[l];
|
||||
// GET returns the full form {models, tier}; older configs may be arrays
|
||||
tasks[l] = Array.isArray(v) ? v : (v && v.models) || [];
|
||||
taskTiers[l] = (v && !Array.isArray(v) && v.tier) || "";
|
||||
}
|
||||
const st = {
|
||||
tiers,
|
||||
tasks,
|
||||
taskTiers,
|
||||
default: routing.default || ids[0] || "",
|
||||
policy: routing.policy || { mode: "fixed_table", min_class: {} },
|
||||
};
|
||||
@@ -130,10 +137,29 @@ export async function renderRouting() {
|
||||
h("div", { class: "muted small mono" }, label),
|
||||
share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null
|
||||
),
|
||||
chipEditor(st.tasks, label, t("routing.tasks.inherit"))
|
||||
chipEditor(st.tasks, label, t("routing.tasks.inherit")),
|
||||
tierSelect(label)
|
||||
);
|
||||
}
|
||||
|
||||
// Per-task complexity pin: "авто" follows the router's verdict; a fixed
|
||||
// tier forces it for this task (legal is always high, chitchat always low)
|
||||
function tierSelect(label) {
|
||||
const sel = h(
|
||||
"select",
|
||||
{ class: "task-tier", title: t("routing.tasks.tierHint") },
|
||||
h("option", { value: "" }, t("routing.tasks.tierAuto")),
|
||||
...Object.keys(st.tiers).map((tr) =>
|
||||
h("option", { value: tr, selected: st.taskTiers[label] === tr ? "" : null }, tr))
|
||||
);
|
||||
sel.value = st.taskTiers[label] || "";
|
||||
sel.addEventListener("change", () => {
|
||||
st.taskTiers[label] = sel.value;
|
||||
});
|
||||
return h("div", { class: "task-tier-wrap" },
|
||||
h("div", { class: "muted small" }, t("routing.tasks.tier")), sel);
|
||||
}
|
||||
|
||||
function tasksCard() {
|
||||
const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" });
|
||||
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom"));
|
||||
@@ -201,7 +227,11 @@ export async function renderRouting() {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const tasks = {};
|
||||
for (const [k, v] of Object.entries(st.tasks)) if (v.length) tasks[k] = v;
|
||||
for (const [k, v] of Object.entries(st.tasks)) {
|
||||
const tier = (st.taskTiers || {})[k] || "";
|
||||
if (tier) tasks[k] = { models: v, tier };
|
||||
else if (v.length) tasks[k] = v;
|
||||
}
|
||||
await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy });
|
||||
toast(t("toast.saved"), "good");
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user