1use 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
11pub 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 #[serde(default)]
23 pub region: Option<String>,
24 #[serde(skip)]
27 pub effort: Option<Effort>,
28}
29
30#[derive(Debug, Clone, Deserialize)]
31struct RouteEntry {
32 #[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#[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#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
79pub struct Tier {
80 pub name: String,
81 pub description: String,
84 pub effort: Effort,
85 pub legs: Vec<ChainLeg>,
86}
87
88#[derive(Debug, Clone, PartialEq)]
90pub struct JevRoute {
91 pub router: JevRouterConfig,
92 pub tiers: Vec<Tier>,
93}
94
95impl JevRoute {
96 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 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 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
158pub 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 routes: HashMap<String, Vec<ChainLeg>>,
169 policies: HashMap<String, String>,
170 jev: HashMap<String, JevRoute>,
171 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 pub fn jev_route(&self, model: &str) -> Option<&JevRoute> {
208 self.jev.get(model)
209 }
210
211 pub fn legs(&self, model: &str) -> Option<&[ChainLeg]> {
213 self.routes.get(model).map(Vec::as_slice)
214 }
215
216 pub fn policy_of(&self, model: &str) -> Option<&str> {
218 self.policies.get(model).map(String::as_str)
219 }
220
221 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 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 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 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
365fn 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#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct VertexPassthroughChain {
447 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 let legs = t.legs("gemini-pro").unwrap();
477 assert_eq!(legs.len(), 1);
478 assert_eq!(legs[0].provider, "qwen");
479 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 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}