use crate::error::{Error, Result};
use crate::features::{Features, TaskKind};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Enforce,
Observe,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(rename = "route", default)]
pub routes: Vec<Route>,
#[serde(default)]
pub budget: Budget,
#[serde(default)]
pub escalation: Escalation,
#[serde(rename = "gate", default)]
pub gate_defs: Vec<GateDef>,
#[serde(rename = "provider", default)]
pub providers: Vec<ProviderDef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dialect {
Anthropic,
Openai,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthScheme {
#[default]
ApiKey,
AwsSigv4,
GcpOauth,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderDef {
pub id: String,
pub dialect: Dialect,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default)]
pub auth: AuthScheme,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub project: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GateDef {
pub id: String,
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default = "default_gate_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub judge: Option<JudgeDef>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JudgeDef {
pub model: String,
#[serde(default = "default_judge_threshold")]
pub threshold: f64,
#[serde(default)]
pub rubric: Option<String>,
}
fn default_gate_timeout_ms() -> u64 {
30_000
}
fn default_judge_threshold() -> f64 {
0.7
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Route {
#[serde(rename = "match", default)]
pub match_: Match,
pub mode: Mode,
#[serde(default)]
pub ladder: Vec<String>,
#[serde(default)]
pub gates: Vec<String>,
#[serde(default)]
pub deferred_gates: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Match {
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub subagent: Option<StringOrVec>,
#[serde(default)]
pub task_kind: Option<TaskKind>,
#[serde(default)]
pub language: Option<StringOrVec>,
}
impl Match {
#[must_use]
pub fn matches(&self, f: &Features) -> bool {
if let Some(agent) = &self.agent
&& f.agent.as_deref() != Some(agent.as_str())
{
return false;
}
if let Some(subs) = &self.subagent {
match &f.subagent {
Some(s) if subs.contains(s) => {}
_ => return false,
}
}
if let Some(tk) = self.task_kind
&& f.task_kind != tk
{
return false;
}
if let Some(langs) = &self.language {
match &f.language {
Some(l) if langs.contains(l) => {}
_ => return false,
}
}
true
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrVec {
One(String),
Many(Vec<String>),
}
impl StringOrVec {
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
match self {
StringOrVec::One(s) => s == needle,
StringOrVec::Many(v) => v.iter().any(|s| s == needle),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnExhausted {
#[default]
ServeBestAttempt,
Error,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Budget {
#[serde(default)]
pub per_request_usd: Option<f64>,
#[serde(default)]
pub per_session_usd: Option<f64>,
#[serde(default)]
pub per_day_usd: Option<f64>,
#[serde(default)]
pub on_exhausted: OnExhausted,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Escalation {
#[serde(default = "default_max_rungs")]
pub max_rungs_per_request: u32,
#[serde(default)]
pub session_promotion: Option<SessionPromotion>,
#[serde(default)]
pub speculation: u32,
#[serde(default)]
pub serve_threshold: Option<f64>,
#[serde(default)]
pub adaptive: Option<AdaptiveConfig>,
#[serde(default)]
pub enforce_structured: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdaptiveConfig {
pub alpha: f64,
#[serde(default = "default_adaptive_gamma")]
pub gamma: f64,
}
fn default_adaptive_gamma() -> f64 {
0.02
}
const fn default_max_rungs() -> u32 {
3
}
impl Default for Escalation {
fn default() -> Self {
Self {
max_rungs_per_request: default_max_rungs(),
session_promotion: None,
speculation: 0,
serve_threshold: None,
adaptive: None,
enforce_structured: false,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionPromotion {
pub after_failures: u32,
pub window: String,
}
impl SessionPromotion {
pub fn window_duration(&self) -> Result<Duration> {
parse_window(&self.window)
}
}
fn parse_window(s: &str) -> Result<Duration> {
let s = s.trim();
let split = s
.find(|c: char| c.is_ascii_alphabetic())
.ok_or_else(|| Error::BadDuration(s.to_owned()))?;
let (num, unit) = s.split_at(split);
let n: u64 = num
.trim()
.parse()
.map_err(|_| Error::BadDuration(s.to_owned()))?;
let secs = match unit {
"s" => n,
"m" => n.saturating_mul(60),
"h" => n.saturating_mul(3600),
"d" => n.saturating_mul(86_400),
_ => return Err(Error::BadDuration(s.to_owned())),
};
Ok(Duration::from_secs(secs))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRef {
pub provider: String,
pub model: String,
}
impl ModelRef {
pub fn parse(s: &str) -> Result<Self> {
match s.split_once('/') {
Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
provider: p.to_owned(),
model: m.to_owned(),
}),
_ => Err(Error::BadModelRef(s.to_owned())),
}
}
}
impl Config {
pub fn parse(toml_str: &str) -> Result<Self> {
let config: Self = toml::from_str(toml_str)?;
let mut seen = std::collections::HashSet::new();
for def in &config.gate_defs {
if def.id.trim().is_empty() {
return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
}
if def.cmd.is_empty() == def.judge.is_none() {
return Err(Error::InvalidConfig(format!(
"gate {:?} must set exactly one of `cmd` or `judge`",
def.id
)));
}
if let Some(judge) = &def.judge
&& !(0.0..=1.0).contains(&judge.threshold)
{
return Err(Error::InvalidConfig(format!(
"gate {:?} judge threshold {} is outside [0, 1]",
def.id, judge.threshold
)));
}
if !seen.insert(def.id.as_str()) {
return Err(Error::InvalidConfig(format!(
"duplicate gate id {:?}",
def.id
)));
}
}
Ok(config)
}
#[must_use]
pub fn route_for(&self, f: &Features) -> Option<&Route> {
self.routes.iter().find(|r| r.match_.matches(f))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::features::Features;
const SPEC_CONFIG: &str = r#"
[[route]]
match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
gates = ["schema", "judge-diff"]
[[route]]
match = { task_kind = "code_edit" }
mode = "enforce"
ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
gates = ["patch-applies", "lint-diff", "judge-diff"]
deferred_gates = ["compiles", "tests"]
[[route]]
match = {}
mode = "observe"
ladder = ["anthropic/claude-opus-4-8"]
[budget]
per_request_usd = 0.50
per_session_usd = 10.00
per_day_usd = 250.00
on_exhausted = "serve_best_attempt"
[escalation]
max_rungs_per_request = 3
session_promotion = { after_failures = 3, window = "30m" }
"#;
#[test]
fn parses_the_spec_example() {
let c = Config::parse(SPEC_CONFIG).unwrap();
assert_eq!(c.routes.len(), 3);
assert_eq!(c.routes[0].mode, Mode::Enforce);
assert_eq!(
c.routes[0].ladder,
["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
);
assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
assert_eq!(c.budget.per_request_usd, Some(0.50));
assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
assert_eq!(c.escalation.max_rungs_per_request, 3);
let sp = c.escalation.session_promotion.as_ref().unwrap();
assert_eq!(sp.after_failures, 3);
assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
}
#[test]
fn first_matching_route_wins() {
let c = Config::parse(SPEC_CONFIG).unwrap();
let mut f = Features::new(TaskKind::Explore);
f.agent = Some("claude-code".into());
f.subagent = Some("test-runner".into());
let r = c.route_for(&f).unwrap();
assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
let f2 = Features::new(TaskKind::CodeEdit);
let r2 = c.route_for(&f2).unwrap();
assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
let f3 = Features::new(TaskKind::Chat);
let r3 = c.route_for(&f3).unwrap();
assert_eq!(r3.mode, Mode::Observe);
}
#[test]
fn shipped_example_config_parses() {
let toml = include_str!("../../../firstpass.example.toml");
let c = Config::parse(toml).expect("firstpass.example.toml must parse");
assert_eq!(c.routes.len(), 3);
assert_eq!(c.routes[0].mode, Mode::Enforce);
}
#[test]
fn parses_gate_definitions() {
let toml = r#"
[[route]]
match = {}
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5"]
gates = ["my-tests"]
[[gate]]
id = "my-tests"
cmd = ["pytest", "-q"]
[[gate]]
id = "judge"
cmd = ["bash", "-c", "./judge.sh"]
timeout_ms = 60000
"#;
let c = Config::parse(toml).unwrap();
assert_eq!(c.gate_defs.len(), 2);
assert_eq!(c.gate_defs[0].id, "my-tests");
assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
}
#[test]
fn rejects_invalid_gate_definitions() {
let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
assert!(matches!(
Config::parse(empty_cmd),
Err(Error::InvalidConfig(_))
));
let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
}
#[test]
fn parses_adaptive_conformal_config() {
let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
let a = c
.escalation
.adaptive
.expect("[escalation.adaptive] should parse");
assert!((a.alpha - 0.1).abs() < 1e-9);
assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
}
#[test]
fn parses_provider_entries_and_ladders_can_reference_them() {
let c = Config::parse(
r#"
[[provider]]
id = "groq"
dialect = "openai"
base_url = "https://api.groq.com/openai"
api_key_env = "GROQ_API_KEY"
[[provider]]
id = "ollama"
dialect = "openai"
base_url = "http://localhost:11434"
[[route]]
match = {}
mode = "enforce"
ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
"#,
)
.unwrap();
assert_eq!(c.providers.len(), 2);
let groq = &c.providers[0];
assert_eq!(groq.id, "groq");
assert_eq!(groq.dialect, Dialect::Openai);
assert_eq!(groq.base_url, "https://api.groq.com/openai");
assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
assert_eq!(c.providers[1].id, "ollama");
assert!(c.providers[1].api_key_env.is_none());
assert!(Config::parse("").unwrap().providers.is_empty());
}
#[test]
fn parses_aws_sigv4_provider_and_defaults_auth_to_api_key() {
let c = Config::parse(
r#"
[[provider]]
id = "bedrock"
dialect = "anthropic"
auth = "aws_sigv4"
region = "us-east-1"
"#,
)
.unwrap();
let bedrock = &c.providers[0];
assert_eq!(bedrock.auth, AuthScheme::AwsSigv4);
assert_eq!(bedrock.region.as_deref(), Some("us-east-1"));
assert!(bedrock.project.is_none());
let c2 = Config::parse(
r#"
[[provider]]
id = "groq"
dialect = "openai"
base_url = "https://api.groq.com/openai"
"#,
)
.unwrap();
assert_eq!(c2.providers[0].auth, AuthScheme::ApiKey);
}
#[test]
fn empty_match_is_wildcard() {
let m = Match::default();
assert!(m.matches(&Features::new(TaskKind::Other)));
}
#[test]
fn subagent_list_membership() {
let c = Config::parse(SPEC_CONFIG).unwrap();
let route0 = &c.routes[0];
let mut f = Features::new(TaskKind::Other);
f.agent = Some("claude-code".into());
f.subagent = Some("docs-writer".into()); assert!(!route0.match_.matches(&f));
f.subagent = Some("explore".into());
assert!(route0.match_.matches(&f));
}
#[test]
fn model_ref_parsing() {
let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
assert_eq!(m.provider, "anthropic");
assert_eq!(m.model, "claude-haiku-4-5");
assert!(ModelRef::parse("no-slash").is_err());
assert!(ModelRef::parse("/model").is_err());
assert!(ModelRef::parse("a/b/c").is_err());
}
#[test]
fn window_parsing_units_and_errors() {
assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
assert!(parse_window("30x").is_err());
assert!(parse_window("abc").is_err());
}
#[test]
fn empty_config_defaults() {
let c = Config::parse("").unwrap();
assert!(c.routes.is_empty());
assert_eq!(c.escalation.max_rungs_per_request, 3);
assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
}
}