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:
Vendored
+117
-30
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user