1use crate::error::{Error, Result};
9use crate::features::{Features, TaskKind};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Mode {
17 Enforce,
19 Observe,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum RoutingMode {
37 Observe,
40 Cost,
43 #[default]
46 Balanced,
47 Quality,
50 Latency,
53 Max,
56}
57
58impl RoutingMode {
59 #[must_use]
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Self::Observe => "observe",
64 Self::Cost => "cost",
65 Self::Balanced => "balanced",
66 Self::Quality => "quality",
67 Self::Latency => "latency",
68 Self::Max => "max",
69 }
70 }
71
72 pub const ALL: &'static [Self] = &[
74 Self::Observe,
75 Self::Cost,
76 Self::Balanced,
77 Self::Quality,
78 Self::Latency,
79 Self::Max,
80 ];
81
82 #[must_use]
87 pub fn preset(self) -> ModePreset {
88 match self {
89 Self::Observe => ModePreset {
91 speculation: None,
92 max_rungs_delta: None,
93 start_at_top: false,
94 description: "shadow mode: forward without gating; verdicts are async learning signals only",
95 tradeoff: "zero added latency, zero quality proof; use to observe before enforcing",
96 },
97 Self::Cost => ModePreset {
99 speculation: Some(0),
100 max_rungs_delta: None,
101 start_at_top: false,
102 description: "no speculative prefetch; bandit start on cheapest rung",
103 tradeoff: "lowest token spend; can serve lower quality on hard or heavy traffic",
104 },
105 Self::Balanced => ModePreset {
107 speculation: None,
108 max_rungs_delta: None,
109 start_at_top: false,
110 description: "bandit start, configured thresholds and speculation (default — no behavioral change)",
111 tradeoff: "today's tuned balance; add a mode only when you need a different point",
112 },
113 Self::Quality => ModePreset {
115 speculation: Some(0),
116 max_rungs_delta: Some(1),
117 start_at_top: false,
118 description: "one extra escalation rung; serial (no speculative waste)",
119 tradeoff: "higher quality ceiling at higher cost; still bounded by gate verification",
120 },
121 Self::Latency => ModePreset {
123 speculation: Some(1),
124 max_rungs_delta: None,
125 start_at_top: false,
126 description: "speculation=1: always prefetch one rung ahead to cut p95 latency",
127 tradeoff: "pays 1 wasted speculative call when cheap rung passes; speculation_band is overridden",
128 },
129 Self::Max => ModePreset {
131 speculation: Some(0),
132 max_rungs_delta: None,
133 start_at_top: true,
134 description: "start at the top (most expensive) ladder rung; verification as insurance",
135 tradeoff: "highest quality, highest cost per call; bandit bypassed; savings minimal",
136 },
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy)]
146pub struct ModePreset {
147 pub speculation: Option<u32>,
149 pub max_rungs_delta: Option<i32>,
152 pub start_at_top: bool,
154 pub description: &'static str,
156 pub tradeoff: &'static str,
158}
159
160#[derive(Debug, Clone, Default, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct Config {
164 #[serde(rename = "route", default)]
166 pub routes: Vec<Route>,
167 #[serde(default)]
169 pub budget: Budget,
170 #[serde(default)]
172 pub escalation: Escalation,
173 #[serde(default)]
176 pub guardrail: Option<crate::guardrail::Guardrail>,
177 #[serde(default = "default_guardrail_cooldown")]
181 pub guardrail_cooldown_secs: i64,
182 #[serde(rename = "gate", default)]
185 pub gate_defs: Vec<GateDef>,
186 #[serde(rename = "price", default)]
190 pub price_defs: Vec<PriceDef>,
191 #[serde(rename = "provider", default)]
196 pub providers: Vec<ProviderDef>,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
203#[serde(rename_all = "lowercase")]
204pub enum Dialect {
205 Anthropic,
207 Openai,
209 Gemini,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum AuthScheme {
220 #[default]
223 ApiKey,
224 AwsSigv4,
227 GcpOauth,
230}
231
232#[derive(Debug, Clone, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct ProviderDef {
237 pub id: String,
239 pub dialect: Dialect,
241 #[serde(default)]
244 pub base_url: String,
245 #[serde(default)]
249 pub api_key_env: Option<String>,
250 #[serde(default)]
253 pub auth: AuthScheme,
254 #[serde(default)]
257 pub region: Option<String>,
258 #[serde(default)]
260 pub project: Option<String>,
261}
262
263#[derive(Debug, Clone, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct PriceDef {
267 pub model: String,
269 pub input_per_mtok: f64,
271 pub output_per_mtok: f64,
273}
274
275#[derive(Debug, Clone, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct GateDef {
286 pub id: String,
288 #[serde(default)]
291 pub cmd: Vec<String>,
292 #[serde(default = "default_gate_timeout_ms")]
295 pub timeout_ms: u64,
296 #[serde(default)]
298 pub judge: Option<JudgeDef>,
299 #[serde(default)]
301 pub consistency: Option<ConsistencyDef>,
302 #[serde(default)]
305 pub schema: Option<serde_json::Value>,
306 #[serde(default)]
311 pub on_abstain: AbstainPolicy,
312}
313
314#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum AbstainPolicy {
318 #[default]
320 FailOpen,
321 FailClosed,
323}
324
325#[derive(Debug, Clone, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct JudgeDef {
331 pub model: String,
333 #[serde(default = "default_judge_threshold")]
335 pub threshold: f64,
336 #[serde(default)]
338 pub rubric: Option<String>,
339}
340
341#[derive(Debug, Clone, Deserialize)]
352#[serde(deny_unknown_fields)]
353pub struct ConsistencyDef {
354 pub model: String,
357 #[serde(default = "default_consistency_k")]
359 pub k: u32,
360 pub threshold: f64,
362}
363
364fn default_gate_timeout_ms() -> u64 {
367 30_000
368}
369
370fn default_judge_threshold() -> f64 {
372 0.7
373}
374
375fn default_consistency_k() -> u32 {
377 3
378}
379
380#[derive(Debug, Clone, Deserialize)]
382#[serde(deny_unknown_fields)]
383pub struct Route {
384 #[serde(rename = "match", default)]
386 pub match_: Match,
387 pub mode: Mode,
389 #[serde(default)]
391 pub ladder: Vec<String>,
392 #[serde(default)]
394 pub gates: Vec<String>,
395 #[serde(default)]
398 pub deferred_gates: Vec<String>,
399 #[serde(default)]
403 pub routing_mode: Option<RoutingMode>,
404 #[serde(default)]
408 pub rollout: Option<crate::rollout::Rollout>,
409 #[serde(default)]
414 pub shadow: Option<crate::rollout::Shadow>,
415}
416
417#[derive(Debug, Clone, Default, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub struct Match {
422 #[serde(default)]
424 pub agent: Option<String>,
425 #[serde(default)]
427 pub subagent: Option<StringOrVec>,
428 #[serde(default)]
430 pub task_kind: Option<TaskKind>,
431 #[serde(default)]
433 pub language: Option<StringOrVec>,
434}
435
436impl Match {
437 #[must_use]
439 pub fn matches(&self, f: &Features) -> bool {
440 if let Some(agent) = &self.agent
441 && f.agent.as_deref() != Some(agent.as_str())
442 {
443 return false;
444 }
445 if let Some(subs) = &self.subagent {
446 match &f.subagent {
447 Some(s) if subs.contains(s) => {}
448 _ => return false,
449 }
450 }
451 if let Some(tk) = self.task_kind
452 && f.task_kind != tk
453 {
454 return false;
455 }
456 if let Some(langs) = &self.language {
457 match &f.language {
458 Some(l) if langs.contains(l) => {}
459 _ => return false,
460 }
461 }
462 true
463 }
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(untagged)]
469pub enum StringOrVec {
470 One(String),
472 Many(Vec<String>),
474}
475
476impl StringOrVec {
477 #[must_use]
479 pub fn contains(&self, needle: &str) -> bool {
480 match self {
481 StringOrVec::One(s) => s == needle,
482 StringOrVec::Many(v) => v.iter().any(|s| s == needle),
483 }
484 }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490pub enum OnExhausted {
491 #[default]
493 ServeBestAttempt,
494 Error,
496}
497
498#[derive(Debug, Clone, Default, Deserialize)]
500#[serde(deny_unknown_fields)]
501pub struct Budget {
502 #[serde(default)]
504 pub per_request_usd: Option<f64>,
505 #[serde(default)]
507 pub per_session_usd: Option<f64>,
508 #[serde(default)]
510 pub per_day_usd: Option<f64>,
511 #[serde(default)]
513 pub on_exhausted: OnExhausted,
514}
515
516#[derive(Debug, Clone, Deserialize)]
518#[serde(deny_unknown_fields)]
519pub struct Escalation {
520 #[serde(default = "default_max_rungs")]
522 pub max_rungs_per_request: u32,
523 #[serde(default)]
525 pub session_promotion: Option<SessionPromotion>,
526 #[serde(default)]
529 pub speculation: u32,
530 #[serde(default)]
534 pub serve_threshold: Option<f64>,
535 #[serde(default)]
539 pub adaptive: Option<AdaptiveConfig>,
540 #[serde(default = "default_enforce_structured")]
547 pub enforce_structured: bool,
548 #[serde(default)]
553 pub bandit: Option<BanditConfig>,
554 #[serde(default)]
563 pub speculation_band: Option<[f64; 2]>,
564 #[serde(default)]
568 pub exploration: Option<ExplorationConfig>,
569 #[serde(default)]
573 pub probe: Option<ProbeConfig>,
574 #[serde(default)]
578 pub predictor: Option<PredictorConfig>,
579 #[serde(default)]
584 pub elastic: Option<ElasticConfig>,
585}
586
587#[derive(Debug, Clone, Copy, Deserialize)]
593#[serde(deny_unknown_fields)]
594pub struct PredictorConfig {
595 #[serde(default = "default_predictor_lr")]
597 pub lr: f64,
598 #[serde(default = "default_predictor_l2")]
600 pub l2: f64,
601}
602
603fn default_predictor_lr() -> f64 {
604 0.05
605}
606
607fn default_predictor_l2() -> f64 {
608 1e-4
609}
610
611#[derive(Debug, Clone, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub struct AdaptiveConfig {
615 pub alpha: f64,
617 #[serde(default = "default_adaptive_gamma")]
619 pub gamma: f64,
620}
621
622fn default_adaptive_gamma() -> f64 {
623 0.02
624}
625
626#[derive(Debug, Clone, Deserialize)]
638#[serde(deny_unknown_fields)]
639pub struct ExplorationConfig {
640 pub epsilon: f64,
645}
646
647#[derive(Debug, Clone, Copy, Deserialize)]
655#[serde(deny_unknown_fields)]
656pub struct ProbeConfig {
657 pub k: u32,
660 pub sample_rate: f64,
663}
664
665#[derive(Debug, Clone, Deserialize, PartialEq)]
681#[serde(deny_unknown_fields)]
682pub struct ElasticConfig {
683 pub expensive_gates: Vec<String>,
687 pub lambda: f64,
690 #[serde(default)]
693 pub alpha: Option<f64>,
694 #[serde(default)]
696 pub delta: Option<f64>,
697 #[serde(default)]
700 pub calibration_id: Option<String>,
701}
702
703#[derive(Debug, Clone, Deserialize)]
707#[serde(deny_unknown_fields)]
708pub struct BanditConfig {
709 #[serde(default = "default_bandit_min_observations")]
712 pub min_observations: usize,
713 #[serde(default = "default_bandit_exploration")]
716 pub exploration: f64,
717 #[serde(default)]
722 pub algorithm: BanditAlgorithm,
723 #[serde(default = "default_bandit_discount")]
727 pub discount: f64,
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
732#[serde(rename_all = "lowercase")]
733pub enum BanditAlgorithm {
734 #[default]
736 Ucb1,
737 Thompson,
739}
740
741fn default_bandit_discount() -> f64 {
742 1.0
743}
744
745fn default_bandit_min_observations() -> usize {
746 50
747}
748
749fn default_bandit_exploration() -> f64 {
750 1.0
751}
752
753const fn default_max_rungs() -> u32 {
754 3
755}
756
757fn default_enforce_structured() -> bool {
760 true
761}
762
763impl Default for Escalation {
764 fn default() -> Self {
765 Self {
766 max_rungs_per_request: default_max_rungs(),
767 session_promotion: None,
768 speculation: 0,
769 serve_threshold: None,
770 adaptive: None,
771 enforce_structured: default_enforce_structured(),
772 speculation_band: None,
773 bandit: None,
774 exploration: None,
775 probe: None,
776 predictor: None,
777 elastic: None,
778 }
779 }
780}
781
782#[derive(Debug, Clone, Deserialize)]
784#[serde(deny_unknown_fields)]
785pub struct SessionPromotion {
786 pub after_failures: u32,
788 pub window: String,
790}
791
792impl SessionPromotion {
793 pub fn window_duration(&self) -> Result<Duration> {
798 parse_window(&self.window)
799 }
800}
801
802fn parse_window(s: &str) -> Result<Duration> {
804 let s = s.trim();
805 let split = s
806 .find(|c: char| c.is_ascii_alphabetic())
807 .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
808 let (num, unit) = s.split_at(split);
809 let n: u64 = num
810 .trim()
811 .parse()
812 .map_err(|_| Error::BadDuration(s.to_owned()))?;
813 let secs = match unit {
814 "s" => n,
815 "m" => n.saturating_mul(60),
816 "h" => n.saturating_mul(3600),
817 "d" => n.saturating_mul(86_400),
818 _ => return Err(Error::BadDuration(s.to_owned())),
819 };
820 Ok(Duration::from_secs(secs))
821}
822
823#[derive(Debug, Clone, PartialEq, Eq)]
825pub struct ModelRef {
826 pub provider: String,
828 pub model: String,
830}
831
832impl ModelRef {
833 pub fn parse(s: &str) -> Result<Self> {
838 match s.split_once('/') {
839 Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
840 provider: p.to_owned(),
841 model: m.to_owned(),
842 }),
843 _ => Err(Error::BadModelRef(s.to_owned())),
844 }
845 }
846}
847
848const fn default_guardrail_cooldown() -> i64 {
849 3_600
850}
851
852impl Config {
853 pub fn parse(toml_str: &str) -> Result<Self> {
860 let config: Self = toml::from_str(toml_str)?;
861 for (i, route) in config.routes.iter().enumerate() {
865 if let Some(r) = &route.rollout {
866 r.validate()
867 .map_err(|e| Error::InvalidConfig(format!("route[{i}]: {e}")))?;
868 }
869 if let Some(sh) = &route.shadow {
870 sh.validate()
871 .map_err(|e| Error::InvalidConfig(format!("route[{i}]: {e}")))?;
872 }
873 }
874 if let Some(g) = &config.guardrail {
875 g.validate().map_err(Error::InvalidConfig)?;
876 }
877 let mut seen = std::collections::HashSet::new();
878 for def in &config.gate_defs {
879 if def.id.trim().is_empty() {
880 return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
881 }
882 let kinds_set = [
884 !def.cmd.is_empty(),
885 def.judge.is_some(),
886 def.consistency.is_some(),
887 def.schema.is_some(),
888 ]
889 .iter()
890 .filter(|&&b| b)
891 .count();
892 if kinds_set != 1 {
893 return Err(Error::InvalidConfig(format!(
894 "gate {:?} must set exactly one of `cmd`, `judge`, `consistency`, or `schema`",
895 def.id
896 )));
897 }
898 if let Some(judge) = &def.judge
899 && !(0.0..=1.0).contains(&judge.threshold)
900 {
901 return Err(Error::InvalidConfig(format!(
902 "gate {:?} judge threshold {} is outside [0, 1]",
903 def.id, judge.threshold
904 )));
905 }
906 if let Some(c) = &def.consistency {
907 if !(2..=8).contains(&c.k) {
908 return Err(Error::InvalidConfig(format!(
909 "gate {:?} consistency k {} is outside [2, 8]",
910 def.id, c.k
911 )));
912 }
913 if !(0.0..=1.0).contains(&c.threshold) {
914 return Err(Error::InvalidConfig(format!(
915 "gate {:?} consistency threshold {} is outside [0, 1]",
916 def.id, c.threshold
917 )));
918 }
919 }
920 if !seen.insert(def.id.as_str()) {
921 return Err(Error::InvalidConfig(format!(
922 "duplicate gate id {:?}",
923 def.id
924 )));
925 }
926 }
927 for price in &config.price_defs {
928 if price.model.trim().is_empty() {
929 return Err(Error::InvalidConfig(
930 "price model must not be empty".to_owned(),
931 ));
932 }
933 let ok = |v: f64| v.is_finite() && v >= 0.0;
934 if !ok(price.input_per_mtok) || !ok(price.output_per_mtok) {
935 return Err(Error::InvalidConfig(format!(
936 "price for {:?} must be finite and >= 0",
937 price.model
938 )));
939 }
940 }
941 if let Some(b) = &config.escalation.bandit {
942 if !b.exploration.is_finite() || b.exploration < 0.0 {
943 return Err(Error::InvalidConfig(format!(
944 "bandit.exploration must be finite and >= 0, got {}",
945 b.exploration
946 )));
947 }
948 if !b.discount.is_finite() || b.discount <= 0.0 || b.discount > 1.0 {
949 return Err(Error::InvalidConfig(format!(
950 "bandit.discount must be in (0, 1], got {}",
951 b.discount
952 )));
953 }
954 }
955 if let Some([lo, hi]) = config.escalation.speculation_band {
956 let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
957 if !ok(lo) || !ok(hi) || lo > hi {
958 return Err(Error::InvalidConfig(format!(
959 "escalation.speculation_band must satisfy 0 <= low <= high <= 1, got [{lo}, {hi}]"
960 )));
961 }
962 }
963 if let Some(exp) = &config.escalation.exploration
964 && (!exp.epsilon.is_finite() || exp.epsilon <= 0.0 || exp.epsilon > 0.5)
965 {
966 return Err(Error::InvalidConfig(format!(
967 "escalation.exploration.epsilon must be finite and in (0, 0.5], got {}",
968 exp.epsilon
969 )));
970 }
971 if let Some(probe) = &config.escalation.probe {
972 if !(2..=8).contains(&probe.k) {
973 return Err(Error::InvalidConfig(format!(
974 "escalation.probe.k must be in [2, 8], got {}",
975 probe.k
976 )));
977 }
978 if !probe.sample_rate.is_finite() || !(0.0..=1.0).contains(&probe.sample_rate) {
979 return Err(Error::InvalidConfig(format!(
980 "escalation.probe.sample_rate must be finite and in [0, 1], got {}",
981 probe.sample_rate
982 )));
983 }
984 }
985 if let Some(pred) = &config.escalation.predictor {
986 if !pred.lr.is_finite() || !(0.0..=1.0).contains(&pred.lr) || pred.lr == 0.0 {
987 return Err(Error::InvalidConfig(format!(
988 "escalation.predictor.lr must be finite and in (0, 1], got {}",
989 pred.lr
990 )));
991 }
992 if !pred.l2.is_finite() || pred.l2 < 0.0 {
993 return Err(Error::InvalidConfig(format!(
994 "escalation.predictor.l2 must be finite and >= 0, got {}",
995 pred.l2
996 )));
997 }
998 }
999 if let Some(elastic) = &config.escalation.elastic {
1000 if elastic.expensive_gates.is_empty() {
1001 return Err(Error::InvalidConfig(
1002 "escalation.elastic.expensive_gates must name at least one gate to skip".into(),
1003 ));
1004 }
1005 if !elastic.lambda.is_finite() || !(0.0..=1.0).contains(&elastic.lambda) {
1006 return Err(Error::InvalidConfig(format!(
1007 "escalation.elastic.lambda must be finite and in [0, 1], got {}",
1008 elastic.lambda
1009 )));
1010 }
1011 }
1012 Ok(config)
1013 }
1014
1015 #[must_use]
1017 pub fn route_for(&self, f: &Features) -> Option<&Route> {
1018 self.routes.iter().find(|r| r.match_.matches(f))
1019 }
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025 use crate::features::Features;
1026
1027 const SPEC_CONFIG: &str = r#"
1028[[route]]
1029match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
1030mode = "enforce"
1031ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1032gates = ["schema", "judge-diff"]
1033
1034[[route]]
1035match = { task_kind = "code_edit" }
1036mode = "enforce"
1037ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
1038gates = ["patch-applies", "lint-diff", "judge-diff"]
1039deferred_gates = ["compiles", "tests"]
1040
1041[[route]]
1042match = {}
1043mode = "observe"
1044ladder = ["anthropic/claude-opus-4-8"]
1045
1046[budget]
1047per_request_usd = 0.50
1048per_session_usd = 10.00
1049per_day_usd = 250.00
1050on_exhausted = "serve_best_attempt"
1051
1052[escalation]
1053max_rungs_per_request = 3
1054session_promotion = { after_failures = 3, window = "30m" }
1055"#;
1056
1057 #[test]
1058 fn parses_the_spec_example() {
1059 let c = Config::parse(SPEC_CONFIG).unwrap();
1060 assert_eq!(c.routes.len(), 3);
1061 assert_eq!(c.routes[0].mode, Mode::Enforce);
1062 assert_eq!(
1063 c.routes[0].ladder,
1064 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1065 );
1066 assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
1067 assert_eq!(c.budget.per_request_usd, Some(0.50));
1068 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
1069 assert_eq!(c.escalation.max_rungs_per_request, 3);
1070 let sp = c.escalation.session_promotion.as_ref().unwrap();
1071 assert_eq!(sp.after_failures, 3);
1072 assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
1073 }
1074
1075 #[test]
1076 fn first_matching_route_wins() {
1077 let c = Config::parse(SPEC_CONFIG).unwrap();
1078
1079 let mut f = Features::new(TaskKind::Explore);
1081 f.agent = Some("claude-code".into());
1082 f.subagent = Some("test-runner".into());
1083 let r = c.route_for(&f).unwrap();
1084 assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
1085
1086 let f2 = Features::new(TaskKind::CodeEdit);
1088 let r2 = c.route_for(&f2).unwrap();
1089 assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
1090
1091 let f3 = Features::new(TaskKind::Chat);
1093 let r3 = c.route_for(&f3).unwrap();
1094 assert_eq!(r3.mode, Mode::Observe);
1095 }
1096
1097 #[test]
1098 fn shipped_example_config_parses() {
1099 let toml = include_str!("../../../firstpass.example.toml");
1102 let c = Config::parse(toml).expect("firstpass.example.toml must parse");
1103 assert_eq!(c.routes.len(), 3);
1104 assert_eq!(c.routes[0].mode, Mode::Enforce);
1105 }
1106
1107 #[test]
1108 fn parses_gate_definitions() {
1109 let toml = r#"
1110[[route]]
1111match = {}
1112mode = "enforce"
1113ladder = ["anthropic/claude-haiku-4-5"]
1114gates = ["my-tests"]
1115
1116[[gate]]
1117id = "my-tests"
1118cmd = ["pytest", "-q"]
1119
1120[[gate]]
1121id = "judge"
1122cmd = ["bash", "-c", "./judge.sh"]
1123timeout_ms = 60000
1124"#;
1125 let c = Config::parse(toml).unwrap();
1126 assert_eq!(c.gate_defs.len(), 2);
1127 assert_eq!(c.gate_defs[0].id, "my-tests");
1128 assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
1129 assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
1130 assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
1131 }
1132
1133 #[test]
1134 fn rejects_invalid_gate_definitions() {
1135 let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
1136 assert!(matches!(
1137 Config::parse(empty_cmd),
1138 Err(Error::InvalidConfig(_))
1139 ));
1140
1141 let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
1142 assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
1143 }
1144
1145 #[test]
1146 fn parses_consistency_gate_definition() {
1147 let toml = r#"
1148[[gate]]
1149id = "uncertainty"
1150consistency = { model = "anthropic/claude-haiku-4-5", k = 5, threshold = 0.6 }
1151"#;
1152 let c = Config::parse(toml).unwrap();
1153 assert_eq!(c.gate_defs.len(), 1);
1154 let cons = c.gate_defs[0].consistency.as_ref().unwrap();
1155 assert_eq!(cons.model, "anthropic/claude-haiku-4-5");
1156 assert_eq!(cons.k, 5);
1157 assert!((cons.threshold - 0.6).abs() < 1e-9);
1158 }
1159
1160 #[test]
1161 fn consistency_k_defaults_to_3() {
1162 let toml = "[[gate]]\nid = \"u\"\nconsistency = { model = \"anthropic/claude-haiku-4-5\", threshold = 0.7 }\n";
1163 let c = Config::parse(toml).unwrap();
1164 assert_eq!(c.gate_defs[0].consistency.as_ref().unwrap().k, 3);
1165 }
1166
1167 #[test]
1168 fn rejects_exactly_one_of_violations_for_consistency() {
1169 let both = "[[gate]]\nid = \"g\"\ncmd = [\"x\"]\nconsistency = { model = \"a/b\", threshold = 0.5 }\n";
1171 assert!(matches!(Config::parse(both), Err(Error::InvalidConfig(_))));
1172
1173 let bad_k =
1175 "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 1, threshold = 0.5 }\n";
1176 assert!(matches!(Config::parse(bad_k), Err(Error::InvalidConfig(_))));
1177
1178 let bad_k2 =
1179 "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 9, threshold = 0.5 }\n";
1180 assert!(matches!(
1181 Config::parse(bad_k2),
1182 Err(Error::InvalidConfig(_))
1183 ));
1184
1185 let bad_thresh =
1187 "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", threshold = 1.1 }\n";
1188 assert!(matches!(
1189 Config::parse(bad_thresh),
1190 Err(Error::InvalidConfig(_))
1191 ));
1192 }
1193
1194 #[test]
1195 fn parses_adaptive_conformal_config() {
1196 let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
1197 let a = c
1198 .escalation
1199 .adaptive
1200 .expect("[escalation.adaptive] should parse");
1201 assert!((a.alpha - 0.1).abs() < 1e-9);
1202 assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
1203 assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
1205 }
1206
1207 #[test]
1208 fn parses_provider_entries_and_ladders_can_reference_them() {
1209 let c = Config::parse(
1210 r#"
1211[[provider]]
1212id = "groq"
1213dialect = "openai"
1214base_url = "https://api.groq.com/openai"
1215api_key_env = "GROQ_API_KEY"
1216
1217[[provider]]
1218id = "ollama"
1219dialect = "openai"
1220base_url = "http://localhost:11434"
1221
1222[[route]]
1223match = {}
1224mode = "enforce"
1225ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
1226"#,
1227 )
1228 .unwrap();
1229 assert_eq!(c.providers.len(), 2);
1230 let groq = &c.providers[0];
1231 assert_eq!(groq.id, "groq");
1232 assert_eq!(groq.dialect, Dialect::Openai);
1233 assert_eq!(groq.base_url, "https://api.groq.com/openai");
1234 assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
1235 assert_eq!(c.providers[1].id, "ollama");
1237 assert!(c.providers[1].api_key_env.is_none());
1238 assert!(Config::parse("").unwrap().providers.is_empty());
1240 }
1241
1242 #[test]
1243 fn parses_aws_sigv4_provider_and_defaults_auth_to_api_key() {
1244 let c = Config::parse(
1245 r#"
1246[[provider]]
1247id = "bedrock"
1248dialect = "anthropic"
1249auth = "aws_sigv4"
1250region = "us-east-1"
1251"#,
1252 )
1253 .unwrap();
1254 let bedrock = &c.providers[0];
1255 assert_eq!(bedrock.auth, AuthScheme::AwsSigv4);
1256 assert_eq!(bedrock.region.as_deref(), Some("us-east-1"));
1257 assert!(bedrock.project.is_none());
1258
1259 let c2 = Config::parse(
1262 r#"
1263[[provider]]
1264id = "groq"
1265dialect = "openai"
1266base_url = "https://api.groq.com/openai"
1267"#,
1268 )
1269 .unwrap();
1270 assert_eq!(c2.providers[0].auth, AuthScheme::ApiKey);
1271 }
1272
1273 #[test]
1274 fn all_documented_provider_shapes_parse() {
1275 let c = Config::parse(
1279 r#"
1280[[provider]]
1281id = "groq"
1282dialect = "openai"
1283base_url = "https://api.groq.com/openai"
1284api_key_env = "GROQ_API_KEY"
1285
1286[[provider]]
1287id = "ollama"
1288dialect = "openai"
1289base_url = "http://localhost:11434"
1290
1291[[provider]]
1292id = "gemini"
1293dialect = "gemini"
1294base_url = "https://generativelanguage.googleapis.com"
1295api_key_env = "GEMINI_API_KEY"
1296
1297[[provider]]
1298id = "bedrock"
1299dialect = "anthropic"
1300auth = "aws_sigv4"
1301region = "us-east-1"
1302
1303[[provider]]
1304id = "vertex"
1305dialect = "anthropic"
1306auth = "gcp_oauth"
1307region = "us-east5"
1308project = "my-gcp-project"
1309"#,
1310 )
1311 .expect("every documented provider shape must parse");
1312 assert_eq!(c.providers.len(), 5);
1313
1314 let gemini = c.providers.iter().find(|p| p.id == "gemini").unwrap();
1315 assert_eq!(gemini.dialect, Dialect::Gemini);
1316 assert_eq!(gemini.auth, AuthScheme::ApiKey);
1317
1318 let vertex = c.providers.iter().find(|p| p.id == "vertex").unwrap();
1319 assert_eq!(vertex.auth, AuthScheme::GcpOauth);
1320 assert_eq!(vertex.region.as_deref(), Some("us-east5"));
1321 assert_eq!(vertex.project.as_deref(), Some("my-gcp-project"));
1322 }
1323
1324 #[test]
1325 fn empty_match_is_wildcard() {
1326 let m = Match::default();
1327 assert!(m.matches(&Features::new(TaskKind::Other)));
1328 }
1329
1330 #[test]
1331 fn subagent_list_membership() {
1332 let c = Config::parse(SPEC_CONFIG).unwrap();
1333 let route0 = &c.routes[0];
1334 let mut f = Features::new(TaskKind::Other);
1335 f.agent = Some("claude-code".into());
1336 f.subagent = Some("docs-writer".into()); assert!(!route0.match_.matches(&f));
1338 f.subagent = Some("explore".into());
1339 assert!(route0.match_.matches(&f));
1340 }
1341
1342 #[test]
1343 fn model_ref_parsing() {
1344 let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
1345 assert_eq!(m.provider, "anthropic");
1346 assert_eq!(m.model, "claude-haiku-4-5");
1347 assert!(ModelRef::parse("no-slash").is_err());
1348 assert!(ModelRef::parse("/model").is_err());
1349 assert!(ModelRef::parse("a/b/c").is_err());
1350 }
1351
1352 #[test]
1353 fn window_parsing_units_and_errors() {
1354 assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
1355 assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
1356 assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
1357 assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
1358 assert!(parse_window("30x").is_err());
1359 assert!(parse_window("abc").is_err());
1360 }
1361
1362 #[test]
1363 fn empty_config_defaults() {
1364 let c = Config::parse("").unwrap();
1365 assert!(c.routes.is_empty());
1366 assert_eq!(c.escalation.max_rungs_per_request, 3);
1367 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
1368 }
1369
1370 #[test]
1373 fn parses_exploration_config() {
1374 let c = Config::parse("[escalation.exploration]\nepsilon = 0.1\n").unwrap();
1375 let exp = c
1376 .escalation
1377 .exploration
1378 .expect("[escalation.exploration] should parse");
1379 assert!((exp.epsilon - 0.1).abs() < 1e-12);
1380 assert!(Config::parse("").unwrap().escalation.exploration.is_none());
1382 }
1383
1384 #[test]
1385 fn exploration_epsilon_boundary_valid() {
1386 let c = Config::parse("[escalation.exploration]\nepsilon = 0.5\n").unwrap();
1388 assert!((c.escalation.exploration.unwrap().epsilon - 0.5).abs() < 1e-12);
1389 }
1390
1391 #[test]
1392 fn exploration_epsilon_above_half_rejected() {
1393 let bad = "[escalation.exploration]\nepsilon = 0.51\n";
1394 assert!(
1395 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1396 "epsilon > 0.5 must be rejected"
1397 );
1398 }
1399
1400 #[test]
1401 fn exploration_epsilon_zero_rejected() {
1402 let bad = "[escalation.exploration]\nepsilon = 0.0\n";
1403 assert!(
1404 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1405 "epsilon = 0 must be rejected (must be strictly positive)"
1406 );
1407 }
1408
1409 #[test]
1410 fn exploration_epsilon_negative_rejected() {
1411 let bad = "[escalation.exploration]\nepsilon = -0.1\n";
1412 assert!(
1413 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1414 "epsilon < 0 must be rejected"
1415 );
1416 }
1417
1418 #[test]
1419 fn gate_def_schema_and_on_abstain_parse() {
1420 let toml = r#"
1421[[route]]
1422match = {}
1423mode = "enforce"
1424ladder = ["anthropic/claude-haiku-4-5"]
1425gates = ["extract-shape"]
1426
1427[[gate]]
1428id = "extract-shape"
1429schema = { type = "object", required = ["name"] }
1430on_abstain = "fail_closed"
1431"#;
1432 let config = Config::parse(toml).expect("schema gate def must parse");
1433 let def = &config.gate_defs[0];
1434 assert_eq!(def.id, "extract-shape");
1435 let schema = def.schema.as_ref().expect("schema captured");
1436 assert_eq!(schema["type"], "object");
1437 assert_eq!(def.on_abstain, AbstainPolicy::FailClosed);
1438 }
1439
1440 #[test]
1441 fn gate_def_on_abstain_defaults_fail_open() {
1442 let toml = r#"
1443[[route]]
1444match = {}
1445mode = "enforce"
1446ladder = ["anthropic/claude-haiku-4-5"]
1447
1448[[gate]]
1449id = "tests"
1450cmd = ["true"]
1451"#;
1452 let config = Config::parse(toml).expect("parse");
1453 assert_eq!(config.gate_defs[0].on_abstain, AbstainPolicy::FailOpen);
1454 }
1455
1456 #[test]
1457 fn gate_def_rejects_schema_plus_cmd() {
1458 let toml = r#"
1459[[route]]
1460match = {}
1461mode = "enforce"
1462ladder = ["anthropic/claude-haiku-4-5"]
1463
1464[[gate]]
1465id = "both"
1466cmd = ["true"]
1467schema = { type = "object" }
1468"#;
1469 let err = Config::parse(toml).unwrap_err();
1470 assert!(
1471 err.to_string().contains("exactly one"),
1472 "two kinds must be rejected: {err}"
1473 );
1474 }
1475
1476 #[test]
1477 fn price_overrides_parse_and_validate() {
1478 let toml = r#"
1479[[route]]
1480match = {}
1481mode = "observe"
1482ladder = ["anthropic/claude-haiku-4-5"]
1483
1484[[price]]
1485model = "anthropic/claude-haiku-4-5"
1486input_per_mtok = 0.8
1487output_per_mtok = 4.0
1488"#;
1489 let config = Config::parse(toml).expect("price override must parse");
1490 assert_eq!(config.price_defs[0].model, "anthropic/claude-haiku-4-5");
1491 assert!((config.price_defs[0].input_per_mtok - 0.8).abs() < 1e-12);
1492
1493 let bad = toml.replace("input_per_mtok = 0.8", "input_per_mtok = -1.0");
1494 assert!(Config::parse(&bad).is_err(), "negative price rejected");
1495 }
1496
1497 #[test]
1502 fn balanced_preset_is_strict_noop() {
1503 let p = RoutingMode::Balanced.preset();
1504 assert!(
1505 p.speculation.is_none(),
1506 "Balanced must not override speculation"
1507 );
1508 assert!(
1509 p.max_rungs_delta.is_none(),
1510 "Balanced must not override max_rungs"
1511 );
1512 assert!(!p.start_at_top, "Balanced must not set start_at_top");
1513 }
1514
1515 #[test]
1516 fn cost_preset_disables_speculation() {
1517 let p = RoutingMode::Cost.preset();
1518 assert_eq!(p.speculation, Some(0));
1519 assert!(p.max_rungs_delta.is_none());
1520 assert!(!p.start_at_top);
1521 }
1522
1523 #[test]
1524 fn quality_preset_bumps_max_rungs_and_disables_speculation() {
1525 let p = RoutingMode::Quality.preset();
1526 assert_eq!(p.max_rungs_delta, Some(1));
1527 assert_eq!(p.speculation, Some(0));
1528 assert!(!p.start_at_top);
1529 }
1530
1531 #[test]
1532 fn latency_preset_enables_speculation() {
1533 let p = RoutingMode::Latency.preset();
1534 assert_eq!(p.speculation, Some(1));
1535 assert!(p.max_rungs_delta.is_none());
1536 assert!(!p.start_at_top);
1537 }
1538
1539 #[test]
1540 fn max_preset_sets_start_at_top_and_disables_speculation() {
1541 let p = RoutingMode::Max.preset();
1542 assert!(p.start_at_top);
1543 assert_eq!(p.speculation, Some(0));
1544 assert!(p.max_rungs_delta.is_none());
1545 }
1546
1547 #[test]
1548 fn routing_mode_as_str_roundtrips() {
1549 for mode in RoutingMode::ALL {
1550 let s = mode.as_str();
1551 let back: RoutingMode = serde_json::from_str(&format!("\"{s}\"")).unwrap();
1552 assert_eq!(*mode, back, "as_str/serde roundtrip for {s}");
1553 }
1554 }
1555
1556 #[test]
1557 fn routing_mode_defaults_to_balanced() {
1558 assert_eq!(RoutingMode::default(), RoutingMode::Balanced);
1559 }
1560
1561 #[test]
1562 fn route_routing_mode_parses_and_defaults_to_none() {
1563 let no_mode = Config::parse(
1565 "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n",
1566 )
1567 .unwrap();
1568 assert_eq!(no_mode.routes[0].routing_mode, None);
1569
1570 let with_mode = Config::parse(
1572 "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\nrouting_mode = \"cost\"\n",
1573 )
1574 .unwrap();
1575 assert_eq!(with_mode.routes[0].routing_mode, Some(RoutingMode::Cost));
1576 }
1577
1578 #[test]
1579 fn all_routing_modes_have_non_empty_description_and_tradeoff() {
1580 for mode in RoutingMode::ALL {
1581 let p = mode.preset();
1582 assert!(
1583 !p.description.is_empty(),
1584 "mode {} has empty description",
1585 mode.as_str()
1586 );
1587 assert!(
1588 !p.tradeoff.is_empty(),
1589 "mode {} has empty tradeoff",
1590 mode.as_str()
1591 );
1592 }
1593 }
1594
1595 #[test]
1596 fn bandit_thompson_and_band_parse_and_validate() {
1597 let toml = r#"
1598[[route]]
1599match = {}
1600mode = "enforce"
1601ladder = ["anthropic/claude-haiku-4-5"]
1602
1603[escalation]
1604speculation = 1
1605speculation_band = [0.3, 0.7]
1606
1607[escalation.bandit]
1608algorithm = "thompson"
1609discount = 0.98
1610"#;
1611 let config = Config::parse(toml).expect("thompson + band must parse");
1612 let b = config.escalation.bandit.as_ref().unwrap();
1613 assert_eq!(b.algorithm, BanditAlgorithm::Thompson);
1614 assert!((b.discount - 0.98).abs() < 1e-12);
1615 assert_eq!(config.escalation.speculation_band, Some([0.3, 0.7]));
1616
1617 let plain = Config::parse(
1619 "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.bandit]\n",
1620 )
1621 .unwrap();
1622 let b = plain.escalation.bandit.as_ref().unwrap();
1623 assert_eq!(b.algorithm, BanditAlgorithm::Ucb1);
1624 assert!((b.discount - 1.0).abs() < 1e-12);
1625
1626 assert!(Config::parse(&toml.replace("discount = 0.98", "discount = 0.0")).is_err());
1628 assert!(
1629 Config::parse(&toml.replace(
1630 "speculation_band = [0.3, 0.7]",
1631 "speculation_band = [0.9, 0.2]"
1632 ))
1633 .is_err()
1634 );
1635 }
1636
1637 #[test]
1640 fn probe_absent_defaults_to_none() {
1641 let c = Config::parse("").unwrap();
1643 assert!(c.escalation.probe.is_none());
1644 }
1645
1646 #[test]
1647 fn parses_valid_probe_config() {
1648 let c = Config::parse("[escalation.probe]\nk = 5\nsample_rate = 0.1\n").unwrap();
1649 let p = c.escalation.probe.expect("[escalation.probe] should parse");
1650 assert_eq!(p.k, 5);
1651 assert!((p.sample_rate - 0.1).abs() < 1e-12);
1652 }
1653
1654 #[test]
1655 fn probe_sample_rate_boundaries_accepted() {
1656 let c0 = Config::parse("[escalation.probe]\nk = 2\nsample_rate = 0.0\n").unwrap();
1658 assert!((c0.escalation.probe.unwrap().sample_rate - 0.0).abs() < 1e-12);
1659 let c1 = Config::parse("[escalation.probe]\nk = 8\nsample_rate = 1.0\n").unwrap();
1660 assert!((c1.escalation.probe.unwrap().sample_rate - 1.0).abs() < 1e-12);
1661 }
1662
1663 #[test]
1664 fn probe_rejects_k_below_2() {
1665 let bad = "[escalation.probe]\nk = 1\nsample_rate = 0.5\n";
1666 assert!(
1667 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1668 "k=1 must be rejected"
1669 );
1670 }
1671
1672 #[test]
1673 fn probe_rejects_k_above_8() {
1674 let bad = "[escalation.probe]\nk = 9\nsample_rate = 0.5\n";
1675 assert!(
1676 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1677 "k=9 must be rejected"
1678 );
1679 }
1680
1681 #[test]
1682 fn probe_rejects_sample_rate_above_1() {
1683 let bad = "[escalation.probe]\nk = 5\nsample_rate = 1.5\n";
1684 assert!(
1685 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1686 "sample_rate=1.5 must be rejected"
1687 );
1688 }
1689
1690 #[test]
1691 fn probe_rejects_negative_sample_rate() {
1692 let bad = "[escalation.probe]\nk = 5\nsample_rate = -0.1\n";
1693 assert!(
1694 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1695 "negative sample_rate must be rejected"
1696 );
1697 }
1698
1699 #[test]
1700 fn predictor_config_parses_and_validates() {
1701 let base = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.predictor]\nlr = 0.05\nl2 = 0.001\n";
1702 let cfg = Config::parse(base).expect("valid predictor must parse");
1703 let pred = cfg.escalation.predictor.unwrap();
1704 assert!((pred.lr - 0.05).abs() < 1e-12 && (pred.l2 - 0.001).abs() < 1e-12);
1705 let d = Config::parse("[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.predictor]\n").unwrap();
1707 assert!(d.escalation.predictor.is_some());
1708 assert!(Config::parse(&base.replace("lr = 0.05", "lr = 0.0")).is_err());
1710 assert!(Config::parse(&base.replace("lr = 0.05", "lr = 1.5")).is_err());
1711 assert!(Config::parse(&base.replace("l2 = 0.001", "l2 = -1.0")).is_err());
1712 }
1713
1714 #[test]
1718 fn shadow_config_parses_and_validates() {
1719 let base = "[[route]]\nmatch = {}\nmode = \"observe\"\nladder = [\"anthropic/a\"]\n";
1720 let none = Config::parse(base).unwrap();
1721 assert!(
1722 none.routes[0].shadow.is_none(),
1723 "shadow must be off by default"
1724 );
1725
1726 let ok = Config::parse(&format!(
1727 "{base}[route.shadow]\nsample_rate = 0.25\nmax_usd_per_day = 5.0\n"
1728 ))
1729 .unwrap();
1730 let sh = ok.routes[0].shadow.expect("shadow parsed");
1731 assert!((sh.sample_rate - 0.25).abs() < f64::EPSILON);
1732 assert!((sh.max_usd_per_day - 5.0).abs() < f64::EPSILON);
1733
1734 for bad in [
1735 "sample_rate = 1.5\nmax_usd_per_day = 5.0",
1736 "sample_rate = -0.1\nmax_usd_per_day = 5.0",
1737 "sample_rate = 0.1\nmax_usd_per_day = -1.0",
1738 ] {
1739 assert!(
1740 Config::parse(&format!("{base}[route.shadow]\n{bad}\n")).is_err(),
1741 "invalid shadow config accepted: {bad}"
1742 );
1743 }
1744 }
1745
1746 #[test]
1748 fn rollout_config_parses_and_validates() {
1749 let base = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/a\"]\n";
1750 assert!(Config::parse(base).unwrap().routes[0].rollout.is_none());
1751 let ok = Config::parse(&format!(
1752 "{base}[route.rollout]\npercent = 5.0\nkey = \"session\"\n"
1753 ))
1754 .unwrap();
1755 assert!((ok.routes[0].rollout.unwrap().percent - 5.0).abs() < f64::EPSILON);
1756 assert!(Config::parse(&format!("{base}[route.rollout]\npercent = 140.0\n")).is_err());
1757 }
1758}