From 06813b3e979de370d7e29f058b5af126217c9c9f Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 8 Aug 2026 08:54:27 +0300 Subject: [PATCH] feat(routing)!: task rules become per-complexity model maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/ROUTING.md | 19 ++--- docs/ROUTING.ru.md | 19 ++--- src/admin/mod.rs | 33 +++++--- src/config.rs | 124 +++++++++++++++++++++++------- src/pipeline.rs | 19 ++--- src/routing.rs | 164 ++++++++++++++++++++++------------------ web/css/styles.css | 6 +- web/js/i18n/de.js | 4 +- web/js/i18n/en.js | 4 +- web/js/i18n/es.js | 4 +- web/js/i18n/fr.js | 4 +- web/js/i18n/ru.js | 4 +- web/js/i18n/tr.js | 4 +- web/js/i18n/zh.js | 4 +- web/js/views/routing.js | 87 +++++++++++++-------- 15 files changed, 316 insertions(+), 183 deletions(-) diff --git a/docs/ROUTING.md b/docs/ROUTING.md index b1940ff..3c38462 100644 --- a/docs/ROUTING.md +++ b/docs/ROUTING.md @@ -179,19 +179,20 @@ A matching rule's models go **first** in the candidate chain; the complexity chain follows as fallback, so a task pin never weakens failover. Tasks without a rule route by complexity alone. -**Pinning complexity per task.** A task rule also accepts a full form with a -forced tier — the router's verdict is replaced for that task before anything -else happens: +**Per-complexity models for a task.** A task rule also accepts a per-tier +map — "which model serves this task at each complexity": ```toml -[routing.tasks.legal] -models = ["strong-model"] -tier = "high" # legal review is always high, whatever the router scored +[routing.tasks.code] +low = ["cheap-coder"] # routine code stays cheap +high = ["best-coder"] # hard code goes to the strongest coder +any = ["generic-coder"] # leads at every tier after the tier-specific pick ``` -Responses whose tier was pinned report `route_source: "router+task-rule@high"`. -The admin console's Routing page exposes this as a per-task «Complexity» -selector (Auto / low / medium / high). +The tier-specific list leads its own tier; `any` (and the legacy plain-array +form) leads at every tier; the global tier chain and `default` always remain +as fallback, so a task rule never weakens failover. The admin console's +Routing page edits this as rows of “model + complexity” pairs per task. **Exception — high complexity escalates.** When the router rates a request `high` (and a `high` chain is configured), the tier chain leads and the task diff --git a/docs/ROUTING.ru.md b/docs/ROUTING.ru.md index 6a47250..e061565 100644 --- a/docs/ROUTING.ru.md +++ b/docs/ROUTING.ru.md @@ -177,19 +177,20 @@ code = ["claude-sonnet", "gpt"] # код — сильнейшему ко сложности следует за ними как резерв — правило по задаче не ослабляет failover. Задачи без правила маршрутизируются только по сложности. -**Закрепление сложности за задачей.** Правило задачи принимает и полную форму -с фиксированным уровнем — вердикт роутера для этой задачи заменяется до всех -остальных шагов: +**Модели по сложности внутри задачи.** Правило задачи принимает и карту по +уровням — «какая модель обслуживает задачу на какой сложности»: ```toml -[routing.tasks.legal] -models = ["strong-model"] -tier = "high" # юридические тексты — всегда high, что бы ни решил роутер +[routing.tasks.code] +low = ["cheap-coder"] # рутинный код — дёшево +high = ["best-coder"] # сложный код — сильнейшему кодеру +any = ["generic-coder"] # ведёт на всех уровнях после уровневого выбора ``` -Ответы с закреплённым уровнем сообщают `route_source: "router+task-rule@high"`. -На вкладке Роутинг это селектор «Сложность» у каждой задачи (Авто / low / -medium / high). +Список конкретного уровня ведёт на своём уровне; `any` (и старая форма-массив) +ведёт на всех; глобальная цепочка уровня и `default` всегда остаются резервом — +правило задачи не ослабляет failover. На вкладке Роутинг это редактируется +строками пар «модель + сложность» у каждой задачи. **Исключение — высокая сложность эскалирует.** Когда роутер оценил запрос как `high` (и цепочка `high` настроена), цепочка уровня идёт первой, а правило diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 0800e24..550593c 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -752,7 +752,13 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) { } for tt in routing.tiers.values_mut().chain(routing.tasks.values_mut()) { match tt { - crate::config::TierTargets::Rule { models, .. } => models.retain(|x| x != id), + crate::config::TierTargets::Map(m) => { + for v in m.values_mut() { + if let crate::config::TaskTierVal::List(l) = v { + l.retain(|x| x != id); + } + } + } crate::config::TierTargets::List(v) => v.retain(|x| x != id), crate::config::TierTargets::One(s) if s == id => { *tt = crate::config::TierTargets::List(Vec::new()); @@ -761,11 +767,7 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) { } } // an emptied task rule means "no rule", not "no candidates" - routing.tasks.retain(|_, tt| match tt { - crate::config::TierTargets::Rule { models, tier } => !models.is_empty() || tier.is_some(), - crate::config::TierTargets::List(v) => !v.is_empty(), - crate::config::TierTargets::One(s) => !s.is_empty(), - }); + routing.tasks.retain(|_, tt| !tt.models().is_empty()); } /// Whether any managed server or static model in `cfg` still uses `path` as its @@ -912,10 +914,19 @@ async fn get_routing(State(state): State) -> ApiResult { .tasks .iter() .map(|(k, v)| { - ( - k.clone(), - json!({ "models": v.models(), "tier": v.forced_tier() }), - ) + (k.clone(), { + let (any, per) = v.task_rule(); + let mut o = serde_json::Map::new(); + if !any.is_empty() { + o.insert("any".into(), json!(any)); + } + for (tier, models) in per { + if !models.is_empty() { + o.insert(tier, json!(models)); + } + } + serde_json::Value::Object(o) + }) }) .collect(); ok(json!({ @@ -984,7 +995,7 @@ async fn put_routing(State(state): State, Json(body): Json, - #[serde(default, skip_serializing_if = "Option::is_none")] - tier: Option, - }, + Map(HashMap), List(Vec), One(String), } +/// A value inside a task's per-tier map: a model list, a single model, or the +/// transitional string value of the legacy `tier = "high"` key. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TaskTierVal { + List(Vec), + Str(String), +} + +impl TaskTierVal { + fn list(&self) -> Vec { + match self { + TaskTierVal::List(v) => v.clone(), + TaskTierVal::Str(s) => vec![s.clone()], + } + } +} + +pub const TASK_TIER_KEYS: [&str; 4] = ["any", "low", "medium", "high"]; + impl TierTargets { - /// The ordered model list, whatever the syntactic form. + /// Every referenced model id, whatever the syntactic form (validation). pub fn models(&self) -> Vec { match self { - TierTargets::Rule { models, .. } => models.clone(), + TierTargets::Map(m) => { + let mut out = Vec::new(); + for (k, v) in m { + if k == "tier" { + continue; // legacy marker, not a model list + } + out.extend(v.list()); + } + out + } TierTargets::List(v) => v.clone(), TierTargets::One(s) => vec![s.clone()], } } - /// Forced complexity tier (task rules only). - pub fn forced_tier(&self) -> Option<&str> { + + /// Normalized task rule: `(any_tier_models, per_tier_models)`. + /// Legacy `{models, tier}` becomes `per[tier] = models`; a plain array + /// becomes `any`. + pub fn task_rule(&self) -> (Vec, HashMap>) { match self { - TierTargets::Rule { tier, .. } => tier.as_deref(), - _ => None, + TierTargets::Map(m) => { + let mut any: Vec = Vec::new(); + let mut per: HashMap> = HashMap::new(); + let legacy_tier = m.get("tier").and_then(|v| match v { + TaskTierVal::Str(s) => Some(s.clone()), + _ => None, + }); + for (k, v) in m { + match k.as_str() { + "tier" => {} + "any" | "models" => any.extend(v.list()), + "low" | "medium" | "high" => { + per.entry(k.clone()).or_default().extend(v.list()) + } + _ => {} + } + } + if let Some(tier) = legacy_tier { + // legacy pin: the models were meant for that tier + let moved = std::mem::take(&mut any); + per.entry(tier).or_default().extend(moved); + } + (any, per) + } + TierTargets::List(v) => (v.clone(), HashMap::new()), + TierTargets::One(s) => (vec![s.clone()], HashMap::new()), + } + } + + /// Unknown keys inside a task map (validation). + pub fn invalid_task_keys(&self) -> Vec { + match self { + TierTargets::Map(m) => m + .keys() + .filter(|k| !TASK_TIER_KEYS.contains(&k.as_str()) && *k != "models" && *k != "tier") + .cloned() + .collect(), + _ => Vec::new(), } } } @@ -902,7 +974,7 @@ impl Config { } for (tier, targets) in &self.routing.tiers { let list = match targets { - TierTargets::Rule { models, .. } => models.clone(), + TierTargets::Map(_) => targets.models(), TierTargets::List(v) => v.clone(), TierTargets::One(s) => vec![s.clone()], }; @@ -926,14 +998,12 @@ impl Config { ); } } - if let Some(tier) = targets.forced_tier() { - if !self.routing.tiers.contains_key(tier) { - anyhow::bail!( - "config: routing.tasks.{}.tier '{}' is not a defined routing tier", - task, - tier - ); - } + if let Some(bad) = targets.invalid_task_keys().first() { + anyhow::bail!( + "config: routing.tasks.{}.{} is not a tier (use any/low/medium/high)", + task, + bad + ); } } Ok(()) @@ -1026,7 +1096,7 @@ min_class = { low = "cheap", medium = "mid" } let low = back.routing.tiers.get("low").expect("low tier present"); match low { - TierTargets::Rule { models, .. } => assert_eq!(models, &vec!["local-qwen".to_string()]), + TierTargets::Map(_) => assert_eq!(low.models(), vec!["local-qwen".to_string()]), TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]), TierTargets::One(s) => assert_eq!(s, "local-qwen"), } diff --git a/src/pipeline.rs b/src/pipeline.rs index 02c324b..4819c5f 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -232,22 +232,15 @@ impl Pipeline { match live.router.route(&text, prof).await { Ok(Some(dec)) => { let src = dec.source.clone(); - let mut dec = dec; - let (cands, task_rule, forced) = live + let (cands, task_rule) = live .routing .candidates_for(&dec.task_label, &dec.complexity_tier); - // surface which rule chose the head of the chain, - // and when the rule pinned the complexity — - // "…+task-rule@high" — reflect it in the - // decision so headers/stats tell the truth - let src = match (task_rule, &forced) { - (true, Some(tier)) => format!("{src}+task-rule@{tier}"), - (true, None) => format!("{src}+task-rule"), - _ => src, + // surface which rule chose the head of the chain + let src = if task_rule { + format!("{src}+task-rule") + } else { + src }; - if let Some(tier) = forced { - dec.complexity_tier = tier; - } (dec, src, cands) } Ok(None) | Err(_) => { diff --git a/src/routing.rs b/src/routing.rs index 9d8e38b..2a32c72 100644 --- a/src/routing.rs +++ b/src/routing.rs @@ -7,12 +7,12 @@ fn to_list(t: &TierTargets) -> Vec { t.models() } -/// A task rule: models that lead the chain, plus an optional FORCED tier — -/// "this task is always this complexity", overriding the router's verdict. -#[derive(Clone)] +/// A task rule, normalized: per-tier model lists ("which model serves this +/// task at each complexity") plus any-tier models that lead regardless. +#[derive(Clone, Default)] pub struct TaskRule { - pub models: Vec, - pub tier: Option, + pub any: Vec, + pub per: std::collections::HashMap>, } pub struct RoutingTable { @@ -36,66 +36,71 @@ impl RoutingTable { .tasks .iter() .map(|(task, targets)| { - ( - task.clone(), - TaskRule { - models: targets.models(), - tier: targets.forced_tier().map(String::from), - }, - ) + let (any, per) = targets.task_rule(); + (task.clone(), TaskRule { any, per }) }) - .filter(|(_, r)| !r.models.is_empty() || r.tier.is_some()) + .filter(|(_, r)| !r.any.is_empty() || r.per.values().any(|v| !v.is_empty())) .collect(), default: cfg.routing.default.clone(), } } - /// Candidates for `(task, tier)`, in preference order, deduplicated: - /// the task rule's models first (when one exists), then the tier chain — - /// so a task pin adds preference without ever weakening failover. - /// Returns `(candidates, task_rule_matched)`. - /// Returns `(candidates, rule_matched, forced_tier)`. A rule with a - /// forced tier replaces the router's verdict for this task BEFORE the - /// escalation check — "legal is always high" both escalates and reports - /// itself in route_source. - pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec, bool, Option) { + /// Candidate chain for `(task, tier)` and whether a task rule shaped it. + /// + /// Order of preference: + /// 1. the task's models FOR THIS TIER (the user said exactly which model + /// serves this task at this complexity); + /// 2. at tier=high — the global high chain (escalation: any-tier task + /// models must not undercut it), then the task's any-tier models; + /// otherwise the task's any-tier models, then the global tier chain; + /// 3. `default` as the last resort. Deduplicated, empties dropped. + pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec, bool) { + fn push(out: &mut Vec, id: &str) { + if !id.is_empty() && !out.iter().any(|x| x == id) { + out.push(id.to_string()); + } + } let mut out: Vec = Vec::new(); let rule = self.tasks.get(task); - let forced = rule.and_then(|r| r.tier.clone()); - let eff_tier = forced.as_deref().unwrap_or(tier); let tier_chain = self .tiers - .get(eff_tier) + .get(tier) .cloned() .unwrap_or_else(|| vec![self.default.clone()]); - // Task rules steer everyday traffic — but a high-complexity verdict - // must escalate ABOVE them, or a "code → cheap-model" rule would pin - // the hardest prompts to the small model forever. At tier=high the - // tier chain leads and the task rule becomes the fallback; otherwise - // the task rule leads as before. - let escalate = eff_tier == "high" && self.tiers.contains_key("high"); + if let Some(r) = rule { + if let Some(list) = r.per.get(tier) { + for id in list { + push(&mut out, id); + } + } + } + let escalate = tier == "high" && self.tiers.contains_key("high"); if escalate { - out.extend(tier_chain.iter().cloned()); - } - let matched = if let Some(rule) = rule { - for id in &rule.models { - if !out.contains(id) { - out.push(id.clone()); + // high verdicts escalate over any-tier task pins + for id in &tier_chain { + push(&mut out, id); + } + if let Some(r) = rule { + for id in &r.any { + push(&mut out, id); } } - true } else { - false - }; - if !escalate { - for id in tier_chain { - if !out.contains(&id) { - out.push(id); + if let Some(r) = rule { + for id in &r.any { + push(&mut out, id); } } + for id in &tier_chain { + push(&mut out, id); + } } - out.retain(|id| !id.is_empty()); - (out, matched, forced) + let d = self.default.clone(); + push(&mut out, &d); + let matched = rule + .map(|r| !r.any.is_empty() || r.per.values().any(|v| !v.is_empty())) + .unwrap_or(false); + (out, matched) } /// Candidates for the given complexity tier, in preference order. @@ -130,8 +135,8 @@ mod tests { ( k.to_string(), TaskRule { - models: v.iter().map(|s| s.to_string()).collect(), - tier: None, + any: v.iter().map(|s| s.to_string()).collect(), + per: Default::default(), }, ) }) @@ -140,14 +145,16 @@ mod tests { } } - fn with_task_tier(mut t: RoutingTable, task: &str, tier: &str) -> RoutingTable { - t.tasks - .entry(task.to_string()) - .or_insert(TaskRule { - models: Vec::new(), - tier: None, - }) - .tier = Some(tier.to_string()); + fn with_task_tier_models( + mut t: RoutingTable, + task: &str, + tier: &str, + models: &[&str], + ) -> RoutingTable { + t.tasks.entry(task.to_string()).or_default().per.insert( + tier.to_string(), + models.iter().map(|s| s.to_string()).collect(), + ); t } @@ -159,7 +166,7 @@ mod tests { "cheap", ); // dedup: rule model already in the tier chain appears once, first - let (c, matched, _) = t.candidates_for("translation", "low"); + let (c, matched) = t.candidates_for("translation", "low"); assert!(matched); assert_eq!(c, vec!["local", "cheap"]); } @@ -172,40 +179,49 @@ mod tests { "cheap", ); // medium (no chain defined → default): task rule still leads - let (c, m, _) = t.candidates_for("code", "medium"); + let (c, m) = t.candidates_for("code", "medium"); assert!(m); assert_eq!(c[0], "cheap-coder"); // high: tier chain leads, task rule follows as fallback - let (c, m, _) = t.candidates_for("code", "high"); + let (c, m) = t.candidates_for("code", "high"); assert!(m); - assert_eq!(c, vec!["big", "cheap-coder"]); + // default rides along as the very last fallback + assert_eq!(c, vec!["big", "cheap-coder", "cheap"]); } #[test] - fn forced_tier_overrides_router_verdict() { + fn per_tier_models_lead_their_tier() { let base = table( - &[("legal", &["cheap-para"])], + &[("code", &["generic-coder"])], &[("low", &["cheap"]), ("high", &["big"])], "cheap", ); - let t = with_task_tier(base, "legal", "high"); - // router said low — the rule forces high: escalation chain leads, - // rule models follow, and the forced tier is reported - let (c, m, forced) = t.candidates_for("legal", "low"); + let t = with_task_tier_models(base, "code", "high", &["best-coder"]); + // at high: the task's HIGH pick leads, escalation chain next, + // any-tier task models after it, default last + let (c, m) = t.candidates_for("code", "high"); assert!(m); - assert_eq!(forced.as_deref(), Some("high")); - assert_eq!(c, vec!["big", "cheap-para"]); - // a task without a rule is untouched - let (_, m, forced) = t.candidates_for("qa", "low"); - assert!(!m); - assert!(forced.is_none()); + assert_eq!(c, vec!["best-coder", "big", "generic-coder", "cheap"]); + // at low: any-tier task models lead as before + let (c, _) = t.candidates_for("code", "low"); + assert_eq!(c, vec!["generic-coder", "cheap"]); + // a tier with an explicit pick but no any-models + let t2 = with_task_tier_models( + table(&[], &[("low", &["cheap"]), ("high", &["big"])], "cheap"), + "legal", + "high", + &["strong"], + ); + let (c, m) = t2.candidates_for("legal", "high"); + assert!(m); + assert_eq!(c, vec!["strong", "big", "cheap"]); } #[test] fn no_rule_falls_back_to_tier_then_default() { let t = table(&[], &[("low", &["a"])], "d"); - assert_eq!(t.candidates_for("qa", "low").0, vec!["a"]); - let (c, matched, _) = t.candidates_for("qa", "unknown-tier"); + assert_eq!(t.candidates_for("qa", "low").0, vec!["a", "d"]); + let (c, matched) = t.candidates_for("qa", "unknown-tier"); assert!(!matched); assert_eq!(c, vec!["d"]); } diff --git a/web/css/styles.css b/web/css/styles.css index d8641ea..88b7b2d 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -592,8 +592,10 @@ body.modal-open { overflow: hidden; } .ok { color: var(--good); } -.task-tier-wrap { display: flex; align-items: center; gap: 6px; margin-left: 10px; white-space: nowrap; } -.task-tier { max-width: 130px; padding: 4px 8px; font-size: 12px; } +.pairs { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; } +.pair-row { display: flex; gap: 6px; align-items: center; } +.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; } /* Benchmark view */ @keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index afa2646..2996d93 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -626,5 +626,7 @@ export default { "keys.col.costAll": "$ gesamt", "routing.tasks.tier": "Komplexität", "routing.tasks.tierAuto": "Auto (Router)", - "routing.tasks.tierHint": "Komplexität für diese Aufgabe festlegen: das Router-Urteil wird ersetzt, high eskaliert weiterhin über die Modellliste.", + "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", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 9f60b4e..2dfb76d 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -619,5 +619,7 @@ export default { "keys.col.costAll": "$ all-time", "routing.tasks.tier": "Complexity", "routing.tasks.tierAuto": "Auto (router)", - "routing.tasks.tierHint": "Pin the complexity tier for this task: the router's verdict is replaced, high still escalates over the model list.", + "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", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index 64bfc80..a889016 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -626,5 +626,7 @@ export default { "keys.col.costAll": "$ total", "routing.tasks.tier": "Complejidad", "routing.tasks.tierAuto": "Auto (router)", - "routing.tasks.tierHint": "Fijar la complejidad de esta tarea: el veredicto del router se sustituye, high sigue escalando por encima de la lista de modelos.", + "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", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index c267937..767bd12 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -626,5 +626,7 @@ export default { "keys.col.costAll": "$ au total", "routing.tasks.tier": "Complexité", "routing.tasks.tierAuto": "Auto (routeur)", - "routing.tasks.tierHint": "Fixer la complexité de cette tâche : le verdict du routeur est remplacé, high continue d'escalader au-dessus de la liste de modèles.", + "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", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 822e780..2aef6e9 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -618,5 +618,7 @@ export default { "keys.col.costAll": "$ за всё время", "routing.tasks.tier": "Сложность", "routing.tasks.tierAuto": "Авто (по роутеру)", - "routing.tasks.tierHint": "Закрепить уровень сложности для задачи: вердикт роутера заменяется, high по-прежнему эскалирует поверх списка моделей.", + "routing.tasks.tierHint": "Какая модель обслуживает задачу на какой сложности; «любая» ведёт на всех уровнях. Порядок = порядок фолбэка. Вердикт high по-прежнему эскалирует поверх «любых».", + "routing.tasks.tierAny": "любая сложность", + "routing.tasks.addModel": "модель", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index 4895d7c..a57473f 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -626,5 +626,7 @@ export default { "keys.col.costAll": "$ toplam", "routing.tasks.tier": "Karmaşıklık", "routing.tasks.tierAuto": "Otomatik (yönlendirici)", - "routing.tasks.tierHint": "Bu görev için karmaşıklık katmanını sabitle: yönlendiricinin kararı değiştirilir, high yine model listesinin üzerine tırmanır.", + "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", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index 9bd7c8c..8da8e5f 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -626,5 +626,7 @@ export default { "keys.col.costAll": "$ 累计", "routing.tasks.tier": "复杂度", "routing.tasks.tierAuto": "自动(路由器)", - "routing.tasks.tierHint": "为该任务固定复杂度层级:替换路由器的判断,high 仍会越过模型列表进行升级。", + "routing.tasks.tierHint": "哪个模型在哪个复杂度上处理该任务;「任意」在所有层级领先。顺序即回退顺序。high 判定仍会越过「任意」条目升级。", + "routing.tasks.tierAny": "任意复杂度", + "routing.tasks.addModel": "模型", }; diff --git a/web/js/views/routing.js b/web/js/views/routing.js index 0fc2a69..98a0be2 100644 --- a/web/js/views/routing.js +++ b/web/js/views/routing.js @@ -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");