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
+22 -11
View File
@@ -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<SharedState>) -> 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<SharedState>, Json(body): Json<RoutingB
.tasks
.into_iter()
// empty rule (no models AND no forced tier) = "по сложности"
.filter(|(_, v)| !v.models().is_empty() || v.forced_tier().is_some())
.filter(|(_, v)| !v.models().is_empty())
.collect(),
default: body.default,
policy: body.policy,
+97 -27
View File
@@ -408,38 +408,110 @@ pub struct RoutingCfg {
/// A routing tier is either a list of model_ids or the string `default`.
/// (`default` is handled by a separate field; only tier lists appear here.)
/// Task rules additionally accept the full form
/// `{ models = [...], tier = "high" }`, where `tier` FORCES the complexity
/// tier for that task (e.g. legal review is always high, no matter what the
/// router scored). Untagged: the map variant is tried first, so the old
/// plain-array form keeps parsing unchanged.
///
/// TASK rules additionally accept a per-complexity map — "which model serves
/// this task at each tier":
///
/// ```toml
/// [routing.tasks.code]
/// low = ["cheap-coder"]
/// high = ["big-model"]
/// any = ["fallback"] # leads at every tier after the tier-specific list
/// ```
///
/// The plain-array form stays valid and means "leads at any tier". The
/// transitional `{ models = [...], tier = "high" }` form is normalized into
/// the map on load. Untagged: map is tried first, arrays keep parsing as is.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TierTargets {
Rule {
#[serde(default)]
models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tier: Option<String>,
},
Map(HashMap<String, TaskTierVal>),
List(Vec<String>),
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<String>),
Str(String),
}
impl TaskTierVal {
fn list(&self) -> Vec<String> {
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<String> {
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<String>, HashMap<String, Vec<String>>) {
match self {
TierTargets::Rule { tier, .. } => tier.as_deref(),
_ => None,
TierTargets::Map(m) => {
let mut any: Vec<String> = Vec::new();
let mut per: HashMap<String, Vec<String>> = 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<String> {
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"),
}
+6 -13
View File
@@ -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(_) => {
+90 -74
View File
@@ -7,12 +7,12 @@ fn to_list(t: &TierTargets) -> Vec<String> {
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<String>,
pub tier: Option<String>,
pub any: Vec<String>,
pub per: std::collections::HashMap<String, Vec<String>>,
}
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<String>, bool, Option<String>) {
/// 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<String>, bool) {
fn push(out: &mut Vec<String>, id: &str) {
if !id.is_empty() && !out.iter().any(|x| x == id) {
out.push(id.to_string());
}
}
let mut out: Vec<String> = 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"]);
}