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 = "provider", default)]
46 pub providers: Vec<ProviderDef>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Dialect {
55 Anthropic,
57 Openai,
59 Gemini,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum AuthScheme {
70 #[default]
73 ApiKey,
74 AwsSigv4,
77 GcpOauth,
80}
81
82#[derive(Debug, Clone, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct ProviderDef {
87 pub id: String,
89 pub dialect: Dialect,
91 #[serde(default)]
94 pub base_url: String,
95 #[serde(default)]
99 pub api_key_env: Option<String>,
100 #[serde(default)]
103 pub auth: AuthScheme,
104 #[serde(default)]
107 pub region: Option<String>,
108 #[serde(default)]
110 pub project: Option<String>,
111}
112
113#[derive(Debug, Clone, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct GateDef {
120 pub id: String,
122 #[serde(default)]
125 pub cmd: Vec<String>,
126 #[serde(default = "default_gate_timeout_ms")]
129 pub timeout_ms: u64,
130 #[serde(default)]
132 pub judge: Option<JudgeDef>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct JudgeDef {
141 pub model: String,
143 #[serde(default = "default_judge_threshold")]
145 pub threshold: f64,
146 #[serde(default)]
148 pub rubric: Option<String>,
149}
150
151fn default_gate_timeout_ms() -> u64 {
154 30_000
155}
156
157fn default_judge_threshold() -> f64 {
159 0.7
160}
161
162#[derive(Debug, Clone, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct Route {
166 #[serde(rename = "match", default)]
168 pub match_: Match,
169 pub mode: Mode,
171 #[serde(default)]
173 pub ladder: Vec<String>,
174 #[serde(default)]
176 pub gates: Vec<String>,
177 #[serde(default)]
180 pub deferred_gates: Vec<String>,
181}
182
183#[derive(Debug, Clone, Default, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Match {
188 #[serde(default)]
190 pub agent: Option<String>,
191 #[serde(default)]
193 pub subagent: Option<StringOrVec>,
194 #[serde(default)]
196 pub task_kind: Option<TaskKind>,
197 #[serde(default)]
199 pub language: Option<StringOrVec>,
200}
201
202impl Match {
203 #[must_use]
205 pub fn matches(&self, f: &Features) -> bool {
206 if let Some(agent) = &self.agent
207 && f.agent.as_deref() != Some(agent.as_str())
208 {
209 return false;
210 }
211 if let Some(subs) = &self.subagent {
212 match &f.subagent {
213 Some(s) if subs.contains(s) => {}
214 _ => return false,
215 }
216 }
217 if let Some(tk) = self.task_kind
218 && f.task_kind != tk
219 {
220 return false;
221 }
222 if let Some(langs) = &self.language {
223 match &f.language {
224 Some(l) if langs.contains(l) => {}
225 _ => return false,
226 }
227 }
228 true
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(untagged)]
235pub enum StringOrVec {
236 One(String),
238 Many(Vec<String>),
240}
241
242impl StringOrVec {
243 #[must_use]
245 pub fn contains(&self, needle: &str) -> bool {
246 match self {
247 StringOrVec::One(s) => s == needle,
248 StringOrVec::Many(v) => v.iter().any(|s| s == needle),
249 }
250 }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum OnExhausted {
257 #[default]
259 ServeBestAttempt,
260 Error,
262}
263
264#[derive(Debug, Clone, Default, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct Budget {
268 #[serde(default)]
270 pub per_request_usd: Option<f64>,
271 #[serde(default)]
273 pub per_session_usd: Option<f64>,
274 #[serde(default)]
276 pub per_day_usd: Option<f64>,
277 #[serde(default)]
279 pub on_exhausted: OnExhausted,
280}
281
282#[derive(Debug, Clone, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct Escalation {
286 #[serde(default = "default_max_rungs")]
288 pub max_rungs_per_request: u32,
289 #[serde(default)]
291 pub session_promotion: Option<SessionPromotion>,
292 #[serde(default)]
295 pub speculation: u32,
296 #[serde(default)]
300 pub serve_threshold: Option<f64>,
301 #[serde(default)]
305 pub adaptive: Option<AdaptiveConfig>,
306 #[serde(default)]
311 pub enforce_structured: bool,
312}
313
314#[derive(Debug, Clone, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct AdaptiveConfig {
318 pub alpha: f64,
320 #[serde(default = "default_adaptive_gamma")]
322 pub gamma: f64,
323}
324
325fn default_adaptive_gamma() -> f64 {
326 0.02
327}
328
329const fn default_max_rungs() -> u32 {
330 3
331}
332
333impl Default for Escalation {
334 fn default() -> Self {
335 Self {
336 max_rungs_per_request: default_max_rungs(),
337 session_promotion: None,
338 speculation: 0,
339 serve_threshold: None,
340 adaptive: None,
341 enforce_structured: false,
342 }
343 }
344}
345
346#[derive(Debug, Clone, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct SessionPromotion {
350 pub after_failures: u32,
352 pub window: String,
354}
355
356impl SessionPromotion {
357 pub fn window_duration(&self) -> Result<Duration> {
362 parse_window(&self.window)
363 }
364}
365
366fn parse_window(s: &str) -> Result<Duration> {
368 let s = s.trim();
369 let split = s
370 .find(|c: char| c.is_ascii_alphabetic())
371 .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
372 let (num, unit) = s.split_at(split);
373 let n: u64 = num
374 .trim()
375 .parse()
376 .map_err(|_| Error::BadDuration(s.to_owned()))?;
377 let secs = match unit {
378 "s" => n,
379 "m" => n.saturating_mul(60),
380 "h" => n.saturating_mul(3600),
381 "d" => n.saturating_mul(86_400),
382 _ => return Err(Error::BadDuration(s.to_owned())),
383 };
384 Ok(Duration::from_secs(secs))
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct ModelRef {
390 pub provider: String,
392 pub model: String,
394}
395
396impl ModelRef {
397 pub fn parse(s: &str) -> Result<Self> {
402 match s.split_once('/') {
403 Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
404 provider: p.to_owned(),
405 model: m.to_owned(),
406 }),
407 _ => Err(Error::BadModelRef(s.to_owned())),
408 }
409 }
410}
411
412impl Config {
413 pub fn parse(toml_str: &str) -> Result<Self> {
420 let config: Self = toml::from_str(toml_str)?;
421 let mut seen = std::collections::HashSet::new();
422 for def in &config.gate_defs {
423 if def.id.trim().is_empty() {
424 return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
425 }
426 if def.cmd.is_empty() == def.judge.is_none() {
428 return Err(Error::InvalidConfig(format!(
429 "gate {:?} must set exactly one of `cmd` or `judge`",
430 def.id
431 )));
432 }
433 if let Some(judge) = &def.judge
434 && !(0.0..=1.0).contains(&judge.threshold)
435 {
436 return Err(Error::InvalidConfig(format!(
437 "gate {:?} judge threshold {} is outside [0, 1]",
438 def.id, judge.threshold
439 )));
440 }
441 if !seen.insert(def.id.as_str()) {
442 return Err(Error::InvalidConfig(format!(
443 "duplicate gate id {:?}",
444 def.id
445 )));
446 }
447 }
448 Ok(config)
449 }
450
451 #[must_use]
453 pub fn route_for(&self, f: &Features) -> Option<&Route> {
454 self.routes.iter().find(|r| r.match_.matches(f))
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use crate::features::Features;
462
463 const SPEC_CONFIG: &str = r#"
464[[route]]
465match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
466mode = "enforce"
467ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
468gates = ["schema", "judge-diff"]
469
470[[route]]
471match = { task_kind = "code_edit" }
472mode = "enforce"
473ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
474gates = ["patch-applies", "lint-diff", "judge-diff"]
475deferred_gates = ["compiles", "tests"]
476
477[[route]]
478match = {}
479mode = "observe"
480ladder = ["anthropic/claude-opus-4-8"]
481
482[budget]
483per_request_usd = 0.50
484per_session_usd = 10.00
485per_day_usd = 250.00
486on_exhausted = "serve_best_attempt"
487
488[escalation]
489max_rungs_per_request = 3
490session_promotion = { after_failures = 3, window = "30m" }
491"#;
492
493 #[test]
494 fn parses_the_spec_example() {
495 let c = Config::parse(SPEC_CONFIG).unwrap();
496 assert_eq!(c.routes.len(), 3);
497 assert_eq!(c.routes[0].mode, Mode::Enforce);
498 assert_eq!(
499 c.routes[0].ladder,
500 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
501 );
502 assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
503 assert_eq!(c.budget.per_request_usd, Some(0.50));
504 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
505 assert_eq!(c.escalation.max_rungs_per_request, 3);
506 let sp = c.escalation.session_promotion.as_ref().unwrap();
507 assert_eq!(sp.after_failures, 3);
508 assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
509 }
510
511 #[test]
512 fn first_matching_route_wins() {
513 let c = Config::parse(SPEC_CONFIG).unwrap();
514
515 let mut f = Features::new(TaskKind::Explore);
517 f.agent = Some("claude-code".into());
518 f.subagent = Some("test-runner".into());
519 let r = c.route_for(&f).unwrap();
520 assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
521
522 let f2 = Features::new(TaskKind::CodeEdit);
524 let r2 = c.route_for(&f2).unwrap();
525 assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
526
527 let f3 = Features::new(TaskKind::Chat);
529 let r3 = c.route_for(&f3).unwrap();
530 assert_eq!(r3.mode, Mode::Observe);
531 }
532
533 #[test]
534 fn shipped_example_config_parses() {
535 let toml = include_str!("../../../firstpass.example.toml");
538 let c = Config::parse(toml).expect("firstpass.example.toml must parse");
539 assert_eq!(c.routes.len(), 3);
540 assert_eq!(c.routes[0].mode, Mode::Enforce);
541 }
542
543 #[test]
544 fn parses_gate_definitions() {
545 let toml = r#"
546[[route]]
547match = {}
548mode = "enforce"
549ladder = ["anthropic/claude-haiku-4-5"]
550gates = ["my-tests"]
551
552[[gate]]
553id = "my-tests"
554cmd = ["pytest", "-q"]
555
556[[gate]]
557id = "judge"
558cmd = ["bash", "-c", "./judge.sh"]
559timeout_ms = 60000
560"#;
561 let c = Config::parse(toml).unwrap();
562 assert_eq!(c.gate_defs.len(), 2);
563 assert_eq!(c.gate_defs[0].id, "my-tests");
564 assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
565 assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
566 assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
567 }
568
569 #[test]
570 fn rejects_invalid_gate_definitions() {
571 let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
572 assert!(matches!(
573 Config::parse(empty_cmd),
574 Err(Error::InvalidConfig(_))
575 ));
576
577 let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
578 assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
579 }
580
581 #[test]
582 fn parses_adaptive_conformal_config() {
583 let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
584 let a = c
585 .escalation
586 .adaptive
587 .expect("[escalation.adaptive] should parse");
588 assert!((a.alpha - 0.1).abs() < 1e-9);
589 assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
590 assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
592 }
593
594 #[test]
595 fn parses_provider_entries_and_ladders_can_reference_them() {
596 let c = Config::parse(
597 r#"
598[[provider]]
599id = "groq"
600dialect = "openai"
601base_url = "https://api.groq.com/openai"
602api_key_env = "GROQ_API_KEY"
603
604[[provider]]
605id = "ollama"
606dialect = "openai"
607base_url = "http://localhost:11434"
608
609[[route]]
610match = {}
611mode = "enforce"
612ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
613"#,
614 )
615 .unwrap();
616 assert_eq!(c.providers.len(), 2);
617 let groq = &c.providers[0];
618 assert_eq!(groq.id, "groq");
619 assert_eq!(groq.dialect, Dialect::Openai);
620 assert_eq!(groq.base_url, "https://api.groq.com/openai");
621 assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
622 assert_eq!(c.providers[1].id, "ollama");
624 assert!(c.providers[1].api_key_env.is_none());
625 assert!(Config::parse("").unwrap().providers.is_empty());
627 }
628
629 #[test]
630 fn parses_aws_sigv4_provider_and_defaults_auth_to_api_key() {
631 let c = Config::parse(
632 r#"
633[[provider]]
634id = "bedrock"
635dialect = "anthropic"
636auth = "aws_sigv4"
637region = "us-east-1"
638"#,
639 )
640 .unwrap();
641 let bedrock = &c.providers[0];
642 assert_eq!(bedrock.auth, AuthScheme::AwsSigv4);
643 assert_eq!(bedrock.region.as_deref(), Some("us-east-1"));
644 assert!(bedrock.project.is_none());
645
646 let c2 = Config::parse(
649 r#"
650[[provider]]
651id = "groq"
652dialect = "openai"
653base_url = "https://api.groq.com/openai"
654"#,
655 )
656 .unwrap();
657 assert_eq!(c2.providers[0].auth, AuthScheme::ApiKey);
658 }
659
660 #[test]
661 fn all_documented_provider_shapes_parse() {
662 let c = Config::parse(
666 r#"
667[[provider]]
668id = "groq"
669dialect = "openai"
670base_url = "https://api.groq.com/openai"
671api_key_env = "GROQ_API_KEY"
672
673[[provider]]
674id = "ollama"
675dialect = "openai"
676base_url = "http://localhost:11434"
677
678[[provider]]
679id = "gemini"
680dialect = "gemini"
681base_url = "https://generativelanguage.googleapis.com"
682api_key_env = "GEMINI_API_KEY"
683
684[[provider]]
685id = "bedrock"
686dialect = "anthropic"
687auth = "aws_sigv4"
688region = "us-east-1"
689
690[[provider]]
691id = "vertex"
692dialect = "anthropic"
693auth = "gcp_oauth"
694region = "us-east5"
695project = "my-gcp-project"
696"#,
697 )
698 .expect("every documented provider shape must parse");
699 assert_eq!(c.providers.len(), 5);
700
701 let gemini = c.providers.iter().find(|p| p.id == "gemini").unwrap();
702 assert_eq!(gemini.dialect, Dialect::Gemini);
703 assert_eq!(gemini.auth, AuthScheme::ApiKey);
704
705 let vertex = c.providers.iter().find(|p| p.id == "vertex").unwrap();
706 assert_eq!(vertex.auth, AuthScheme::GcpOauth);
707 assert_eq!(vertex.region.as_deref(), Some("us-east5"));
708 assert_eq!(vertex.project.as_deref(), Some("my-gcp-project"));
709 }
710
711 #[test]
712 fn empty_match_is_wildcard() {
713 let m = Match::default();
714 assert!(m.matches(&Features::new(TaskKind::Other)));
715 }
716
717 #[test]
718 fn subagent_list_membership() {
719 let c = Config::parse(SPEC_CONFIG).unwrap();
720 let route0 = &c.routes[0];
721 let mut f = Features::new(TaskKind::Other);
722 f.agent = Some("claude-code".into());
723 f.subagent = Some("docs-writer".into()); assert!(!route0.match_.matches(&f));
725 f.subagent = Some("explore".into());
726 assert!(route0.match_.matches(&f));
727 }
728
729 #[test]
730 fn model_ref_parsing() {
731 let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
732 assert_eq!(m.provider, "anthropic");
733 assert_eq!(m.model, "claude-haiku-4-5");
734 assert!(ModelRef::parse("no-slash").is_err());
735 assert!(ModelRef::parse("/model").is_err());
736 assert!(ModelRef::parse("a/b/c").is_err());
737 }
738
739 #[test]
740 fn window_parsing_units_and_errors() {
741 assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
742 assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
743 assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
744 assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
745 assert!(parse_window("30x").is_err());
746 assert!(parse_window("abc").is_err());
747 }
748
749 #[test]
750 fn empty_config_defaults() {
751 let c = Config::parse("").unwrap();
752 assert!(c.routes.is_empty());
753 assert_eq!(c.escalation.max_rungs_per_request, 3);
754 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
755 }
756}