feat(routing): linked complexity-boundary sliders — your own low/medium/high

The tier boundaries lived on the router and were global for every customer
of a hosted router; per-gateway tuning meant hand-editing score ranges. The
Routing page now has one track with two draggable handles (the low|medium
and medium|high boundaries) over three colored zones: handles push each
other (low can't cross medium — it shoves it along, and vice versa), the
numbers ride on the handles and the legend updates live, so nobody types
ranges by hand. Unchecked = the router's own bands, untouched behavior.

Under the hood the gateway re-maps the router's CONTINUOUS complexity score
with the user's boundaries ([routing.bands] low/medium in gateway.toml,
validated 0 < low < medium < 1) before tier chains, escalation and stats —
verified live: with bands at 0.70/0.72 a score-0.27 request that used to be
medium routed low, and back after restoring the defaults.

2 new strings in all 7 languages.
This commit is contained in:
infosave2007
2026-08-08 09:48:36 +03:00
parent 179c414a0e
commit 788c87c565
12 changed files with 172 additions and 1 deletions
+4
View File
@@ -931,6 +931,7 @@ async fn get_routing(State(state): State<SharedState>) -> ApiResult {
.collect();
ok(json!({
"tiers": tiers_to_map(&live.cfg.routing),
"bands": live.cfg.routing.bands,
"tasks": tasks,
"default": live.cfg.routing.default,
"policy": live.cfg.routing.policy,
@@ -978,6 +979,8 @@ struct RoutingBody {
tiers: HashMap<String, Vec<String>>,
#[serde(default)]
tasks: HashMap<String, TierTargets>,
#[serde(default)]
bands: Option<crate::config::TierBands>,
default: String,
#[serde(default)]
policy: RoutingPolicy,
@@ -997,6 +1000,7 @@ async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingB
// empty rule (no models AND no forced tier) = "по сложности"
.filter(|(_, v)| !v.models().is_empty())
.collect(),
bands: body.bands,
default: body.default,
policy: body.policy,
};
+35
View File
@@ -404,6 +404,31 @@ pub struct RoutingCfg {
/// called "tasks" — serde extracts it before the flattened tier map.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tasks: HashMap<String, TierTargets>,
/// Local complexity bands: re-map the router's continuous score into
/// low/medium/high with YOUR OWN boundaries. The hosted router's bands
/// are global for all customers; these are per-gateway. `None` = keep
/// the router's tier verdict as delivered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bands: Option<TierBands>,
}
/// Score boundaries: `score ≤ low` → low, `≤ medium` → medium, else high.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct TierBands {
pub low: f32,
pub medium: f32,
}
impl TierBands {
pub fn tier_of(&self, score: f32) -> &'static str {
if score <= self.low {
"low"
} else if score <= self.medium {
"medium"
} else {
"high"
}
}
}
/// A routing tier is either a list of model_ids or the string `default`.
@@ -861,6 +886,7 @@ impl Default for Config {
default: String::new(),
policy: RoutingPolicy::default(),
tasks: HashMap::new(),
bands: None,
},
breaker: BreakerCfg::default(),
protocols: ProtocolsCfg {
@@ -988,6 +1014,15 @@ impl Config {
}
}
}
if let Some(b) = &self.routing.bands {
if !(b.low > 0.0 && b.low < b.medium && b.medium < 1.0) {
anyhow::bail!(
"config: routing.bands must satisfy 0 < low < medium < 1 (got {} / {})",
b.low,
b.medium
);
}
}
for (task, targets) in &self.routing.tasks {
for id in &targets.models() {
if !ids.contains(id.as_str()) {
+13
View File
@@ -231,6 +231,19 @@ impl Pipeline {
let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile);
match live.router.route(&text, prof).await {
Ok(Some(dec)) => {
let mut dec = dec;
// per-gateway complexity bands: re-map the
// router's continuous score with the user's
// own boundaries (sliders on the Routing page)
if let Some(b) = &live.cfg.routing.bands {
if matches!(
dec.complexity_tier.as_str(),
"low" | "medium" | "high"
) {
dec.complexity_tier =
b.tier_of(dec.complexity_score).to_string();
}
}
let src = dec.source.clone();
let (cands, task_rule) = live
.routing
+17
View File
@@ -597,6 +597,23 @@ body.modal-open { overflow: hidden; }
.pair-model { min-width: 220px; max-width: 340px; padding: 4px 8px; font-size: 12.5px; }
.pair-tier { max-width: 150px; padding: 4px 8px; font-size: 12.5px; }
/* Complexity bands slider */
.bands-wrap { margin: 4px 0 16px; }
.bands-track { position: relative; height: 14px; border-radius: 8px; background: var(--panel-2, #23252c); margin: 26px 8px 10px; }
.bands-track.disabled { opacity: .45; pointer-events: none; }
.bands-zone { position: absolute; top: 0; bottom: 0; }
.bands-zone.z-low { left: 0; background: color-mix(in srgb, var(--good) 35%, transparent); border-radius: 8px 0 0 8px; }
.bands-zone.z-med { background: color-mix(in srgb, var(--warn, #d9a514) 35%, transparent); }
.bands-zone.z-high { background: color-mix(in srgb, var(--bad, #e5484d) 35%, transparent); border-radius: 0 8px 8px 0; }
.bands-handle { position: absolute; top: -5px; width: 24px; height: 24px; margin-left: -12px; border-radius: 50%;
background: var(--fg, #fff); border: 3px solid var(--accent, #6c6cf5); cursor: grab; touch-action: none; z-index: 2; }
.bands-handle:active { cursor: grabbing; }
.bands-val { position: absolute; top: -24px; left: 50%; transform: translateX(-50%); font-size: 11.5px;
font-family: var(--mono, monospace); color: var(--fg); background: var(--panel, #1b1d23); padding: 1px 6px;
border-radius: 6px; border: 1px solid var(--border, #333); white-space: nowrap; }
.bands-val.raised { top: -46px; }
.bands-legend { display: flex; gap: 8px; margin: 6px 8px 0; flex-wrap: wrap; }
/* Benchmark view */
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
tbody tr:last-child { animation: bm-in .25s ease; }
+2
View File
@@ -629,4 +629,6 @@ export default {
"routing.tasks.tierHint": "Welches Modell diese Aufgabe bei welcher Komplexität bedient; \"jede\" führt auf allen Stufen. Reihenfolge = Fallback-Reihenfolge. High eskaliert weiterhin über \"jede\"-Einträge.",
"routing.tasks.tierAny": "jede Komplexität",
"routing.tasks.addModel": "Modell",
"routing.bands.enable": "Eigene Komplexitätsgrenzen",
"routing.bands.hint": "Ziehen Sie die Griffe: wo low endet und high beginnt — die Griffe schieben einander, die Zahlen aktualisieren sich live. Der kontinuierliche Router-Score wird mit IHREN Grenzen neu eingeteilt; ohne Häkchen gelten die Grenzen des Routers.",
};
+2
View File
@@ -622,4 +622,6 @@ export default {
"routing.tasks.tierHint": "Which model serves this task at which complexity; \"any\" leads at every tier. Order = fallback order. High verdicts still escalate over any-tier pins.",
"routing.tasks.tierAny": "any complexity",
"routing.tasks.addModel": "model",
"routing.bands.enable": "My complexity boundaries",
"routing.bands.hint": "Drag the handles to set where low ends and where high begins — the handles push each other, the numbers update live. The router's continuous score is re-mapped with YOUR boundaries; unchecked = the router's own bands.",
};
+2
View File
@@ -629,4 +629,6 @@ export default {
"routing.tasks.tierHint": "Qué modelo atiende esta tarea en qué complejidad; «cualquiera» lidera en todos los niveles. El orden = orden de respaldo. Un veredicto high sigue escalando sobre las entradas «cualquiera».",
"routing.tasks.tierAny": "cualquier complejidad",
"routing.tasks.addModel": "modelo",
"routing.bands.enable": "Mis límites de complejidad",
"routing.bands.hint": "Arrastra los tiradores: dónde termina low y dónde empieza high — se empujan entre sí y las cifras se actualizan en vivo. La puntuación continua del router se re-mapea con TUS límites; sin marcar = las bandas del propio router.",
};
+2
View File
@@ -629,4 +629,6 @@ export default {
"routing.tasks.tierHint": "Quel modèle sert cette tâche à quelle complexité ; « toute » mène à tous les niveaux. L'ordre = ordre de repli. Un verdict high escalade toujours au-dessus des entrées « toute ».",
"routing.tasks.tierAny": "toute complexité",
"routing.tasks.addModel": "modèle",
"routing.bands.enable": "Mes limites de complexité",
"routing.bands.hint": "Faites glisser les poignées : où finit low et où commence high — elles se poussent l'une l'autre, les chiffres se mettent à jour en direct. Le score continu du routeur est re-découpé avec VOS limites ; décoché = les bandes du routeur.",
};
+2
View File
@@ -621,4 +621,6 @@ export default {
"routing.tasks.tierHint": "Какая модель обслуживает задачу на какой сложности; «любая» ведёт на всех уровнях. Порядок = порядок фолбэка. Вердикт high по-прежнему эскалирует поверх «любых».",
"routing.tasks.tierAny": "любая сложность",
"routing.tasks.addModel": "модель",
"routing.bands.enable": "Мои границы сложности",
"routing.bands.hint": "Тяните ручки: где кончается low и где начинается high — ручки толкают друг друга, цифры обновляются на лету. Непрерывный score роутера пересчитывается ВАШИМИ границами; галочка снята — действуют пороги самого роутера.",
};
+2
View File
@@ -629,4 +629,6 @@ export default {
"routing.tasks.tierHint": "Bu görevi hangi karmaşıklıkta hangi model karşılar; \"her\" tüm katmanlarda önde gider. Sıra = yedekleme sırası. High kararı \"her\" girdilerinin üzerine yine tırmanır.",
"routing.tasks.tierAny": "her karmaşıklık",
"routing.tasks.addModel": "model",
"routing.bands.enable": "Kendi karmaşıklık sınırlarım",
"routing.bands.hint": "Tutamaçları sürükleyin: low nerede biter, high nerede başlar — tutamaçlar birbirini iter, sayılar anlık güncellenir. Yönlendiricinin sürekli puanı SİZİN sınırlarınızla yeniden bölümlenir; işaret kaldırılırsa yönlendiricinin kendi bantları geçerli.",
};
+2
View File
@@ -629,4 +629,6 @@ export default {
"routing.tasks.tierHint": "哪个模型在哪个复杂度上处理该任务;「任意」在所有层级领先。顺序即回退顺序。high 判定仍会越过「任意」条目升级。",
"routing.tasks.tierAny": "任意复杂度",
"routing.tasks.addModel": "模型",
"routing.bands.enable": "自定义复杂度边界",
"routing.bands.hint": "拖动手柄设定 low 到哪里结束、high 从哪里开始 — 手柄会相互推动,数字实时更新。路由器的连续得分将按你的边界重新划分;取消勾选则使用路由器自身的边界。",
};
+89 -1
View File
@@ -71,6 +71,7 @@ export async function renderRouting() {
const st = {
tiers,
taskPairs,
bands: routing.bands || null, // null = пороги роутера как есть
default: routing.default || ids[0] || "",
policy: routing.policy || { mode: "fixed_table", min_class: {} },
};
@@ -120,6 +121,92 @@ export async function renderRouting() {
);
}
// 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" }, `low ≤ ${lo.toFixed(2)}`),
h("span", { class: "badge tier-medium" }, `medium ≤ ${me.toFixed(2)}`),
h("span", { class: "badge tier-high" }, `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",
@@ -257,7 +344,7 @@ export async function renderRouting() {
}
if (Object.keys(rule).length) tasks[k] = rule;
}
await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy });
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");
@@ -282,6 +369,7 @@ export async function renderRouting() {
"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(),