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.
276 lines
9.7 KiB
JavaScript
Vendored
276 lines
9.7 KiB
JavaScript
Vendored
// Routing — tier editor (ordered model chips), default model, and policy.
|
||
import { h, mount, toast } from "../ui.js";
|
||
import { t } from "../i18n.js";
|
||
import { api } from "../api.js";
|
||
import { appState } from "../app.js";
|
||
|
||
// Known labels of the hosted taxonomy — used for localized names/descriptions
|
||
// and as the offline fallback; the live list comes from /routing/labels.
|
||
const TASK_META = {
|
||
chitchat: { icon: "💬" },
|
||
code: { icon: "⌨" },
|
||
"creative-writing": { icon: "✍" },
|
||
extraction: { icon: "📑" },
|
||
math: { icon: "∑" },
|
||
qa: { icon: "❓" },
|
||
summarization: { icon: "📋" },
|
||
translation: { icon: "🌍" },
|
||
};
|
||
|
||
export async function renderRouting() {
|
||
const meta = appState.meta || (await api.meta());
|
||
const [routing, modelsData] = await Promise.all([api.getRouting(), api.listModels()]);
|
||
const ids = (modelsData.models || []).map((m) => m.id);
|
||
|
||
// Task labels: live from the router when reachable, built-in set otherwise;
|
||
// labels that already have a rule are always shown (custom taxonomies).
|
||
let taskLabels = Object.keys(TASK_META);
|
||
let labelsSource = "builtin";
|
||
try {
|
||
const r = await api.routingLabels();
|
||
if (r.labels && r.labels.length) {
|
||
taskLabels = r.labels;
|
||
labelsSource = "router";
|
||
}
|
||
} catch {
|
||
/* offline — builtin list */
|
||
}
|
||
for (const k of Object.keys(routing.tasks || {})) {
|
||
if (!taskLabels.includes(k)) taskLabels.push(k);
|
||
}
|
||
|
||
// Share of recent traffic per task — shows which rules actually matter.
|
||
let taskShare = {};
|
||
try {
|
||
const stat = await api.stats("7d", "label");
|
||
const total = (stat.breakdown || []).reduce((a, b) => a + (b.requests || 0), 0);
|
||
if (total > 0) {
|
||
for (const b of stat.breakdown || []) taskShare[b.key] = (b.requests || 0) / total;
|
||
}
|
||
} catch {
|
||
/* no stats yet */
|
||
}
|
||
|
||
// local editable state — ensure standard tiers exist for a friendly editor
|
||
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
|
||
const tasks = {};
|
||
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: {} },
|
||
};
|
||
|
||
const root = h("div");
|
||
|
||
function moveChip(map, key, i, dir) {
|
||
const arr = map[key];
|
||
const ni = i + dir;
|
||
if (ni < 0 || ni >= arr.length) return;
|
||
[arr[i], arr[ni]] = [arr[ni], arr[i]];
|
||
render();
|
||
}
|
||
function removeChip(map, key, i) {
|
||
map[key].splice(i, 1);
|
||
render();
|
||
}
|
||
function addChip(map, key, id) {
|
||
if (id && !map[key].includes(id)) map[key].push(id);
|
||
render();
|
||
}
|
||
function chipEditor(map, key, emptyLabel) {
|
||
const arr = map[key];
|
||
const avail = ids.filter((id) => !arr.includes(id));
|
||
const addSel = h(
|
||
"select",
|
||
{ style: "width:auto", onChange: (e) => addChip(map, key, e.target.value) },
|
||
h("option", { value: "" }, t("routing.addModel")),
|
||
...avail.map((id) => h("option", { value: id }, id))
|
||
);
|
||
return h(
|
||
"div",
|
||
{ class: "chips" },
|
||
arr.length === 0 && emptyLabel ? h("span", { class: "muted small chips-empty" }, emptyLabel) : null,
|
||
...arr.map((id, i) =>
|
||
h(
|
||
"div",
|
||
{ class: "chip" },
|
||
h("span", { class: "ord" }, i + 1),
|
||
id,
|
||
h("span", { class: "arrow", title: "←", onClick: () => moveChip(map, key, i, -1) }, "◄"),
|
||
h("span", { class: "arrow", title: "→", onClick: () => moveChip(map, key, i, 1) }, "►"),
|
||
h("span", { class: "x", onClick: () => removeChip(map, key, i) }, "×")
|
||
)
|
||
),
|
||
avail.length ? addSel : null
|
||
);
|
||
}
|
||
|
||
function tierRow(tier) {
|
||
return h(
|
||
"div",
|
||
{ class: "tierrow" },
|
||
h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, tier)),
|
||
chipEditor(st.tiers, tier)
|
||
);
|
||
}
|
||
|
||
function taskRow(label) {
|
||
const known = TASK_META[label];
|
||
const name = known ? t("task." + label) : label;
|
||
const share = taskShare[label];
|
||
return h(
|
||
"div",
|
||
{ class: "tierrow taskrow" },
|
||
h(
|
||
"div",
|
||
{ class: "tname taskname" },
|
||
h("div", {}, (known ? known.icon + " " : "") + name),
|
||
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")),
|
||
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"));
|
||
addBtn.addEventListener("click", () => {
|
||
const v = addIn.value.trim().toLowerCase();
|
||
if (!v || st.tasks[v]) return;
|
||
st.tasks[v] = [];
|
||
render();
|
||
});
|
||
return h(
|
||
"div",
|
||
{ class: "card" },
|
||
h("div", { class: "card-head" },
|
||
h("h3", {}, "🎯 " + t("routing.tasks.title")),
|
||
h("span", { class: "card-head sub" },
|
||
labelsSource === "router" ? t("routing.tasks.fromRouter") : t("routing.tasks.builtin"))),
|
||
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.tasks.hint")),
|
||
...Object.keys(st.tasks).map(taskRow),
|
||
h("div", { class: "flex", style: "gap:8px; margin-top:10px" }, addIn, addBtn)
|
||
);
|
||
}
|
||
|
||
function policyCard() {
|
||
const modeSel = h(
|
||
"select",
|
||
{ onChange: (e) => { st.policy.mode = e.target.value; render(); } },
|
||
h("option", { value: "fixed_table", selected: st.policy.mode === "fixed_table" }, t("routing.policy.fixed")),
|
||
h("option", { value: "cost_aware", selected: st.policy.mode === "cost_aware" }, t("routing.policy.costAware"))
|
||
);
|
||
const children = [
|
||
h("label", { class: "field" }, h("span", {}, t("routing.policy.mode")), modeSel),
|
||
];
|
||
if (st.policy.mode === "cost_aware") {
|
||
const maxIn = h("input", {
|
||
type: "number",
|
||
step: "0.01",
|
||
value: st.policy.max_cost_usd_per_request ?? "",
|
||
placeholder: "0.50",
|
||
onChange: (e) => { st.policy.max_cost_usd_per_request = e.target.value === "" ? null : parseFloat(e.target.value); },
|
||
});
|
||
children.push(h("label", { class: "field" }, h("span", {}, t("routing.policy.maxCost")), maxIn));
|
||
st.policy.min_class = st.policy.min_class || {};
|
||
const rows = Object.keys(st.tiers).map((tier) => {
|
||
const sel = h(
|
||
"select",
|
||
{ onChange: (e) => { st.policy.min_class[tier] = e.target.value; } },
|
||
h("option", { value: "" }, "—"),
|
||
...(meta.cost_tiers || []).map((c) => h("option", { value: c, selected: st.policy.min_class[tier] === c }, c))
|
||
);
|
||
return h("label", { class: "field" }, h("span", {}, t("routing.policy.minClass") + " · " + tier), sel);
|
||
});
|
||
children.push(h("div", { class: "row" }, ...rows));
|
||
}
|
||
return h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("routing.policy"))), ...children);
|
||
}
|
||
|
||
function render() {
|
||
const defSel = h(
|
||
"select",
|
||
{ onChange: (e) => { st.default = e.target.value; } },
|
||
...ids.map((id) => h("option", { value: id, selected: id === st.default }, id))
|
||
);
|
||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||
saveBtn.addEventListener("click", async () => {
|
||
saveBtn.disabled = true;
|
||
try {
|
||
const tasks = {};
|
||
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) {
|
||
toast(String(e.message || e), "bad");
|
||
}
|
||
saveBtn.disabled = false;
|
||
});
|
||
|
||
mount(
|
||
root,
|
||
h(
|
||
"div",
|
||
{ class: "page-head" },
|
||
h(
|
||
"div",
|
||
{ class: "flex" },
|
||
h("div", { class: "grow" }, h("h2", {}, t("routing.title")), h("p", {}, t("routing.subtitle"))),
|
||
saveBtn
|
||
)
|
||
),
|
||
ids.length === 0 ? h("div", { class: "callout" }, t("routing.empty")) : null,
|
||
h(
|
||
"div",
|
||
{ class: "card" },
|
||
h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))),
|
||
...Object.keys(st.tiers).map(tierRow)
|
||
),
|
||
tasksCard(),
|
||
h(
|
||
"div",
|
||
{ class: "card" },
|
||
h("div", { class: "card-head" }, h("h3", {}, t("routing.default"))),
|
||
h("label", { class: "field" }, h("span", {}, t("routing.defaultHint")), defSel)
|
||
),
|
||
policyCard()
|
||
);
|
||
}
|
||
|
||
render();
|
||
return root;
|
||
}
|