feat(routing)!: task rules become per-complexity model maps

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).
This commit is contained in:
infosave2007
2026-08-08 08:54:27 +03:00
parent 95e560dc72
commit 06813b3e97
15 changed files with 316 additions and 183 deletions
+56 -31
View File
@@ -54,17 +54,23 @@ export async function renderRouting() {
// local editable state — ensure standard tiers exist for a friendly editor
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
const tasks = {};
const taskTiers = {};
// 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];
// GET returns the full form {models, tier}; older configs may be arrays
tasks[l] = Array.isArray(v) ? v : (v && v.models) || [];
taskTiers[l] = (v && !Array.isArray(v) && v.tier) || "";
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,
tasks,
taskTiers,
taskPairs,
default: routing.default || ids[0] || "",
policy: routing.policy || { mode: "fixed_table", min_class: {} },
};
@@ -137,27 +143,43 @@ export async function renderRouting() {
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")),
tierSelect(label)
pairsEditor(label)
);
}
// Per-task complexity pin: "авто" follows the router's verdict; a fixed
// tier forces it for this task (legal is always high, chitchat always low)
function tierSelect(label) {
const sel = h(
"select",
{ class: "task-tier", title: t("routing.tasks.tierHint") },
h("option", { value: "" }, t("routing.tasks.tierAuto")),
...Object.keys(st.tiers).map((tr) =>
h("option", { value: tr, selected: st.taskTiers[label] === tr ? "" : null }, tr))
);
sel.value = st.taskTiers[label] || "";
sel.addEventListener("change", () => {
st.taskTiers[label] = sel.value;
});
return h("div", { class: "task-tier-wrap" },
h("div", { class: "muted small" }, t("routing.tasks.tier")), sel);
// 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() {
@@ -165,8 +187,8 @@ export async function renderRouting() {
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] = [];
if (!v || st.taskPairs[v]) return;
st.taskPairs[v] = [];
render();
});
return h(
@@ -177,7 +199,7 @@ export async function renderRouting() {
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),
...Object.keys(st.taskPairs).map(taskRow),
h("div", { class: "flex", style: "gap:8px; margin-top:10px" }, addIn, addBtn)
);
}
@@ -227,10 +249,13 @@ export async function renderRouting() {
saveBtn.disabled = true;
try {
const tasks = {};
for (const [k, v] of Object.entries(st.tasks)) {
const tier = (st.taskTiers || {})[k] || "";
if (tier) tasks[k] = { models: v, tier };
else if (v.length) tasks[k] = v;
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");