Files
cortiq-gateway/web/js/views/routing.js
T
infosave2007 2ede0ee452 feat(routing): per-task rules on top of complexity tiers; hosted router is the default
The router has always returned two signals — task label and complexity tier —
but only the tier picked models. [routing.tasks] adds `label → ordered models`:
a matching rule's models lead the candidate chain and the tier chain follows as
fallback, so pinning translations to a local model never weakens failover.
Requests whose chain was headed by a task rule report
route_source "router+task-rule".

The Routing page grows a per-task editor built from the account's LIVE
taxonomy (GET /v1/taxonomies via the new /admin/api/routing/labels proxy;
built-in label set when the router is unreachable), with localized task names,
each task's share of 7-day traffic, "by complexity (no rule)" as the explicit
empty state, and custom labels for other taxonomies. Deleting a model scrubs
it from task rules like it always did from tiers.

Also fixes the default config pointing at a placeholder localhost:8080 router
with a nonexistent taxonomy — a fresh install now targets the hosted allaigate
router (IP endpoint, verify_tls off, data-assistant, 12 s oracle timeout) and
degrades gracefully until a key is set. Verified end-to-end against the live
router: translation → task-rule model, math → tier path.

16 new strings in all 7 languages.
2026-08-07 17:39:38 +03:00

246 lines
8.6 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = {};
for (const l of taskLabels) tasks[l] = (routing.tasks || {})[l] || [];
const st = {
tiers,
tasks,
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"))
);
}
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)) 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;
}