Skip to main content

synapse/routing/
table.rs

1//! Route table: client-facing model alias → ordered fallback legs.
2
3use anyhow::{anyhow, bail};
4use serde::Deserialize;
5use std::collections::{HashMap, HashSet};
6use tap::Tap;
7
8use crate::routing::effort::Effort;
9use crate::routing::jev_router::{order_legs, EffortPolicy};
10
11/// Jev Score questions accept at most ten levels.
12pub const MAX_TIERS: usize = 10;
13
14#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
15pub struct ChainLeg {
16    pub provider: String,
17    pub model: String,
18    /// Optional per-leg region override for the native Vertex lane. When unset,
19    /// the lane falls back to the provider's configured region (env
20    /// `VERTEX_LOCATION`). Lets a route pin a model to the region that serves it
21    /// (e.g. `global` for Gemini 3 previews) without a process-wide env change.
22    #[serde(default)]
23    pub region: Option<String>,
24    /// Reasoning effort the route planner chose for this leg. Never read from
25    /// config: tiers carry effort and the planner stamps it onto their legs.
26    #[serde(skip)]
27    pub effort: Option<Effort>,
28}
29
30#[derive(Debug, Clone, Deserialize)]
31struct RouteEntry {
32    /// Optional only so `jev` routes can omit it; static routes still require it.
33    #[serde(default)]
34    legs: Option<Vec<ChainLeg>>,
35    #[serde(default)]
36    policy: Option<String>,
37    #[serde(default)]
38    strategy: Option<String>,
39    #[serde(default)]
40    jev_router: Option<JevRouterConfig>,
41    #[serde(default)]
42    tiers: Vec<Tier>,
43}
44
45#[derive(Debug, Clone, Deserialize)]
46struct RoutesFile {
47    routes: HashMap<String, RouteEntry>,
48}
49
50/// Decision settings of a `strategy = "jev"` route.
51#[derive(Debug, Clone, Deserialize, PartialEq)]
52pub struct JevRouterConfig {
53    #[serde(default = "default_jev_model")]
54    pub model: String,
55    pub default_tier: String,
56    #[serde(default = "default_timeout_ms")]
57    pub timeout_ms: u64,
58    #[serde(default = "default_min_confidence")]
59    pub min_confidence: f64,
60    #[serde(default = "default_reasoning_threshold")]
61    pub reasoning_threshold: f64,
62}
63
64fn default_jev_model() -> String {
65    crate::jev_native::DEFAULT_MODEL.to_string()
66}
67fn default_timeout_ms() -> u64 {
68    400
69}
70fn default_min_confidence() -> f64 {
71    0.5
72}
73fn default_reasoning_threshold() -> f64 {
74    0.7
75}
76
77/// One difficulty level of a `jev` route; tiers are ordered easiest → hardest.
78#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
79pub struct Tier {
80    pub name: String,
81    /// Task difficulty in words; sent to Jev as a Score level, so it must
82    /// describe the work, never the model.
83    pub description: String,
84    pub effort: Effort,
85    pub legs: Vec<ChainLeg>,
86}
87
88/// A validated `strategy = "jev"` route.
89#[derive(Debug, Clone, PartialEq)]
90pub struct JevRoute {
91    pub router: JevRouterConfig,
92    pub tiers: Vec<Tier>,
93}
94
95impl JevRoute {
96    /// Index of `default_tier` (validated to exist at load time).
97    pub fn default_index(&self) -> usize {
98        self.tiers
99            .iter()
100            .position(|t| t.name == self.router.default_tier)
101            .unwrap_or(0)
102    }
103
104    /// Legs in static order: `default_tier`, each harder tier, then each easier
105    /// tier, stamped with their tier's configured effort.
106    pub fn static_legs(&self) -> Vec<ChainLeg> {
107        order_legs(
108            &self.tiers,
109            self.default_index(),
110            EffortPolicy::Tier { bump: false },
111        )
112        .into_iter()
113        .map(|p| p.leg)
114        .collect()
115    }
116
117    /// This route without legs of `drop`: empty tiers removed and `default_tier`
118    /// re-picked (the first surviving tier at or above the old default, else the
119    /// hardest surviving tier). `None` when
120    /// fewer than two tiers survive — nothing is left to choose between.
121    fn pruned(&self, drop: &HashSet<String>) -> Option<JevRoute> {
122        let default = self.default_index();
123        let survivors: Vec<(usize, Tier)> = self
124            .tiers
125            .iter()
126            .enumerate()
127            .map(|(i, t)| {
128                (
129                    i,
130                    Tier {
131                        legs: t
132                            .legs
133                            .iter()
134                            .filter(|l| !drop.contains(&l.provider))
135                            .cloned()
136                            .collect(),
137                        ..t.clone()
138                    },
139                )
140            })
141            .filter(|(_, t)| !t.legs.is_empty())
142            .collect();
143        let default_tier = survivors
144            .iter()
145            .find(|(i, _)| *i >= default)
146            .or_else(|| survivors.last())
147            .map(|(_, t)| t.name.clone())?;
148        (survivors.len() >= 2).then(|| JevRoute {
149            router: JevRouterConfig {
150                default_tier,
151                ..self.router.clone()
152            },
153            tiers: survivors.into_iter().map(|(_, t)| t).collect(),
154        })
155    }
156}
157
158/// Tier indices in fallback order: `start`, each harder tier, then each easier
159/// tier. `start` is clamped into range.
160pub fn escalation_order(len: usize, start: usize) -> Vec<usize> {
161    let start = start.min(len.saturating_sub(1));
162    (start..len).chain((0..start).rev()).collect()
163}
164
165#[derive(Debug, Clone)]
166pub struct RouteTable {
167    /// Every route's legs; a `jev` route's in static order.
168    routes: HashMap<String, Vec<ChainLeg>>,
169    policies: HashMap<String, String>,
170    jev: HashMap<String, JevRoute>,
171    /// Aliases declared `strategy = "jev"`, including those since downgraded
172    /// to static.
173    jev_declared: HashSet<String>,
174}
175
176impl RouteTable {
177    pub fn from_toml_str(s: &str) -> anyhow::Result<Self> {
178        let file = toml::from_str::<RoutesFile>(s)?;
179        let policies = file
180            .routes
181            .iter()
182            .filter_map(|(name, e)| e.policy.clone().map(|p| (name.clone(), p)))
183            .collect();
184        let kinds = file
185            .routes
186            .into_iter()
187            .map(|(name, entry)| route_kind(&name, entry).map(|kind| (name, kind)))
188            .collect::<anyhow::Result<Vec<_>>>()?;
189        let jev: HashMap<String, JevRoute> = kinds
190            .iter()
191            .filter_map(|(name, (_, j))| j.clone().map(|j| (name.clone(), j)))
192            .collect();
193        let jev_declared = jev.keys().cloned().collect();
194        let routes = kinds
195            .into_iter()
196            .map(|(name, (legs, _))| (name, legs))
197            .collect();
198        Ok(Self {
199            routes,
200            policies,
201            jev,
202            jev_declared,
203        })
204    }
205
206    /// The tiers and decision settings of a `strategy = "jev"` route.
207    pub fn jev_route(&self, model: &str) -> Option<&JevRoute> {
208        self.jev.get(model)
209    }
210
211    /// Ordered legs for a model alias, or `None` if the alias is unknown.
212    pub fn legs(&self, model: &str) -> Option<&[ChainLeg]> {
213        self.routes.get(model).map(Vec::as_slice)
214    }
215
216    /// Policy name selected by a route alias, or `None` when unset.
217    pub fn policy_of(&self, model: &str) -> Option<&str> {
218        self.policies.get(model).map(String::as_str)
219    }
220
221    /// All registered aliases (for `/v1/models`), sorted for stable output.
222    pub fn aliases(&self) -> Vec<String> {
223        let mut v: Vec<String> = self.routes.keys().cloned().collect();
224        v.sort();
225        v
226    }
227
228    /// Provider ids referenced by any leg, plus `typesafe` when a `jev` route
229    /// needs Jev to decide (for fail-fast credential validation).
230    pub fn referenced_providers(&self) -> HashSet<String> {
231        self.routes
232            .values()
233            .flatten()
234            .map(|l| l.provider.clone())
235            .chain((!self.jev.is_empty()).then(|| "typesafe".to_string()))
236            .collect()
237    }
238
239    /// This table with every leg belonging to `drop` removed, and any route left
240    /// with no legs removed entirely.
241    ///
242    /// A multi-leg route survives on its remaining legs, so a route whose first
243    /// choice is unavailable degrades to its fallback instead of disappearing.
244    /// A `jev` route prunes inside its tiers; it becomes a static route when
245    /// `typesafe` is dropped or fewer than two tiers survive.
246    pub fn without_providers(&self, drop: &HashSet<String>) -> Self {
247        let jev: HashMap<String, JevRoute> = self
248            .jev
249            .iter()
250            .filter_map(|(name, route)| {
251                match drop.contains("typesafe") {
252                    true => None,
253                    false => route.pruned(drop),
254                }
255                .tap(|kept| match kept {
256                    None => tracing::warn!(
257                        route = %name,
258                        "jev route downgraded to static (removed if no legs remain): typesafe unavailable or fewer than two tiers left"
259                    ),
260                    Some(k) if k.router.default_tier != route.router.default_tier => {
261                        tracing::warn!(
262                            route = %name,
263                            from = %route.router.default_tier,
264                            to = %k.router.default_tier,
265                            "jev route default_tier pruned; re-picked"
266                        )
267                    }
268                    Some(_) => {}
269                })
270                .map(|k| (name.clone(), k))
271            })
272            .collect();
273        let routes: HashMap<String, Vec<ChainLeg>> = self
274            .routes
275            .iter()
276            .map(|(name, legs)| {
277                let kept = jev.get(name).map_or_else(
278                    || {
279                        legs.iter()
280                            .filter(|l| !drop.contains(&l.provider))
281                            .cloned()
282                            .collect()
283                    },
284                    JevRoute::static_legs,
285                );
286                (name.clone(), kept)
287            })
288            .filter(|(_, legs)| !legs.is_empty())
289            .collect();
290        let policies = self
291            .policies
292            .iter()
293            .filter(|(name, _)| routes.contains_key(*name))
294            .map(|(name, policy)| (name.clone(), policy.clone()))
295            .collect();
296        Self {
297            routes,
298            policies,
299            jev,
300            jev_declared: self.jev_declared.clone(),
301        }
302    }
303
304    /// Consecutive Vertex legs for Gemini-native passthrough fallback.
305    ///
306    /// Prefer a route whose first leg is `vertex` + `model` (lexicographically
307    /// first alias if several match). Otherwise start at the first matching
308    /// vertex leg in the lex-first route that contains it. Stop before the first
309    /// non-vertex leg. If nothing matches, return a single synthetic leg so
310    /// callers always have ≥1 attempt (same as today's single forward).
311    /// `jev` routes never take part: their legs span difficulty tiers, not
312    /// fallbacks for one model.
313    pub fn vertex_fallback_chain(&self, model: &str) -> VertexPassthroughChain {
314        let mut aliases: Vec<&String> = self
315            .routes
316            .keys()
317            .filter(|alias| !self.jev_declared.contains(*alias))
318            .collect();
319        aliases.sort();
320
321        let from_first = aliases.iter().find_map(|alias| {
322            let legs = self.routes.get(*alias)?;
323            let first = legs.first()?;
324            if first.provider == "vertex" && first.model == model {
325                Some(((*alias).clone(), 0usize))
326            } else {
327                None
328            }
329        });
330
331        let resolved = from_first.or_else(|| {
332            aliases.iter().find_map(|alias| {
333                let legs = self.routes.get(*alias)?;
334                legs.iter()
335                    .position(|l| l.provider == "vertex" && l.model == model)
336                    .map(|idx| ((*alias).clone(), idx))
337            })
338        });
339
340        match resolved {
341            Some((route, start)) => {
342                let legs = self.routes.get(&route).expect("alias from routes keys");
343                let chain: Vec<ChainLeg> = legs[start..]
344                    .iter()
345                    .take_while(|l| l.provider == "vertex")
346                    .cloned()
347                    .collect();
348                VertexPassthroughChain {
349                    route: Some(route),
350                    legs: chain,
351                }
352            }
353            None => VertexPassthroughChain {
354                route: None,
355                legs: vec![ChainLeg {
356                    provider: "vertex".into(),
357                    model: model.to_string(),
358                    ..Default::default()
359                }],
360            },
361        }
362    }
363}
364
365/// Split one `routes.toml` entry into its flat legs and, for `jev` routes, the
366/// validated tiers.
367fn route_kind(name: &str, entry: RouteEntry) -> anyhow::Result<(Vec<ChainLeg>, Option<JevRoute>)> {
368    match (
369        entry.strategy.as_deref().unwrap_or("static"),
370        entry.tiers.is_empty(),
371        entry.legs,
372    ) {
373        ("static", false, _) => bail!("route '{name}': tiers require strategy = \"jev\""),
374        ("static", true, Some(legs)) => Ok((legs, None)),
375        ("static", true, None) => bail!("route '{name}': missing field `legs`"),
376        ("jev", _, Some(legs)) if !legs.is_empty() => {
377            bail!("route '{name}': a jev route declares tiers, not legs")
378        }
379        ("jev", true, _) => bail!("route '{name}': strategy = \"jev\" requires tiers"),
380        ("jev", false, _) => validate_jev_route(name, entry.jev_router, entry.tiers)
381            .map(|r| (r.static_legs(), Some(r))),
382        (other, _, _) => {
383            bail!("route '{name}': unknown strategy '{other}' (expected \"static\" or \"jev\")")
384        }
385    }
386}
387
388fn validate_jev_route(
389    name: &str,
390    router: Option<JevRouterConfig>,
391    tiers: Vec<Tier>,
392) -> anyhow::Result<JevRoute> {
393    let router = router
394        .ok_or_else(|| anyhow!("route '{name}': strategy = \"jev\" requires a jev_router table"))?;
395    let problems: Vec<String> = [
396        (!(2..=MAX_TIERS).contains(&tiers.len()))
397            .then(|| format!("needs 2 to {MAX_TIERS} tiers, found {}", tiers.len())),
398        tiers
399            .iter()
400            .find(|t| t.name.trim().is_empty())
401            .map(|_| "tier names must be non-empty".to_string()),
402        tiers
403            .iter()
404            .find(|t| !t.name.chars().all(|c| c.is_ascii_graphic() || c == ' '))
405            .map(|t| format!("tier names must be printable ASCII: '{}'", t.name)),
406        duplicate_name(&tiers).map(|n| format!("duplicate tier name '{n}'")),
407        tiers
408            .iter()
409            .find(|t| t.description.trim().is_empty())
410            .map(|t| format!("tier '{}' needs a description", t.name)),
411        tiers
412            .iter()
413            .find(|t| t.legs.is_empty())
414            .map(|t| format!("tier '{}' has no legs", t.name)),
415        tiers
416            .iter()
417            .find(|t| t.legs.iter().any(|l| l.provider == "typesafe"))
418            .map(|t| format!("tier '{}' cannot use provider 'typesafe'", t.name)),
419        (!tiers.iter().any(|t| t.name == router.default_tier))
420            .then(|| format!("default_tier '{}' is not a tier", router.default_tier)),
421        (!(0.0..=1.0).contains(&router.min_confidence))
422            .then(|| "min_confidence must be within [0, 1]".to_string()),
423        (!(0.0..=1.0).contains(&router.reasoning_threshold))
424            .then(|| "reasoning_threshold must be within [0, 1]".to_string()),
425        (router.timeout_ms == 0).then(|| "timeout_ms must be > 0".to_string()),
426    ]
427    .into_iter()
428    .flatten()
429    .collect();
430    match problems.as_slice() {
431        [] => Ok(JevRoute { router, tiers }),
432        _ => bail!("route '{name}': {}", problems.join("; ")),
433    }
434}
435
436fn duplicate_name(tiers: &[Tier]) -> Option<&str> {
437    tiers
438        .iter()
439        .enumerate()
440        .find(|(i, t)| tiers[..*i].iter().any(|p| p.name == t.name))
441        .map(|(_, t)| t.name.as_str())
442}
443
444/// Vertex-only fallback chain used by Gemini passthrough.
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct VertexPassthroughChain {
447    /// Matched route alias, if any.
448    pub route: Option<String>,
449    pub legs: Vec<ChainLeg>,
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    const SAMPLE: &str = r#"
457        [routes."gemini-pro"]
458        legs = [
459          { provider = "vertex", model = "gemini-3-pro" },
460          { provider = "qwen", model = "qwen-max" },
461        ]
462        [routes."fast"]
463        legs = [{ provider = "vertex", model = "gemini-3-flash" }]
464    "#;
465
466    fn drop_set(ids: &[&str]) -> std::collections::HashSet<String> {
467        ids.iter().map(|s| s.to_string()).collect()
468    }
469
470    #[test]
471    fn without_providers_keeps_a_route_alive_on_its_remaining_legs() {
472        let t = RouteTable::from_toml_str(SAMPLE)
473            .unwrap()
474            .without_providers(&drop_set(&["vertex"]));
475        // gemini-pro loses its vertex leg but still serves via qwen.
476        let legs = t.legs("gemini-pro").unwrap();
477        assert_eq!(legs.len(), 1);
478        assert_eq!(legs[0].provider, "qwen");
479        // fast was vertex-only, so the alias is gone rather than empty.
480        assert!(t.legs("fast").is_none());
481        assert_eq!(t.aliases(), vec!["gemini-pro".to_string()]);
482    }
483
484    #[test]
485    fn without_providers_is_a_no_op_when_nothing_matches() {
486        let before = RouteTable::from_toml_str(SAMPLE).unwrap();
487        let after = before.without_providers(&drop_set(&["typesafe"]));
488        assert_eq!(before.aliases(), after.aliases());
489        assert_eq!(after.legs("gemini-pro").unwrap().len(), 2);
490    }
491
492    #[test]
493    fn without_providers_drops_the_policy_of_a_dropped_route() {
494        let with_policy = r#"
495            [routes."jev-first"]
496            policy = "default"
497            legs = [{ provider = "typesafe", model = "jev-latest" }]
498            [routes."kept"]
499            policy = "default"
500            legs = [{ provider = "vertex", model = "gemini-3-flash" }]
501        "#;
502        let t = RouteTable::from_toml_str(with_policy)
503            .unwrap()
504            .without_providers(&drop_set(&["typesafe"]));
505        assert!(t.policy_of("jev-first").is_none());
506        assert_eq!(t.policy_of("kept"), Some("default"));
507    }
508
509    #[test]
510    fn parses_and_resolves_legs_in_order() {
511        let t = RouteTable::from_toml_str(SAMPLE).unwrap();
512        let legs = t.legs("gemini-pro").unwrap();
513        assert_eq!(legs.len(), 2);
514        assert_eq!(
515            legs[0],
516            ChainLeg {
517                provider: "vertex".into(),
518                model: "gemini-3-pro".into(),
519                ..Default::default()
520            }
521        );
522        assert_eq!(legs[1].provider, "qwen");
523        assert!(t.legs("nope").is_none());
524    }
525
526    #[test]
527    fn parses_optional_per_leg_region() {
528        let t = RouteTable::from_toml_str(
529            r#"
530            [routes."visual"]
531            legs = [
532              { provider = "vertex", model = "gemini-3.1-pro-preview", region = "global" },
533              { provider = "qwen", model = "qwen3-vl-plus" },
534            ]
535        "#,
536        )
537        .unwrap();
538        let legs = t.legs("visual").unwrap();
539        assert_eq!(legs[0].region.as_deref(), Some("global"));
540        assert_eq!(legs[1].region, None);
541    }
542
543    #[test]
544    fn aliases_are_sorted() {
545        let t = RouteTable::from_toml_str(SAMPLE).unwrap();
546        assert_eq!(
547            t.aliases(),
548            vec!["fast".to_string(), "gemini-pro".to_string()]
549        );
550    }
551
552    #[test]
553    fn referenced_providers_collected() {
554        let t = RouteTable::from_toml_str(SAMPLE).unwrap();
555        let p = t.referenced_providers();
556        assert!(p.contains("vertex"));
557        assert!(p.contains("qwen"));
558    }
559
560    #[test]
561    fn parses_optional_route_policy_and_defaults_to_none() {
562        let t = RouteTable::from_toml_str(
563            r#"
564            [routes."guarded"]
565            policy = "strict"
566            legs = [{ provider = "vertex", model = "gemini-3-pro" }]
567            [routes."plain"]
568            legs = [{ provider = "qwen", model = "qwen-max" }]
569        "#,
570        )
571        .unwrap();
572        assert_eq!(t.policy_of("guarded"), Some("strict"));
573        assert_eq!(t.policy_of("plain"), None);
574        assert_eq!(t.policy_of("missing"), None);
575    }
576
577    #[test]
578    fn vertex_fallback_chain_deep_like_and_stops_before_qwen() {
579        let t = RouteTable::from_toml_str(
580            r#"
581            [routes."conversation"]
582            legs = [
583              { provider = "vertex", model = "gemini-3.1-pro-preview", region = "global" },
584              { provider = "vertex", model = "gemini-2.5-pro", region = "us-central1" },
585            ]
586            [routes."visual"]
587            legs = [
588              { provider = "vertex", model = "gemini-flash-image", region = "global" },
589              { provider = "qwen", model = "qwen3-vl-plus" },
590            ]
591        "#,
592        )
593        .unwrap();
594        let c = t.vertex_fallback_chain("gemini-3.1-pro-preview");
595        assert_eq!(c.route.as_deref(), Some("conversation"));
596        assert_eq!(c.legs.len(), 2);
597        assert_eq!(c.legs[1].model, "gemini-2.5-pro");
598
599        let image = t.vertex_fallback_chain("gemini-flash-image");
600        assert_eq!(image.legs.len(), 1);
601    }
602
603    #[test]
604    fn vertex_fallback_chain_picks_lex_first_alias() {
605        let t = RouteTable::from_toml_str(
606            r#"
607            [routes."planning"]
608            legs = [
609              { provider = "vertex", model = "gemini-3.1-pro-preview" },
610              { provider = "vertex", model = "gemini-2.5-pro" },
611            ]
612            [routes."conversation"]
613            legs = [
614              { provider = "vertex", model = "gemini-3.1-pro-preview" },
615              { provider = "vertex", model = "gemini-2.5-pro" },
616            ]
617        "#,
618        )
619        .unwrap();
620        assert_eq!(
621            t.vertex_fallback_chain("gemini-3.1-pro-preview")
622                .route
623                .as_deref(),
624            Some("conversation")
625        );
626    }
627
628    #[test]
629    fn vertex_fallback_chain_unknown_is_synthetic_single() {
630        let t = RouteTable::from_toml_str(SAMPLE).unwrap();
631        let c = t.vertex_fallback_chain("totally-unknown");
632        assert_eq!(c.route, None);
633        assert_eq!(c.legs.len(), 1);
634        assert_eq!(c.legs[0].model, "totally-unknown");
635    }
636
637    fn tier(name: &str, effort: &str, provider: &str, model: &str) -> String {
638        format!(
639            "[[routes.\"auto\".tiers]]\nname = \"{name}\"\ndescription = \"{name} work\"\n\
640             effort = \"{effort}\"\nlegs = [{{ provider = \"{provider}\", model = \"{model}\" }}]\n"
641        )
642    }
643
644    fn jev_toml(router_extra: &str, tiers: &[String]) -> String {
645        format!(
646            "[routes.\"auto\"]\nstrategy = \"jev\"\n[routes.\"auto\".jev_router]\n\
647             default_tier = \"moderate\"\n{router_extra}\n{}",
648            tiers.concat()
649        )
650    }
651
652    fn three_tiers() -> Vec<String> {
653        vec![
654            tier("trivial", "none", "qwen", "qwen-flash"),
655            tier("moderate", "low", "vertex", "gemini-2.5-flash"),
656            tier("hard", "medium", "vertex", "gemini-2.5-pro"),
657        ]
658    }
659
660    fn load_err(toml: &str) -> String {
661        RouteTable::from_toml_str(toml).unwrap_err().to_string()
662    }
663
664    #[test]
665    fn parses_a_tiered_route_with_router_defaults() {
666        let t = RouteTable::from_toml_str(&jev_toml("", &three_tiers())).unwrap();
667        let r = t.jev_route("auto").unwrap();
668        assert_eq!(r.tiers.len(), 3);
669        assert_eq!(r.tiers[2].effort, crate::routing::effort::Effort::Medium);
670        assert_eq!(r.router.model, "jev-latest");
671        assert_eq!(r.router.timeout_ms, 400);
672        assert_eq!(r.router.min_confidence, 0.5);
673        assert_eq!(r.router.reasoning_threshold, 0.7);
674        assert_eq!(r.default_index(), 1);
675        assert!(t.jev_route("missing").is_none());
676    }
677
678    #[test]
679    fn jev_route_legs_are_in_static_order_with_tier_effort() {
680        let t = RouteTable::from_toml_str(&jev_toml("", &three_tiers())).unwrap();
681        let legs = t.legs("auto").unwrap();
682        let got: Vec<(&str, Option<crate::routing::effort::Effort>)> =
683            legs.iter().map(|l| (l.model.as_str(), l.effort)).collect();
684        use crate::routing::effort::Effort;
685        assert_eq!(
686            got,
687            vec![
688                ("gemini-2.5-flash", Some(Effort::Low)),
689                ("gemini-2.5-pro", Some(Effort::Medium)),
690                ("qwen-flash", Some(Effort::None)),
691            ]
692        );
693    }
694
695    #[test]
696    fn referenced_providers_include_typesafe_for_jev_routes() {
697        let t = RouteTable::from_toml_str(&jev_toml("", &three_tiers())).unwrap();
698        let p = t.referenced_providers();
699        assert!(p.contains("typesafe"));
700        assert!(p.contains("qwen"));
701        assert!(!RouteTable::from_toml_str(SAMPLE)
702            .unwrap()
703            .referenced_providers()
704            .contains("typesafe"));
705    }
706
707    #[test]
708    fn empty_static_legs_stay_legal() {
709        assert!(RouteTable::from_toml_str("[routes.\"dummy\"]\nlegs = []").is_ok());
710    }
711
712    #[test]
713    fn jev_route_with_empty_legs_and_valid_tiers_is_accepted() {
714        let toml = jev_toml("", &three_tiers()).replacen(
715            "strategy = \"jev\"\n",
716            "strategy = \"jev\"\nlegs = []\n",
717            1,
718        );
719        let t = RouteTable::from_toml_str(&toml).unwrap();
720        assert_eq!(t.jev_route("auto").unwrap().tiers.len(), 3);
721        assert_eq!(t.legs("auto").unwrap().len(), 3);
722    }
723
724    #[test]
725    fn static_route_without_legs_is_still_rejected() {
726        assert!(
727            load_err("[routes.\"dummy\"]\npolicy = \"default\"\n").contains("missing field `legs`")
728        );
729    }
730
731    #[test]
732    fn rejects_invalid_tier_config() {
733        let cases: Vec<(String, &str)> = vec![
734            (
735                format!("[routes.\"auto\"]\n{}", tier("a", "low", "qwen", "m")),
736                "tiers require strategy = \"jev\"",
737            ),
738            (
739                "[routes.\"auto\"]\nstrategy = \"jev\"\nlegs = [{ provider = \"qwen\", model = \"m\" }]\n\
740                 [routes.\"auto\".jev_router]\ndefault_tier = \"a\"\n"
741                    .to_string(),
742                "declares tiers, not legs",
743            ),
744            (
745                "[routes.\"auto\"]\nstrategy = \"jev\"\n[routes.\"auto\".jev_router]\ndefault_tier = \"a\"\n"
746                    .to_string(),
747                "requires tiers",
748            ),
749            (
750                "[routes.\"auto\"]\nstrategy = \"fastest\"\nlegs = []\n".to_string(),
751                "unknown strategy 'fastest'",
752            ),
753            (
754                format!("[routes.\"auto\"]\nstrategy = \"jev\"\n{}", three_tiers().concat()),
755                "requires a jev_router table",
756            ),
757            (jev_toml("", &[tier("moderate", "low", "qwen", "m")]), "needs 2 to 10 tiers"),
758            (
759                jev_toml(
760                    "",
761                    &[tier("moderate", "low", "qwen", "a"), tier("moderate", "high", "qwen", "b")],
762                ),
763                "duplicate tier name 'moderate'",
764            ),
765            (
766                jev_toml(
767                    "",
768                    &[tier("easy", "low", "qwen", "a"), tier("hard", "high", "qwen", "b")],
769                ),
770                "default_tier 'moderate' is not a tier",
771            ),
772            (
773                jev_toml(
774                    "",
775                    &[tier("moderate", "low", "qwen", "a"), tier("jev", "high", "typesafe", "jev-latest")],
776                ),
777                "tier 'jev' cannot use provider 'typesafe'",
778            ),
779            (
780                jev_toml(
781                    "",
782                    &[
783                        tier("moderate", "low", "qwen", "a"),
784                        "[[routes.\"auto\".tiers]]\nname = \"hard\"\ndescription = \"hard work\"\neffort = \"high\"\nlegs = []\n"
785                            .to_string(),
786                    ],
787                ),
788                "tier 'hard' has no legs",
789            ),
790            (
791                jev_toml(
792                    "",
793                    &[
794                        tier("moderate", "low", "qwen", "a"),
795                        "[[routes.\"auto\".tiers]]\nname = \"hard\"\ndescription = \" \"\neffort = \"high\"\nlegs = [{ provider = \"qwen\", model = \"b\" }]\n"
796                            .to_string(),
797                    ],
798                ),
799                "tier 'hard' needs a description",
800            ),
801            (jev_toml("min_confidence = 1.5", &three_tiers()), "min_confidence must be within [0, 1]"),
802            (
803                jev_toml("reasoning_threshold = -0.1", &three_tiers()),
804                "reasoning_threshold must be within [0, 1]",
805            ),
806            (jev_toml("timeout_ms = 0", &three_tiers()), "timeout_ms must be > 0"),
807            (
808                jev_toml("", &[tier("moderate", "extreme", "qwen", "a"), tier("hard", "high", "qwen", "b")]),
809                "unknown variant",
810            ),
811            (
812                jev_toml("", &[tier("moderate", "low", "qwen", "a"), tier("", "high", "qwen", "b")]),
813                "tier names must be non-empty",
814            ),
815            (
816                jev_toml("", &[tier("moderate", "low", "qwen", "a"), tier("  ", "high", "qwen", "b")]),
817                "tier names must be non-empty",
818            ),
819            (
820                jev_toml("", &[tier("moderate", "low", "qwen", "a"), tier("difícil", "high", "qwen", "b")]),
821                "tier names must be printable ASCII: 'difícil'",
822            ),
823            (
824                jev_toml(
825                    "",
826                    &std::iter::once(tier("moderate", "low", "qwen", "m"))
827                        .chain((1..=MAX_TIERS).map(|i| tier(&format!("t{i}"), "low", "qwen", "m")))
828                        .collect::<Vec<_>>(),
829                ),
830                "needs 2 to 10 tiers, found 11",
831            ),
832        ];
833        cases.iter().for_each(|(toml, needle)| {
834            let err = load_err(toml);
835            assert!(err.contains(needle), "expected '{needle}' in: {err}");
836        });
837    }
838
839    #[test]
840    fn pruning_drops_empty_tiers_and_keeps_the_route_jev() {
841        let toml = jev_toml(
842            "",
843            &[
844                tier("trivial", "none", "qwen", "qwen-flash"),
845                tier("moderate", "low", "vertex", "gemini-2.5-flash"),
846                tier("hard", "medium", "vertex", "gemini-2.5-pro"),
847                tier("expert", "high", "openai", "gpt-x"),
848            ],
849        );
850        let t = RouteTable::from_toml_str(&toml)
851            .unwrap()
852            .without_providers(&drop_set(&["qwen"]));
853        let r = t.jev_route("auto").unwrap();
854        assert_eq!(
855            r.tiers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
856            vec!["moderate", "hard", "expert"]
857        );
858        assert_eq!(r.router.default_tier, "moderate");
859        assert!(t.legs("auto").unwrap().iter().all(|l| l.provider != "qwen"));
860    }
861
862    #[test]
863    fn pruning_the_default_tier_repicks_the_nearest_harder_tier() {
864        let t = RouteTable::from_toml_str(&jev_toml("", &three_tiers()))
865            .unwrap()
866            .without_providers(&drop_set(&["vertex"]));
867        // moderate and hard were vertex-only: one tier left ⇒ downgraded to static.
868        assert!(t.jev_route("auto").is_none());
869        assert_eq!(t.legs("auto").unwrap().len(), 1);
870        assert_eq!(t.legs("auto").unwrap()[0].model, "qwen-flash");
871
872        let four = jev_toml(
873            "",
874            &[
875                tier("trivial", "none", "qwen", "qwen-flash"),
876                tier("moderate", "low", "openai", "gpt-mini"),
877                tier("hard", "medium", "vertex", "gemini-2.5-pro"),
878                tier("expert", "high", "vertex", "gemini-3.1-pro-preview"),
879            ],
880        );
881        let t = RouteTable::from_toml_str(&four)
882            .unwrap()
883            .without_providers(&drop_set(&["openai"]));
884        assert_eq!(t.jev_route("auto").unwrap().router.default_tier, "hard");
885    }
886
887    #[test]
888    fn pruning_the_hardest_default_falls_back_to_the_nearest_easier_tier() {
889        use crate::routing::effort::Effort;
890        let toml = jev_toml(
891            "",
892            &[
893                tier("trivial", "none", "qwen", "qwen-flash"),
894                tier("easy", "low", "openai", "gpt-mini"),
895                tier("moderate", "high", "vertex", "gemini-2.5-pro"),
896            ],
897        );
898        let t = RouteTable::from_toml_str(&toml)
899            .unwrap()
900            .without_providers(&drop_set(&["vertex"]));
901        assert_eq!(t.jev_route("auto").unwrap().router.default_tier, "easy");
902        assert_eq!(
903            t.legs("auto")
904                .unwrap()
905                .iter()
906                .map(|l| (l.model.as_str(), l.effort))
907                .collect::<Vec<_>>(),
908            vec![
909                ("gpt-mini", Some(Effort::Low)),
910                ("qwen-flash", Some(Effort::None)),
911            ]
912        );
913    }
914
915    #[test]
916    fn pruning_every_tier_removes_the_route() {
917        let t = RouteTable::from_toml_str(&jev_toml("", &three_tiers()))
918            .unwrap()
919            .without_providers(&drop_set(&["qwen", "vertex"]));
920        assert!(t.jev_route("auto").is_none());
921        assert!(t.legs("auto").is_none());
922    }
923
924    #[test]
925    fn pruning_typesafe_downgrades_jev_routes_to_static() {
926        use crate::routing::effort::Effort;
927        let t = RouteTable::from_toml_str(&jev_toml("", &three_tiers()))
928            .unwrap()
929            .without_providers(&drop_set(&["typesafe"]));
930        assert!(t.jev_route("auto").is_none());
931        assert_eq!(
932            t.legs("auto")
933                .unwrap()
934                .iter()
935                .map(|l| (l.model.as_str(), l.effort))
936                .collect::<Vec<_>>(),
937            vec![
938                ("gemini-2.5-flash", Some(Effort::Low)),
939                ("gemini-2.5-pro", Some(Effort::Medium)),
940                ("qwen-flash", Some(Effort::None)),
941            ]
942        );
943        assert!(!t.referenced_providers().contains("typesafe"));
944    }
945
946    #[test]
947    fn jev_routes_never_join_the_vertex_fallback_chain() {
948        let toml = format!(
949            "{}\n[routes.\"conversation\"]\nlegs = [{{ provider = \"vertex\", model = \"gemini-2.5-pro\" }}]\n",
950            jev_toml(
951                "",
952                &[
953                    tier("trivial", "none", "qwen", "qwen-flash"),
954                    tier("moderate", "low", "vertex", "gemini-2.5-pro"),
955                    tier("hard", "medium", "vertex", "gemini-3.1-pro-preview"),
956                ],
957            )
958        );
959        let tiered = RouteTable::from_toml_str(&toml).unwrap();
960        let downgraded = tiered.without_providers(&drop_set(&["typesafe"]));
961        assert!(downgraded.jev_route("auto").is_none());
962        [tiered, downgraded].iter().for_each(|t| {
963            let pro = t.vertex_fallback_chain("gemini-2.5-pro");
964            assert_eq!(pro.route.as_deref(), Some("conversation"));
965            assert_eq!(pro.legs.len(), 1);
966            let preview = t.vertex_fallback_chain("gemini-3.1-pro-preview");
967            assert_eq!(preview.route, None);
968            assert_eq!(preview.legs.len(), 1);
969        });
970    }
971
972    #[test]
973    fn shipped_routes_and_commented_jev_example_parse() {
974        let shipped = include_str!("../../config/routes.toml");
975        assert!(RouteTable::from_toml_str(shipped).is_ok());
976        let example = shipped
977            .lines()
978            .skip_while(|l| !l.starts_with("# [routes.\"auto\"]"))
979            .map(|l| {
980                l.strip_prefix("# ")
981                    .or_else(|| l.strip_prefix('#'))
982                    .unwrap_or(l)
983            })
984            .collect::<Vec<_>>()
985            .join("\n");
986        let t = RouteTable::from_toml_str(&example).unwrap();
987        assert_eq!(t.jev_route("auto").unwrap().tiers.len(), 4);
988    }
989}