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
+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"),
}