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:
infosave2007
2026-08-08 08:42:11 +03:00
parent ef28a6633d
commit 95e560dc72
18 changed files with 441 additions and 50 deletions
+33 -3
View File
@@ -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) {