The single per-task «Complexity» pin was the wrong shape — what you actually
decide is WHICH model serves a task at WHICH complexity. A task rule is now a
per-tier map, edited in the console as rows of “model ▾ + complexity ▾” pairs
(plus ×/add) inside each task:
[routing.tasks.code]
low = ["cheap-coder"]
high = ["best-coder"]
any = ["generic-coder"] # leads at every tier after the tier pick
Chain order: the task's tier-specific list → (at high: the global high chain,
so escalation still beats any-tier pins) → the task's any-tier list → the
global tier chain → default as the last resort; deduplicated. Plain-array
rules keep meaning "any tier" unchanged, and the short-lived {models, tier}
form from the previous commit is normalized into the map on load.
Verified live: legacy config migrated visibly in the editor, pairs survive
save+reload, a math prompt at low ignored the math@high pair, per-tier leads
confirmed by unit tests (19 passing).
301 lines
11 KiB
JavaScript
Vendored
301 lines
11 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 = {};
|
||
// A task rule is edited as ordered PAIRS (model, tier): "this model serves
|
||
// this task at this complexity" (tier "any" = at every complexity).
|
||
// GET returns {any:[], low:[], medium:[], high:[]}; legacy arrays = any.
|
||
const taskPairs = {};
|
||
for (const l of taskLabels) {
|
||
const v = (routing.tasks || {})[l];
|
||
const pairs = [];
|
||
if (Array.isArray(v)) for (const m of v) pairs.push({ model: m, tier: "any" });
|
||
else if (v && typeof v === "object") {
|
||
for (const tier of ["low", "medium", "high", "any"])
|
||
for (const m of v[tier] || []) pairs.push({ model: m, tier });
|
||
}
|
||
taskPairs[l] = pairs;
|
||
}
|
||
const st = {
|
||
tiers,
|
||
taskPairs,
|
||
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
|
||
),
|
||
pairsEditor(label)
|
||
);
|
||
}
|
||
|
||
// Pairs editor: each row is [model ▾][complexity ▾][×]; "any" serves every
|
||
// tier. Order matters — it is the fallback order inside the tier.
|
||
function pairsEditor(label) {
|
||
const pairs = st.taskPairs[label];
|
||
const wrap = h("div", { class: "pairs grow" });
|
||
function paint() {
|
||
mount(
|
||
wrap,
|
||
...pairs.map((p, i) => {
|
||
const modelSel = h(
|
||
"select",
|
||
{ class: "pair-model" },
|
||
...ids.map((id) => h("option", { value: id }, id))
|
||
);
|
||
modelSel.value = p.model;
|
||
modelSel.addEventListener("change", () => { p.model = modelSel.value; });
|
||
const tierSel = h(
|
||
"select",
|
||
{ class: "pair-tier", title: t("routing.tasks.tierHint") },
|
||
h("option", { value: "any" }, t("routing.tasks.tierAny")),
|
||
...Object.keys(st.tiers).map((tr) => h("option", { value: tr }, tr))
|
||
);
|
||
tierSel.value = p.tier;
|
||
tierSel.addEventListener("change", () => { p.tier = tierSel.value; });
|
||
const del = h("button", { class: "btn sm ghost danger", onClick: () => { pairs.splice(i, 1); paint(); } }, "×");
|
||
return h("div", { class: "pair-row" }, modelSel, tierSel, del);
|
||
}),
|
||
pairs.length === 0 ? h("div", { class: "muted small", style: "align-self:center" }, t("routing.tasks.inherit")) : null,
|
||
h("button", { class: "btn ghost sm", onClick: () => { pairs.push({ model: ids[0] || "", tier: "any" }); paint(); } }, "+ " + t("routing.tasks.addModel"))
|
||
);
|
||
}
|
||
paint();
|
||
return wrap;
|
||
}
|
||
|
||
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.taskPairs[v]) return;
|
||
st.taskPairs[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.taskPairs).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, pairs] of Object.entries(st.taskPairs)) {
|
||
const rule = {};
|
||
for (const p of pairs) {
|
||
if (!p.model) continue;
|
||
(rule[p.tier] = rule[p.tier] || []).push(p.model);
|
||
}
|
||
if (Object.keys(rule).length) tasks[k] = rule;
|
||
}
|
||
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;
|
||
}
|