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)]
52#[serde(rename_all = "lowercase")]
53pub enum Dialect {
54 Anthropic,
56 Openai,
58}
59
60#[derive(Debug, Clone, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct ProviderDef {
65 pub id: String,
67 pub dialect: Dialect,
69 pub base_url: String,
71 #[serde(default)]
75 pub api_key_env: Option<String>,
76}
77
78#[derive(Debug, Clone, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct GateDef {
85 pub id: String,
87 #[serde(default)]
90 pub cmd: Vec<String>,
91 #[serde(default = "default_gate_timeout_ms")]
94 pub timeout_ms: u64,
95 #[serde(default)]
97 pub judge: Option<JudgeDef>,
98}
99
100#[derive(Debug, Clone, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct JudgeDef {
106 pub model: String,
108 #[serde(default = "default_judge_threshold")]
110 pub threshold: f64,
111 #[serde(default)]
113 pub rubric: Option<String>,
114}
115
116fn default_gate_timeout_ms() -> u64 {
119 30_000
120}
121
122fn default_judge_threshold() -> f64 {
124 0.7
125}
126
127#[derive(Debug, Clone, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct Route {
131 #[serde(rename = "match", default)]
133 pub match_: Match,
134 pub mode: Mode,
136 #[serde(default)]
138 pub ladder: Vec<String>,
139 #[serde(default)]
141 pub gates: Vec<String>,
142 #[serde(default)]
145 pub deferred_gates: Vec<String>,
146}
147
148#[derive(Debug, Clone, Default, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct Match {
153 #[serde(default)]
155 pub agent: Option<String>,
156 #[serde(default)]
158 pub subagent: Option<StringOrVec>,
159 #[serde(default)]
161 pub task_kind: Option<TaskKind>,
162 #[serde(default)]
164 pub language: Option<StringOrVec>,
165}
166
167impl Match {
168 #[must_use]
170 pub fn matches(&self, f: &Features) -> bool {
171 if let Some(agent) = &self.agent
172 && f.agent.as_deref() != Some(agent.as_str())
173 {
174 return false;
175 }
176 if let Some(subs) = &self.subagent {
177 match &f.subagent {
178 Some(s) if subs.contains(s) => {}
179 _ => return false,
180 }
181 }
182 if let Some(tk) = self.task_kind
183 && f.task_kind != tk
184 {
185 return false;
186 }
187 if let Some(langs) = &self.language {
188 match &f.language {
189 Some(l) if langs.contains(l) => {}
190 _ => return false,
191 }
192 }
193 true
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(untagged)]
200pub enum StringOrVec {
201 One(String),
203 Many(Vec<String>),
205}
206
207impl StringOrVec {
208 #[must_use]
210 pub fn contains(&self, needle: &str) -> bool {
211 match self {
212 StringOrVec::One(s) => s == needle,
213 StringOrVec::Many(v) => v.iter().any(|s| s == needle),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum OnExhausted {
222 #[default]
224 ServeBestAttempt,
225 Error,
227}
228
229#[derive(Debug, Clone, Default, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct Budget {
233 #[serde(default)]
235 pub per_request_usd: Option<f64>,
236 #[serde(default)]
238 pub per_session_usd: Option<f64>,
239 #[serde(default)]
241 pub per_day_usd: Option<f64>,
242 #[serde(default)]
244 pub on_exhausted: OnExhausted,
245}
246
247#[derive(Debug, Clone, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Escalation {
251 #[serde(default = "default_max_rungs")]
253 pub max_rungs_per_request: u32,
254 #[serde(default)]
256 pub session_promotion: Option<SessionPromotion>,
257 #[serde(default)]
260 pub speculation: u32,
261 #[serde(default)]
265 pub serve_threshold: Option<f64>,
266 #[serde(default)]
270 pub adaptive: Option<AdaptiveConfig>,
271 #[serde(default)]
276 pub enforce_structured: bool,
277}
278
279#[derive(Debug, Clone, Deserialize)]
281#[serde(deny_unknown_fields)]
282pub struct AdaptiveConfig {
283 pub alpha: f64,
285 #[serde(default = "default_adaptive_gamma")]
287 pub gamma: f64,
288}
289
290fn default_adaptive_gamma() -> f64 {
291 0.02
292}
293
294const fn default_max_rungs() -> u32 {
295 3
296}
297
298impl Default for Escalation {
299 fn default() -> Self {
300 Self {
301 max_rungs_per_request: default_max_rungs(),
302 session_promotion: None,
303 speculation: 0,
304 serve_threshold: None,
305 adaptive: None,
306 enforce_structured: false,
307 }
308 }
309}
310
311#[derive(Debug, Clone, Deserialize)]
313#[serde(deny_unknown_fields)]
314pub struct SessionPromotion {
315 pub after_failures: u32,
317 pub window: String,
319}
320
321impl SessionPromotion {
322 pub fn window_duration(&self) -> Result<Duration> {
327 parse_window(&self.window)
328 }
329}
330
331fn parse_window(s: &str) -> Result<Duration> {
333 let s = s.trim();
334 let split = s
335 .find(|c: char| c.is_ascii_alphabetic())
336 .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
337 let (num, unit) = s.split_at(split);
338 let n: u64 = num
339 .trim()
340 .parse()
341 .map_err(|_| Error::BadDuration(s.to_owned()))?;
342 let secs = match unit {
343 "s" => n,
344 "m" => n.saturating_mul(60),
345 "h" => n.saturating_mul(3600),
346 "d" => n.saturating_mul(86_400),
347 _ => return Err(Error::BadDuration(s.to_owned())),
348 };
349 Ok(Duration::from_secs(secs))
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct ModelRef {
355 pub provider: String,
357 pub model: String,
359}
360
361impl ModelRef {
362 pub fn parse(s: &str) -> Result<Self> {
367 match s.split_once('/') {
368 Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
369 provider: p.to_owned(),
370 model: m.to_owned(),
371 }),
372 _ => Err(Error::BadModelRef(s.to_owned())),
373 }
374 }
375}
376
377impl Config {
378 pub fn parse(toml_str: &str) -> Result<Self> {
385 let config: Self = toml::from_str(toml_str)?;
386 let mut seen = std::collections::HashSet::new();
387 for def in &config.gate_defs {
388 if def.id.trim().is_empty() {
389 return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
390 }
391 if def.cmd.is_empty() == def.judge.is_none() {
393 return Err(Error::InvalidConfig(format!(
394 "gate {:?} must set exactly one of `cmd` or `judge`",
395 def.id
396 )));
397 }
398 if let Some(judge) = &def.judge
399 && !(0.0..=1.0).contains(&judge.threshold)
400 {
401 return Err(Error::InvalidConfig(format!(
402 "gate {:?} judge threshold {} is outside [0, 1]",
403 def.id, judge.threshold
404 )));
405 }
406 if !seen.insert(def.id.as_str()) {
407 return Err(Error::InvalidConfig(format!(
408 "duplicate gate id {:?}",
409 def.id
410 )));
411 }
412 }
413 Ok(config)
414 }
415
416 #[must_use]
418 pub fn route_for(&self, f: &Features) -> Option<&Route> {
419 self.routes.iter().find(|r| r.match_.matches(f))
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::features::Features;
427
428 const SPEC_CONFIG: &str = r#"
429[[route]]
430match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
431mode = "enforce"
432ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
433gates = ["schema", "judge-diff"]
434
435[[route]]
436match = { task_kind = "code_edit" }
437mode = "enforce"
438ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
439gates = ["patch-applies", "lint-diff", "judge-diff"]
440deferred_gates = ["compiles", "tests"]
441
442[[route]]
443match = {}
444mode = "observe"
445ladder = ["anthropic/claude-opus-4-8"]
446
447[budget]
448per_request_usd = 0.50
449per_session_usd = 10.00
450per_day_usd = 250.00
451on_exhausted = "serve_best_attempt"
452
453[escalation]
454max_rungs_per_request = 3
455session_promotion = { after_failures = 3, window = "30m" }
456"#;
457
458 #[test]
459 fn parses_the_spec_example() {
460 let c = Config::parse(SPEC_CONFIG).unwrap();
461 assert_eq!(c.routes.len(), 3);
462 assert_eq!(c.routes[0].mode, Mode::Enforce);
463 assert_eq!(
464 c.routes[0].ladder,
465 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
466 );
467 assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
468 assert_eq!(c.budget.per_request_usd, Some(0.50));
469 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
470 assert_eq!(c.escalation.max_rungs_per_request, 3);
471 let sp = c.escalation.session_promotion.as_ref().unwrap();
472 assert_eq!(sp.after_failures, 3);
473 assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
474 }
475
476 #[test]
477 fn first_matching_route_wins() {
478 let c = Config::parse(SPEC_CONFIG).unwrap();
479
480 let mut f = Features::new(TaskKind::Explore);
482 f.agent = Some("claude-code".into());
483 f.subagent = Some("test-runner".into());
484 let r = c.route_for(&f).unwrap();
485 assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
486
487 let f2 = Features::new(TaskKind::CodeEdit);
489 let r2 = c.route_for(&f2).unwrap();
490 assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
491
492 let f3 = Features::new(TaskKind::Chat);
494 let r3 = c.route_for(&f3).unwrap();
495 assert_eq!(r3.mode, Mode::Observe);
496 }
497
498 #[test]
499 fn shipped_example_config_parses() {
500 let toml = include_str!("../../../firstpass.example.toml");
503 let c = Config::parse(toml).expect("firstpass.example.toml must parse");
504 assert_eq!(c.routes.len(), 3);
505 assert_eq!(c.routes[0].mode, Mode::Enforce);
506 }
507
508 #[test]
509 fn parses_gate_definitions() {
510 let toml = r#"
511[[route]]
512match = {}
513mode = "enforce"
514ladder = ["anthropic/claude-haiku-4-5"]
515gates = ["my-tests"]
516
517[[gate]]
518id = "my-tests"
519cmd = ["pytest", "-q"]
520
521[[gate]]
522id = "judge"
523cmd = ["bash", "-c", "./judge.sh"]
524timeout_ms = 60000
525"#;
526 let c = Config::parse(toml).unwrap();
527 assert_eq!(c.gate_defs.len(), 2);
528 assert_eq!(c.gate_defs[0].id, "my-tests");
529 assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
530 assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
531 assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
532 }
533
534 #[test]
535 fn rejects_invalid_gate_definitions() {
536 let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
537 assert!(matches!(
538 Config::parse(empty_cmd),
539 Err(Error::InvalidConfig(_))
540 ));
541
542 let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
543 assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
544 }
545
546 #[test]
547 fn parses_adaptive_conformal_config() {
548 let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
549 let a = c
550 .escalation
551 .adaptive
552 .expect("[escalation.adaptive] should parse");
553 assert!((a.alpha - 0.1).abs() < 1e-9);
554 assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
555 assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
557 }
558
559 #[test]
560 fn parses_provider_entries_and_ladders_can_reference_them() {
561 let c = Config::parse(
562 r#"
563[[provider]]
564id = "groq"
565dialect = "openai"
566base_url = "https://api.groq.com/openai"
567api_key_env = "GROQ_API_KEY"
568
569[[provider]]
570id = "ollama"
571dialect = "openai"
572base_url = "http://localhost:11434"
573
574[[route]]
575match = {}
576mode = "enforce"
577ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
578"#,
579 )
580 .unwrap();
581 assert_eq!(c.providers.len(), 2);
582 let groq = &c.providers[0];
583 assert_eq!(groq.id, "groq");
584 assert_eq!(groq.dialect, Dialect::Openai);
585 assert_eq!(groq.base_url, "https://api.groq.com/openai");
586 assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
587 assert_eq!(c.providers[1].id, "ollama");
589 assert!(c.providers[1].api_key_env.is_none());
590 assert!(Config::parse("").unwrap().providers.is_empty());
592 }
593
594 #[test]
595 fn empty_match_is_wildcard() {
596 let m = Match::default();
597 assert!(m.matches(&Features::new(TaskKind::Other)));
598 }
599
600 #[test]
601 fn subagent_list_membership() {
602 let c = Config::parse(SPEC_CONFIG).unwrap();
603 let route0 = &c.routes[0];
604 let mut f = Features::new(TaskKind::Other);
605 f.agent = Some("claude-code".into());
606 f.subagent = Some("docs-writer".into()); assert!(!route0.match_.matches(&f));
608 f.subagent = Some("explore".into());
609 assert!(route0.match_.matches(&f));
610 }
611
612 #[test]
613 fn model_ref_parsing() {
614 let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
615 assert_eq!(m.provider, "anthropic");
616 assert_eq!(m.model, "claude-haiku-4-5");
617 assert!(ModelRef::parse("no-slash").is_err());
618 assert!(ModelRef::parse("/model").is_err());
619 assert!(ModelRef::parse("a/b/c").is_err());
620 }
621
622 #[test]
623 fn window_parsing_units_and_errors() {
624 assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
625 assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
626 assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
627 assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
628 assert!(parse_window("30x").is_err());
629 assert!(parse_window("abc").is_err());
630 }
631
632 #[test]
633 fn empty_config_defaults() {
634 let c = Config::parse("").unwrap();
635 assert!(c.routes.is_empty());
636 assert_eq!(c.escalation.max_rungs_per_request, 3);
637 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
638 }
639}