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.
This commit is contained in:
infosave2007
2026-08-07 17:39:38 +03:00
parent 15de36be3b
commit 2ede0ee452
18 changed files with 507 additions and 58 deletions
+117 -30
View File
@@ -4,71 +4,155 @@ 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(tier, i, dir) {
const arr = st.tiers[tier];
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(tier, i) {
st.tiers[tier].splice(i, 1);
function removeChip(map, key, i) {
map[key].splice(i, 1);
render();
}
function addChip(tier, id) {
if (id && !st.tiers[tier].includes(id)) st.tiers[tier].push(id);
function addChip(map, key, id) {
if (id && !map[key].includes(id)) map[key].push(id);
render();
}
function tierRow(tier) {
const arr = st.tiers[tier];
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(tier, e.target.value);
},
},
{ 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: "chips" },
...arr.map((id, i) =>
h(
"div",
{ class: "chip" },
h("span", { class: "ord" }, i + 1),
id,
h("span", { class: "arrow", title: "←", onClick: () => moveChip(tier, i, -1) }, "◄"),
h("span", { class: "arrow", title: "→", onClick: () => moveChip(tier, i, 1) }, "►"),
h("span", { class: "x", onClick: () => removeChip(tier, i) }, "×")
)
),
avail.length ? addSel : null
)
{ 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)
);
}
@@ -116,7 +200,9 @@ export async function renderRouting() {
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
try {
await api.putRouting({ tiers: st.tiers, default: st.default, policy: st.policy });
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");
@@ -143,6 +229,7 @@ export async function renderRouting() {
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" },