// Routing — tier editor (ordered model chips), default model, and policy. import { h, mount, toast } from "../ui.js"; import { t, trTier } 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, bands: routing.bands || null, // null = пороги роутера как есть 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 ); } // Linked boundary sliders: one track, two handles (low|medium boundaries), // three colored zones. Handles push each other, numbers update live — // nobody types score ranges by hand. function bandsCard() { const DEF = { low: 0.33, medium: 0.48 }; const enabled = !!st.bands; const b = st.bands || { ...DEF }; const MIN_GAP = 0.02; const track = h("div", { class: "bands-track" }); const zoneLow = h("div", { class: "bands-zone z-low" }); const zoneMed = h("div", { class: "bands-zone z-med" }); const zoneHigh = h("div", { class: "bands-zone z-high" }); const hLow = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" })); const hMed = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" })); const legend = h("div", { class: "bands-legend" }); track.append(zoneLow, zoneMed, zoneHigh, hLow, hMed); function paint() { const lo = b.low, me = b.medium; zoneLow.style.width = lo * 100 + "%"; zoneMed.style.left = lo * 100 + "%"; zoneMed.style.width = (me - lo) * 100 + "%"; zoneHigh.style.left = me * 100 + "%"; zoneHigh.style.width = (1 - me) * 100 + "%"; hLow.style.left = lo * 100 + "%"; hMed.style.left = me * 100 + "%"; hLow.querySelector(".bands-val").textContent = lo.toFixed(2); hMed.querySelector(".bands-val").textContent = me.toFixed(2); // подписи не наезжают, когда ручки рядом hMed.querySelector(".bands-val").classList.toggle("raised", me - lo < 0.09); mount(legend, h("span", { class: "badge tier-low" }, `${trTier("low")} ≤ ${lo.toFixed(2)}`), h("span", { class: "badge tier-medium" }, `${trTier("medium")} ≤ ${me.toFixed(2)}`), h("span", { class: "badge tier-high" }, `${trTier("high")} > ${me.toFixed(2)}`)); track.classList.toggle("disabled", !st.bands); } function drag(handle, which) { handle.addEventListener("pointerdown", (e) => { if (!st.bands) return; handle.setPointerCapture(e.pointerId); const rect = track.getBoundingClientRect(); const move = (ev) => { let v = (ev.clientX - rect.left) / rect.width; v = Math.min(0.98, Math.max(0.02, Math.round(v * 100) / 100)); if (which === "low") { b.low = v; // связанность: low толкает medium перед собой if (b.medium < b.low + MIN_GAP) b.medium = Math.min(0.99, b.low + MIN_GAP); } else { b.medium = v; // и medium толкает low назад if (b.low > b.medium - MIN_GAP) b.low = Math.max(0.01, b.medium - MIN_GAP); } st.bands = { low: b.low, medium: b.medium }; paint(); }; const up = () => { handle.removeEventListener("pointermove", move); handle.removeEventListener("pointerup", up); }; handle.addEventListener("pointermove", move); handle.addEventListener("pointerup", up); }); } drag(hLow, "low"); drag(hMed, "medium"); const toggle = h("input", { type: "checkbox", checked: enabled ? "" : null }); toggle.checked = enabled; toggle.addEventListener("change", () => { st.bands = toggle.checked ? { low: b.low, medium: b.medium } : null; paint(); }); paint(); return h( "div", { class: "bands-wrap" }, h("label", { class: "check", style: "margin-bottom:8px" }, toggle, t("routing.bands.enable")), h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.bands.hint")), track, legend ); } function tierRow(tier) { return h( "div", { class: "tierrow" }, h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, trTier(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 }, trTier(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, bands: st.bands, 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"))), bandsCard(), ...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; }