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:
+10
-9
@@ -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
|
||||
|
||||
+10
-9
@@ -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` настроена), цепочка уровня идёт первой, а правило
|
||||
|
||||
+22
-11
@@ -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
@@ -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
@@ -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
@@ -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"]);
|
||||
}
|
||||
|
||||
Vendored
+4
-2
@@ -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; } }
|
||||
|
||||
Vendored
+3
-1
@@ -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",
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -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",
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -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",
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -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",
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -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": "модель",
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -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",
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -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": "模型",
|
||||
};
|
||||
|
||||
Vendored
+56
-31
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user