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, Default, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct Config {
28 #[serde(rename = "route", default)]
30 pub routes: Vec<Route>,
31 #[serde(default)]
33 pub budget: Budget,
34 #[serde(default)]
36 pub escalation: Escalation,
37 #[serde(rename = "gate", default)]
40 pub gate_defs: Vec<GateDef>,
41 #[serde(rename = "price", default)]
45 pub price_defs: Vec<PriceDef>,
46 #[serde(rename = "provider", default)]
51 pub providers: Vec<ProviderDef>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Dialect {
60 Anthropic,
62 Openai,
64 Gemini,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum AuthScheme {
75 #[default]
78 ApiKey,
79 AwsSigv4,
82 GcpOauth,
85}
86
87#[derive(Debug, Clone, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ProviderDef {
92 pub id: String,
94 pub dialect: Dialect,
96 #[serde(default)]
99 pub base_url: String,
100 #[serde(default)]
104 pub api_key_env: Option<String>,
105 #[serde(default)]
108 pub auth: AuthScheme,
109 #[serde(default)]
112 pub region: Option<String>,
113 #[serde(default)]
115 pub project: Option<String>,
116}
117
118#[derive(Debug, Clone, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct PriceDef {
122 pub model: String,
124 pub input_per_mtok: f64,
126 pub output_per_mtok: f64,
128}
129
130#[derive(Debug, Clone, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct GateDef {
141 pub id: String,
143 #[serde(default)]
146 pub cmd: Vec<String>,
147 #[serde(default = "default_gate_timeout_ms")]
150 pub timeout_ms: u64,
151 #[serde(default)]
153 pub judge: Option<JudgeDef>,
154 #[serde(default)]
156 pub consistency: Option<ConsistencyDef>,
157 #[serde(default)]
160 pub schema: Option<serde_json::Value>,
161 #[serde(default)]
166 pub on_abstain: AbstainPolicy,
167}
168
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum AbstainPolicy {
173 #[default]
175 FailOpen,
176 FailClosed,
178}
179
180#[derive(Debug, Clone, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct JudgeDef {
186 pub model: String,
188 #[serde(default = "default_judge_threshold")]
190 pub threshold: f64,
191 #[serde(default)]
193 pub rubric: Option<String>,
194}
195
196#[derive(Debug, Clone, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct ConsistencyDef {
209 pub model: String,
212 #[serde(default = "default_consistency_k")]
214 pub k: u32,
215 pub threshold: f64,
217}
218
219fn default_gate_timeout_ms() -> u64 {
222 30_000
223}
224
225fn default_judge_threshold() -> f64 {
227 0.7
228}
229
230fn default_consistency_k() -> u32 {
232 3
233}
234
235#[derive(Debug, Clone, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct Route {
239 #[serde(rename = "match", default)]
241 pub match_: Match,
242 pub mode: Mode,
244 #[serde(default)]
246 pub ladder: Vec<String>,
247 #[serde(default)]
249 pub gates: Vec<String>,
250 #[serde(default)]
253 pub deferred_gates: Vec<String>,
254}
255
256#[derive(Debug, Clone, Default, Deserialize)]
259#[serde(deny_unknown_fields)]
260pub struct Match {
261 #[serde(default)]
263 pub agent: Option<String>,
264 #[serde(default)]
266 pub subagent: Option<StringOrVec>,
267 #[serde(default)]
269 pub task_kind: Option<TaskKind>,
270 #[serde(default)]
272 pub language: Option<StringOrVec>,
273}
274
275impl Match {
276 #[must_use]
278 pub fn matches(&self, f: &Features) -> bool {
279 if let Some(agent) = &self.agent
280 && f.agent.as_deref() != Some(agent.as_str())
281 {
282 return false;
283 }
284 if let Some(subs) = &self.subagent {
285 match &f.subagent {
286 Some(s) if subs.contains(s) => {}
287 _ => return false,
288 }
289 }
290 if let Some(tk) = self.task_kind
291 && f.task_kind != tk
292 {
293 return false;
294 }
295 if let Some(langs) = &self.language {
296 match &f.language {
297 Some(l) if langs.contains(l) => {}
298 _ => return false,
299 }
300 }
301 true
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(untagged)]
308pub enum StringOrVec {
309 One(String),
311 Many(Vec<String>),
313}
314
315impl StringOrVec {
316 #[must_use]
318 pub fn contains(&self, needle: &str) -> bool {
319 match self {
320 StringOrVec::One(s) => s == needle,
321 StringOrVec::Many(v) => v.iter().any(|s| s == needle),
322 }
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum OnExhausted {
330 #[default]
332 ServeBestAttempt,
333 Error,
335}
336
337#[derive(Debug, Clone, Default, Deserialize)]
339#[serde(deny_unknown_fields)]
340pub struct Budget {
341 #[serde(default)]
343 pub per_request_usd: Option<f64>,
344 #[serde(default)]
346 pub per_session_usd: Option<f64>,
347 #[serde(default)]
349 pub per_day_usd: Option<f64>,
350 #[serde(default)]
352 pub on_exhausted: OnExhausted,
353}
354
355#[derive(Debug, Clone, Deserialize)]
357#[serde(deny_unknown_fields)]
358pub struct Escalation {
359 #[serde(default = "default_max_rungs")]
361 pub max_rungs_per_request: u32,
362 #[serde(default)]
364 pub session_promotion: Option<SessionPromotion>,
365 #[serde(default)]
368 pub speculation: u32,
369 #[serde(default)]
373 pub serve_threshold: Option<f64>,
374 #[serde(default)]
378 pub adaptive: Option<AdaptiveConfig>,
379 #[serde(default = "default_enforce_structured")]
386 pub enforce_structured: bool,
387 #[serde(default)]
392 pub bandit: Option<BanditConfig>,
393 #[serde(default)]
402 pub speculation_band: Option<[f64; 2]>,
403 #[serde(default)]
407 pub exploration: Option<ExplorationConfig>,
408}
409
410#[derive(Debug, Clone, Deserialize)]
412#[serde(deny_unknown_fields)]
413pub struct AdaptiveConfig {
414 pub alpha: f64,
416 #[serde(default = "default_adaptive_gamma")]
418 pub gamma: f64,
419}
420
421fn default_adaptive_gamma() -> f64 {
422 0.02
423}
424
425#[derive(Debug, Clone, Deserialize)]
437#[serde(deny_unknown_fields)]
438pub struct ExplorationConfig {
439 pub epsilon: f64,
444}
445
446#[derive(Debug, Clone, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct BanditConfig {
452 #[serde(default = "default_bandit_min_observations")]
455 pub min_observations: usize,
456 #[serde(default = "default_bandit_exploration")]
459 pub exploration: f64,
460 #[serde(default)]
465 pub algorithm: BanditAlgorithm,
466 #[serde(default = "default_bandit_discount")]
470 pub discount: f64,
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
475#[serde(rename_all = "lowercase")]
476pub enum BanditAlgorithm {
477 #[default]
479 Ucb1,
480 Thompson,
482}
483
484fn default_bandit_discount() -> f64 {
485 1.0
486}
487
488fn default_bandit_min_observations() -> usize {
489 50
490}
491
492fn default_bandit_exploration() -> f64 {
493 1.0
494}
495
496const fn default_max_rungs() -> u32 {
497 3
498}
499
500fn default_enforce_structured() -> bool {
503 true
504}
505
506impl Default for Escalation {
507 fn default() -> Self {
508 Self {
509 max_rungs_per_request: default_max_rungs(),
510 session_promotion: None,
511 speculation: 0,
512 serve_threshold: None,
513 adaptive: None,
514 enforce_structured: default_enforce_structured(),
515 speculation_band: None,
516 bandit: None,
517 exploration: None,
518 }
519 }
520}
521
522#[derive(Debug, Clone, Deserialize)]
524#[serde(deny_unknown_fields)]
525pub struct SessionPromotion {
526 pub after_failures: u32,
528 pub window: String,
530}
531
532impl SessionPromotion {
533 pub fn window_duration(&self) -> Result<Duration> {
538 parse_window(&self.window)
539 }
540}
541
542fn parse_window(s: &str) -> Result<Duration> {
544 let s = s.trim();
545 let split = s
546 .find(|c: char| c.is_ascii_alphabetic())
547 .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
548 let (num, unit) = s.split_at(split);
549 let n: u64 = num
550 .trim()
551 .parse()
552 .map_err(|_| Error::BadDuration(s.to_owned()))?;
553 let secs = match unit {
554 "s" => n,
555 "m" => n.saturating_mul(60),
556 "h" => n.saturating_mul(3600),
557 "d" => n.saturating_mul(86_400),
558 _ => return Err(Error::BadDuration(s.to_owned())),
559 };
560 Ok(Duration::from_secs(secs))
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct ModelRef {
566 pub provider: String,
568 pub model: String,
570}
571
572impl ModelRef {
573 pub fn parse(s: &str) -> Result<Self> {
578 match s.split_once('/') {
579 Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
580 provider: p.to_owned(),
581 model: m.to_owned(),
582 }),
583 _ => Err(Error::BadModelRef(s.to_owned())),
584 }
585 }
586}
587
588impl Config {
589 pub fn parse(toml_str: &str) -> Result<Self> {
596 let config: Self = toml::from_str(toml_str)?;
597 let mut seen = std::collections::HashSet::new();
598 for def in &config.gate_defs {
599 if def.id.trim().is_empty() {
600 return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
601 }
602 let kinds_set = [
604 !def.cmd.is_empty(),
605 def.judge.is_some(),
606 def.consistency.is_some(),
607 def.schema.is_some(),
608 ]
609 .iter()
610 .filter(|&&b| b)
611 .count();
612 if kinds_set != 1 {
613 return Err(Error::InvalidConfig(format!(
614 "gate {:?} must set exactly one of `cmd`, `judge`, `consistency`, or `schema`",
615 def.id
616 )));
617 }
618 if let Some(judge) = &def.judge
619 && !(0.0..=1.0).contains(&judge.threshold)
620 {
621 return Err(Error::InvalidConfig(format!(
622 "gate {:?} judge threshold {} is outside [0, 1]",
623 def.id, judge.threshold
624 )));
625 }
626 if let Some(c) = &def.consistency {
627 if !(2..=8).contains(&c.k) {
628 return Err(Error::InvalidConfig(format!(
629 "gate {:?} consistency k {} is outside [2, 8]",
630 def.id, c.k
631 )));
632 }
633 if !(0.0..=1.0).contains(&c.threshold) {
634 return Err(Error::InvalidConfig(format!(
635 "gate {:?} consistency threshold {} is outside [0, 1]",
636 def.id, c.threshold
637 )));
638 }
639 }
640 if !seen.insert(def.id.as_str()) {
641 return Err(Error::InvalidConfig(format!(
642 "duplicate gate id {:?}",
643 def.id
644 )));
645 }
646 }
647 for price in &config.price_defs {
648 if price.model.trim().is_empty() {
649 return Err(Error::InvalidConfig(
650 "price model must not be empty".to_owned(),
651 ));
652 }
653 let ok = |v: f64| v.is_finite() && v >= 0.0;
654 if !ok(price.input_per_mtok) || !ok(price.output_per_mtok) {
655 return Err(Error::InvalidConfig(format!(
656 "price for {:?} must be finite and >= 0",
657 price.model
658 )));
659 }
660 }
661 if let Some(b) = &config.escalation.bandit {
662 if !b.exploration.is_finite() || b.exploration < 0.0 {
663 return Err(Error::InvalidConfig(format!(
664 "bandit.exploration must be finite and >= 0, got {}",
665 b.exploration
666 )));
667 }
668 if !b.discount.is_finite() || b.discount <= 0.0 || b.discount > 1.0 {
669 return Err(Error::InvalidConfig(format!(
670 "bandit.discount must be in (0, 1], got {}",
671 b.discount
672 )));
673 }
674 }
675 if let Some([lo, hi]) = config.escalation.speculation_band {
676 let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
677 if !ok(lo) || !ok(hi) || lo > hi {
678 return Err(Error::InvalidConfig(format!(
679 "escalation.speculation_band must satisfy 0 <= low <= high <= 1, got [{lo}, {hi}]"
680 )));
681 }
682 }
683 if let Some(exp) = &config.escalation.exploration
684 && (!exp.epsilon.is_finite() || exp.epsilon <= 0.0 || exp.epsilon > 0.5)
685 {
686 return Err(Error::InvalidConfig(format!(
687 "escalation.exploration.epsilon must be finite and in (0, 0.5], got {}",
688 exp.epsilon
689 )));
690 }
691 Ok(config)
692 }
693
694 #[must_use]
696 pub fn route_for(&self, f: &Features) -> Option<&Route> {
697 self.routes.iter().find(|r| r.match_.matches(f))
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use crate::features::Features;
705
706 const SPEC_CONFIG: &str = r#"
707[[route]]
708match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
709mode = "enforce"
710ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
711gates = ["schema", "judge-diff"]
712
713[[route]]
714match = { task_kind = "code_edit" }
715mode = "enforce"
716ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
717gates = ["patch-applies", "lint-diff", "judge-diff"]
718deferred_gates = ["compiles", "tests"]
719
720[[route]]
721match = {}
722mode = "observe"
723ladder = ["anthropic/claude-opus-4-8"]
724
725[budget]
726per_request_usd = 0.50
727per_session_usd = 10.00
728per_day_usd = 250.00
729on_exhausted = "serve_best_attempt"
730
731[escalation]
732max_rungs_per_request = 3
733session_promotion = { after_failures = 3, window = "30m" }
734"#;
735
736 #[test]
737 fn parses_the_spec_example() {
738 let c = Config::parse(SPEC_CONFIG).unwrap();
739 assert_eq!(c.routes.len(), 3);
740 assert_eq!(c.routes[0].mode, Mode::Enforce);
741 assert_eq!(
742 c.routes[0].ladder,
743 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
744 );
745 assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
746 assert_eq!(c.budget.per_request_usd, Some(0.50));
747 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
748 assert_eq!(c.escalation.max_rungs_per_request, 3);
749 let sp = c.escalation.session_promotion.as_ref().unwrap();
750 assert_eq!(sp.after_failures, 3);
751 assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
752 }
753
754 #[test]
755 fn first_matching_route_wins() {
756 let c = Config::parse(SPEC_CONFIG).unwrap();
757
758 let mut f = Features::new(TaskKind::Explore);
760 f.agent = Some("claude-code".into());
761 f.subagent = Some("test-runner".into());
762 let r = c.route_for(&f).unwrap();
763 assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
764
765 let f2 = Features::new(TaskKind::CodeEdit);
767 let r2 = c.route_for(&f2).unwrap();
768 assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
769
770 let f3 = Features::new(TaskKind::Chat);
772 let r3 = c.route_for(&f3).unwrap();
773 assert_eq!(r3.mode, Mode::Observe);
774 }
775
776 #[test]
777 fn shipped_example_config_parses() {
778 let toml = include_str!("../../../firstpass.example.toml");
781 let c = Config::parse(toml).expect("firstpass.example.toml must parse");
782 assert_eq!(c.routes.len(), 3);
783 assert_eq!(c.routes[0].mode, Mode::Enforce);
784 }
785
786 #[test]
787 fn parses_gate_definitions() {
788 let toml = r#"
789[[route]]
790match = {}
791mode = "enforce"
792ladder = ["anthropic/claude-haiku-4-5"]
793gates = ["my-tests"]
794
795[[gate]]
796id = "my-tests"
797cmd = ["pytest", "-q"]
798
799[[gate]]
800id = "judge"
801cmd = ["bash", "-c", "./judge.sh"]
802timeout_ms = 60000
803"#;
804 let c = Config::parse(toml).unwrap();
805 assert_eq!(c.gate_defs.len(), 2);
806 assert_eq!(c.gate_defs[0].id, "my-tests");
807 assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
808 assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
809 assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
810 }
811
812 #[test]
813 fn rejects_invalid_gate_definitions() {
814 let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
815 assert!(matches!(
816 Config::parse(empty_cmd),
817 Err(Error::InvalidConfig(_))
818 ));
819
820 let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
821 assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
822 }
823
824 #[test]
825 fn parses_consistency_gate_definition() {
826 let toml = r#"
827[[gate]]
828id = "uncertainty"
829consistency = { model = "anthropic/claude-haiku-4-5", k = 5, threshold = 0.6 }
830"#;
831 let c = Config::parse(toml).unwrap();
832 assert_eq!(c.gate_defs.len(), 1);
833 let cons = c.gate_defs[0].consistency.as_ref().unwrap();
834 assert_eq!(cons.model, "anthropic/claude-haiku-4-5");
835 assert_eq!(cons.k, 5);
836 assert!((cons.threshold - 0.6).abs() < 1e-9);
837 }
838
839 #[test]
840 fn consistency_k_defaults_to_3() {
841 let toml = "[[gate]]\nid = \"u\"\nconsistency = { model = \"anthropic/claude-haiku-4-5\", threshold = 0.7 }\n";
842 let c = Config::parse(toml).unwrap();
843 assert_eq!(c.gate_defs[0].consistency.as_ref().unwrap().k, 3);
844 }
845
846 #[test]
847 fn rejects_exactly_one_of_violations_for_consistency() {
848 let both = "[[gate]]\nid = \"g\"\ncmd = [\"x\"]\nconsistency = { model = \"a/b\", threshold = 0.5 }\n";
850 assert!(matches!(Config::parse(both), Err(Error::InvalidConfig(_))));
851
852 let bad_k =
854 "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 1, threshold = 0.5 }\n";
855 assert!(matches!(Config::parse(bad_k), Err(Error::InvalidConfig(_))));
856
857 let bad_k2 =
858 "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 9, threshold = 0.5 }\n";
859 assert!(matches!(
860 Config::parse(bad_k2),
861 Err(Error::InvalidConfig(_))
862 ));
863
864 let bad_thresh =
866 "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", threshold = 1.1 }\n";
867 assert!(matches!(
868 Config::parse(bad_thresh),
869 Err(Error::InvalidConfig(_))
870 ));
871 }
872
873 #[test]
874 fn parses_adaptive_conformal_config() {
875 let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
876 let a = c
877 .escalation
878 .adaptive
879 .expect("[escalation.adaptive] should parse");
880 assert!((a.alpha - 0.1).abs() < 1e-9);
881 assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
882 assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
884 }
885
886 #[test]
887 fn parses_provider_entries_and_ladders_can_reference_them() {
888 let c = Config::parse(
889 r#"
890[[provider]]
891id = "groq"
892dialect = "openai"
893base_url = "https://api.groq.com/openai"
894api_key_env = "GROQ_API_KEY"
895
896[[provider]]
897id = "ollama"
898dialect = "openai"
899base_url = "http://localhost:11434"
900
901[[route]]
902match = {}
903mode = "enforce"
904ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
905"#,
906 )
907 .unwrap();
908 assert_eq!(c.providers.len(), 2);
909 let groq = &c.providers[0];
910 assert_eq!(groq.id, "groq");
911 assert_eq!(groq.dialect, Dialect::Openai);
912 assert_eq!(groq.base_url, "https://api.groq.com/openai");
913 assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
914 assert_eq!(c.providers[1].id, "ollama");
916 assert!(c.providers[1].api_key_env.is_none());
917 assert!(Config::parse("").unwrap().providers.is_empty());
919 }
920
921 #[test]
922 fn parses_aws_sigv4_provider_and_defaults_auth_to_api_key() {
923 let c = Config::parse(
924 r#"
925[[provider]]
926id = "bedrock"
927dialect = "anthropic"
928auth = "aws_sigv4"
929region = "us-east-1"
930"#,
931 )
932 .unwrap();
933 let bedrock = &c.providers[0];
934 assert_eq!(bedrock.auth, AuthScheme::AwsSigv4);
935 assert_eq!(bedrock.region.as_deref(), Some("us-east-1"));
936 assert!(bedrock.project.is_none());
937
938 let c2 = Config::parse(
941 r#"
942[[provider]]
943id = "groq"
944dialect = "openai"
945base_url = "https://api.groq.com/openai"
946"#,
947 )
948 .unwrap();
949 assert_eq!(c2.providers[0].auth, AuthScheme::ApiKey);
950 }
951
952 #[test]
953 fn all_documented_provider_shapes_parse() {
954 let c = Config::parse(
958 r#"
959[[provider]]
960id = "groq"
961dialect = "openai"
962base_url = "https://api.groq.com/openai"
963api_key_env = "GROQ_API_KEY"
964
965[[provider]]
966id = "ollama"
967dialect = "openai"
968base_url = "http://localhost:11434"
969
970[[provider]]
971id = "gemini"
972dialect = "gemini"
973base_url = "https://generativelanguage.googleapis.com"
974api_key_env = "GEMINI_API_KEY"
975
976[[provider]]
977id = "bedrock"
978dialect = "anthropic"
979auth = "aws_sigv4"
980region = "us-east-1"
981
982[[provider]]
983id = "vertex"
984dialect = "anthropic"
985auth = "gcp_oauth"
986region = "us-east5"
987project = "my-gcp-project"
988"#,
989 )
990 .expect("every documented provider shape must parse");
991 assert_eq!(c.providers.len(), 5);
992
993 let gemini = c.providers.iter().find(|p| p.id == "gemini").unwrap();
994 assert_eq!(gemini.dialect, Dialect::Gemini);
995 assert_eq!(gemini.auth, AuthScheme::ApiKey);
996
997 let vertex = c.providers.iter().find(|p| p.id == "vertex").unwrap();
998 assert_eq!(vertex.auth, AuthScheme::GcpOauth);
999 assert_eq!(vertex.region.as_deref(), Some("us-east5"));
1000 assert_eq!(vertex.project.as_deref(), Some("my-gcp-project"));
1001 }
1002
1003 #[test]
1004 fn empty_match_is_wildcard() {
1005 let m = Match::default();
1006 assert!(m.matches(&Features::new(TaskKind::Other)));
1007 }
1008
1009 #[test]
1010 fn subagent_list_membership() {
1011 let c = Config::parse(SPEC_CONFIG).unwrap();
1012 let route0 = &c.routes[0];
1013 let mut f = Features::new(TaskKind::Other);
1014 f.agent = Some("claude-code".into());
1015 f.subagent = Some("docs-writer".into()); assert!(!route0.match_.matches(&f));
1017 f.subagent = Some("explore".into());
1018 assert!(route0.match_.matches(&f));
1019 }
1020
1021 #[test]
1022 fn model_ref_parsing() {
1023 let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
1024 assert_eq!(m.provider, "anthropic");
1025 assert_eq!(m.model, "claude-haiku-4-5");
1026 assert!(ModelRef::parse("no-slash").is_err());
1027 assert!(ModelRef::parse("/model").is_err());
1028 assert!(ModelRef::parse("a/b/c").is_err());
1029 }
1030
1031 #[test]
1032 fn window_parsing_units_and_errors() {
1033 assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
1034 assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
1035 assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
1036 assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
1037 assert!(parse_window("30x").is_err());
1038 assert!(parse_window("abc").is_err());
1039 }
1040
1041 #[test]
1042 fn empty_config_defaults() {
1043 let c = Config::parse("").unwrap();
1044 assert!(c.routes.is_empty());
1045 assert_eq!(c.escalation.max_rungs_per_request, 3);
1046 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
1047 }
1048
1049 #[test]
1052 fn parses_exploration_config() {
1053 let c = Config::parse("[escalation.exploration]\nepsilon = 0.1\n").unwrap();
1054 let exp = c
1055 .escalation
1056 .exploration
1057 .expect("[escalation.exploration] should parse");
1058 assert!((exp.epsilon - 0.1).abs() < 1e-12);
1059 assert!(Config::parse("").unwrap().escalation.exploration.is_none());
1061 }
1062
1063 #[test]
1064 fn exploration_epsilon_boundary_valid() {
1065 let c = Config::parse("[escalation.exploration]\nepsilon = 0.5\n").unwrap();
1067 assert!((c.escalation.exploration.unwrap().epsilon - 0.5).abs() < 1e-12);
1068 }
1069
1070 #[test]
1071 fn exploration_epsilon_above_half_rejected() {
1072 let bad = "[escalation.exploration]\nepsilon = 0.51\n";
1073 assert!(
1074 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1075 "epsilon > 0.5 must be rejected"
1076 );
1077 }
1078
1079 #[test]
1080 fn exploration_epsilon_zero_rejected() {
1081 let bad = "[escalation.exploration]\nepsilon = 0.0\n";
1082 assert!(
1083 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1084 "epsilon = 0 must be rejected (must be strictly positive)"
1085 );
1086 }
1087
1088 #[test]
1089 fn exploration_epsilon_negative_rejected() {
1090 let bad = "[escalation.exploration]\nepsilon = -0.1\n";
1091 assert!(
1092 matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1093 "epsilon < 0 must be rejected"
1094 );
1095 }
1096
1097 #[test]
1098 fn gate_def_schema_and_on_abstain_parse() {
1099 let toml = r#"
1100[[route]]
1101match = {}
1102mode = "enforce"
1103ladder = ["anthropic/claude-haiku-4-5"]
1104gates = ["extract-shape"]
1105
1106[[gate]]
1107id = "extract-shape"
1108schema = { type = "object", required = ["name"] }
1109on_abstain = "fail_closed"
1110"#;
1111 let config = Config::parse(toml).expect("schema gate def must parse");
1112 let def = &config.gate_defs[0];
1113 assert_eq!(def.id, "extract-shape");
1114 let schema = def.schema.as_ref().expect("schema captured");
1115 assert_eq!(schema["type"], "object");
1116 assert_eq!(def.on_abstain, AbstainPolicy::FailClosed);
1117 }
1118
1119 #[test]
1120 fn gate_def_on_abstain_defaults_fail_open() {
1121 let toml = r#"
1122[[route]]
1123match = {}
1124mode = "enforce"
1125ladder = ["anthropic/claude-haiku-4-5"]
1126
1127[[gate]]
1128id = "tests"
1129cmd = ["true"]
1130"#;
1131 let config = Config::parse(toml).expect("parse");
1132 assert_eq!(config.gate_defs[0].on_abstain, AbstainPolicy::FailOpen);
1133 }
1134
1135 #[test]
1136 fn gate_def_rejects_schema_plus_cmd() {
1137 let toml = r#"
1138[[route]]
1139match = {}
1140mode = "enforce"
1141ladder = ["anthropic/claude-haiku-4-5"]
1142
1143[[gate]]
1144id = "both"
1145cmd = ["true"]
1146schema = { type = "object" }
1147"#;
1148 let err = Config::parse(toml).unwrap_err();
1149 assert!(
1150 err.to_string().contains("exactly one"),
1151 "two kinds must be rejected: {err}"
1152 );
1153 }
1154
1155 #[test]
1156 fn price_overrides_parse_and_validate() {
1157 let toml = r#"
1158[[route]]
1159match = {}
1160mode = "observe"
1161ladder = ["anthropic/claude-haiku-4-5"]
1162
1163[[price]]
1164model = "anthropic/claude-haiku-4-5"
1165input_per_mtok = 0.8
1166output_per_mtok = 4.0
1167"#;
1168 let config = Config::parse(toml).expect("price override must parse");
1169 assert_eq!(config.price_defs[0].model, "anthropic/claude-haiku-4-5");
1170 assert!((config.price_defs[0].input_per_mtok - 0.8).abs() < 1e-12);
1171
1172 let bad = toml.replace("input_per_mtok = 0.8", "input_per_mtok = -1.0");
1173 assert!(Config::parse(&bad).is_err(), "negative price rejected");
1174 }
1175
1176 #[test]
1177 fn bandit_thompson_and_band_parse_and_validate() {
1178 let toml = r#"
1179[[route]]
1180match = {}
1181mode = "enforce"
1182ladder = ["anthropic/claude-haiku-4-5"]
1183
1184[escalation]
1185speculation = 1
1186speculation_band = [0.3, 0.7]
1187
1188[escalation.bandit]
1189algorithm = "thompson"
1190discount = 0.98
1191"#;
1192 let config = Config::parse(toml).expect("thompson + band must parse");
1193 let b = config.escalation.bandit.as_ref().unwrap();
1194 assert_eq!(b.algorithm, BanditAlgorithm::Thompson);
1195 assert!((b.discount - 0.98).abs() < 1e-12);
1196 assert_eq!(config.escalation.speculation_band, Some([0.3, 0.7]));
1197
1198 let plain = Config::parse(
1200 "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.bandit]\n",
1201 )
1202 .unwrap();
1203 let b = plain.escalation.bandit.as_ref().unwrap();
1204 assert_eq!(b.algorithm, BanditAlgorithm::Ucb1);
1205 assert!((b.discount - 1.0).abs() < 1e-12);
1206
1207 assert!(Config::parse(&toml.replace("discount = 0.98", "discount = 0.0")).is_err());
1209 assert!(
1210 Config::parse(&toml.replace(
1211 "speculation_band = [0.3, 0.7]",
1212 "speculation_band = [0.9, 0.2]"
1213 ))
1214 .is_err()
1215 );
1216 }
1217}