// 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; }