Skip to main content

firstpass_core/
config.rs

1//! Routing configuration (SPEC §8.4) — declarative, agent-first.
2//!
3//! Config is the policy an operator (or agent) hands Firstpass: an ordered list of routes,
4//! each matching a slice of traffic to a mode, a model ladder, and gates; plus budget caps
5//! and escalation rules. The first matching route wins, so specific routes go first and a
6//! bare `match = {}` catch-all goes last.
7
8use crate::error::{Error, Result};
9use crate::features::{Features, TaskKind};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13/// Serving mode for a route (SPEC glossary).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Mode {
17    /// Gate before serving; escalate on fail. The output is proven before the caller sees it.
18    Enforce,
19    /// Serve immediately, gate asynchronously — verdicts feed learning only. Zero added latency,
20    /// zero risk; the default for onboarding unfamiliar traffic.
21    Observe,
22}
23
24/// Top-level configuration document.
25#[derive(Debug, Clone, Default, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct Config {
28    /// Ordered routes; first match wins.
29    #[serde(rename = "route", default)]
30    pub routes: Vec<Route>,
31    /// Spend caps.
32    #[serde(default)]
33    pub budget: Budget,
34    /// Escalation limits and promotion rules.
35    #[serde(default)]
36    pub escalation: Escalation,
37    /// User-defined subprocess gates (SPEC §8.1), referenced by `id` from a route's `gates` /
38    /// `deferred_gates`. Declared as `[[gate]]` sections in TOML.
39    #[serde(rename = "gate", default)]
40    pub gate_defs: Vec<GateDef>,
41    /// Extra model providers a ladder can route to, beyond the built-in `anthropic` / `openai`.
42    /// Any OpenAI-compatible endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI,
43    /// OpenRouter, Ollama, vLLM, Azure, …) is one `[[provider]]` entry — no rebuild. Declared as
44    /// `[[provider]]` sections; a ladder rung is then `<id>/<model>`.
45    #[serde(rename = "provider", default)]
46    pub providers: Vec<ProviderDef>,
47}
48
49/// The wire API a provider speaks. `anthropic` = Messages API; `openai` = Chat Completions API
50/// (the de-facto standard that nearly every hosted and open-source model host implements);
51/// `gemini` = Google's Generative Language API (`generateContent`).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Dialect {
55    /// Anthropic Messages API (`POST /v1/messages`).
56    Anthropic,
57    /// OpenAI Chat Completions API (`POST /v1/chat/completions`).
58    Openai,
59    /// Google Gemini Generative Language API (`POST /v1beta/models/<model>:generateContent`),
60    /// authenticated with an API key in the `x-goog-api-key` header.
61    Gemini,
62}
63
64/// How a provider call is credentialed — orthogonal to [`Dialect`] (ADR 0006): dialect shapes the
65/// request/response body, auth scheme shapes how the request is signed/credentialed. Default
66/// (`api_key_env` / BYOK header) is unchanged for every existing `[[provider]]` entry.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum AuthScheme {
70    /// API key in a header (`x-api-key`, `Authorization: Bearer`, or `x-goog-api-key`) — today's
71    /// behavior for `anthropic` / `openai` / `gemini`.
72    #[default]
73    ApiKey,
74    /// AWS SigV4 request signing (Bedrock) — credentials from `AWS_ACCESS_KEY_ID` /
75    /// `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, region-scoped.
76    AwsSigv4,
77    /// GCP OAuth2 bearer token (Vertex AI) — minted and cached by `gcp_auth` from
78    /// `GOOGLE_APPLICATION_CREDENTIALS` or the ambient environment.
79    GcpOauth,
80}
81
82/// A model provider a ladder can route to. Declared as `[[provider]]` in TOML; referenced from a
83/// ladder as `<id>/<model>` (e.g. `groq/llama-3.3-70b-versatile`).
84#[derive(Debug, Clone, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct ProviderDef {
87    /// Ladder prefix for this provider, e.g. `"groq"`. Overrides a built-in of the same id.
88    pub id: String,
89    /// Which wire API it speaks.
90    pub dialect: Dialect,
91    /// Base URL, e.g. `"https://api.groq.com/openai"` or `"http://localhost:11434"` for Ollama.
92    /// Unused for `aws_sigv4/gcp_oauth` auth (those construct the URL from `region`/`project`).
93    #[serde(default)]
94    pub base_url: String,
95    /// Env var the API key is read from at call time, e.g. `"GROQ_API_KEY"`. Omit for a keyless
96    /// endpoint (local Ollama / vLLM). Per-request BYOK headers still apply to the built-in
97    /// `anthropic` / `openai` providers.
98    #[serde(default)]
99    pub api_key_env: Option<String>,
100    /// How this provider's requests are credentialed. `api_key` (default) is byte-identical to
101    /// today; `aws_sigv4` / `gcp_oauth` are the Bedrock/Vertex auth schemes (ADR 0006).
102    #[serde(default)]
103    pub auth: AuthScheme,
104    /// Cloud region, e.g. `"us-east-1"` — required for `aws_sigv4` (Bedrock) and `gcp_oauth`
105    /// (Vertex).
106    #[serde(default)]
107    pub region: Option<String>,
108    /// GCP project id — required for `gcp_oauth` (Vertex).
109    #[serde(default)]
110    pub project: Option<String>,
111}
112
113/// A user-defined gate (SPEC §8.1). Exactly one kind per definition:
114/// - **subprocess** (`cmd`): any executable that reads the candidate as JSON on **stdin** (never
115///   argv — injection-resistant) and emits `{"verdict":"pass|fail|abstain", ...}` on stdout.
116/// - **judge** (`judge`): a native LLM-judge gate that grades the candidate against a rubric.
117#[derive(Debug, Clone, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct GateDef {
120    /// The id a route references this gate by (must be unique and not shadow a built-in gate id).
121    pub id: String,
122    /// Subprocess command: program first, then its args — e.g. `["pytest", "-q"]`. Set this **or**
123    /// `judge`, not both.
124    #[serde(default)]
125    pub cmd: Vec<String>,
126    /// Hard timeout in milliseconds for a subprocess gate; it abstains (`timeout`) if the process
127    /// runs longer.
128    #[serde(default = "default_gate_timeout_ms")]
129    pub timeout_ms: u64,
130    /// LLM-judge configuration. Set this **or** `cmd`, not both.
131    #[serde(default)]
132    pub judge: Option<JudgeDef>,
133}
134
135/// Configuration for a native LLM-judge gate (SPEC §8.3): a separate model grades the candidate
136/// against a rubric. The runner enforces maker ≠ checker (a model never grades its own output) and
137/// treats the candidate as data, not instructions.
138#[derive(Debug, Clone, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct JudgeDef {
141    /// The judge model, as `provider/model` (must differ from the candidate's model at runtime).
142    pub model: String,
143    /// Pass iff the judge's score ≥ this threshold, in `[0, 1]`.
144    #[serde(default = "default_judge_threshold")]
145    pub threshold: f64,
146    /// What "good" means for this route — handed to the judge as the grading rubric.
147    #[serde(default)]
148    pub rubric: Option<String>,
149}
150
151/// Default subprocess-gate timeout: 30s. Long enough for a test suite, short enough to bound the
152/// enforce-path tail.
153fn default_gate_timeout_ms() -> u64 {
154    30_000
155}
156
157/// Default judge pass threshold.
158fn default_judge_threshold() -> f64 {
159    0.7
160}
161
162/// One routing rule.
163#[derive(Debug, Clone, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct Route {
166    /// The traffic this route claims. An empty match (`{}`) matches everything.
167    #[serde(rename = "match", default)]
168    pub match_: Match,
169    /// Serving mode.
170    pub mode: Mode,
171    /// Model ladder, cheapest first, as `provider/model` strings.
172    #[serde(default)]
173    pub ladder: Vec<String>,
174    /// Inline gates run before serving (enforce mode).
175    #[serde(default)]
176    pub gates: Vec<String>,
177    /// Gates run asynchronously after serving; their verdicts attach to the trace as a
178    /// learning signal and never block the response.
179    #[serde(default)]
180    pub deferred_gates: Vec<String>,
181}
182
183/// Predicate over a request's [`Features`]. Every present field is an AND-constraint; absent
184/// fields are wildcards.
185#[derive(Debug, Clone, Default, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Match {
188    /// Require a specific calling agent.
189    #[serde(default)]
190    pub agent: Option<String>,
191    /// Require the subagent to be one of these (or this one, if a bare string).
192    #[serde(default)]
193    pub subagent: Option<StringOrVec>,
194    /// Require a specific task kind.
195    #[serde(default)]
196    pub task_kind: Option<TaskKind>,
197    /// Require the language to be one of these (or this one, if a bare string).
198    #[serde(default)]
199    pub language: Option<StringOrVec>,
200}
201
202impl Match {
203    /// Whether `f` satisfies every present constraint.
204    #[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/// A field that accepts either a single string or a list of strings in TOML/JSON.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(untagged)]
235pub enum StringOrVec {
236    /// A single value.
237    One(String),
238    /// A set of values.
239    Many(Vec<String>),
240}
241
242impl StringOrVec {
243    /// Whether `needle` is contained in this value.
244    #[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/// What to do when a spend cap is hit (SPEC §8.4 — never brick the customer).
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum OnExhausted {
257    /// Serve the best attempt seen so far (default).
258    #[default]
259    ServeBestAttempt,
260    /// Return a structured error instead of serving.
261    Error,
262}
263
264/// Spend caps. `None` means "no cap".
265#[derive(Debug, Clone, Default, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct Budget {
268    /// Max USD per single request (across all rungs + gates).
269    #[serde(default)]
270    pub per_request_usd: Option<f64>,
271    /// Max USD per session.
272    #[serde(default)]
273    pub per_session_usd: Option<f64>,
274    /// Max USD per day.
275    #[serde(default)]
276    pub per_day_usd: Option<f64>,
277    /// Behaviour when a cap is reached.
278    #[serde(default)]
279    pub on_exhausted: OnExhausted,
280}
281
282/// Escalation limits and session-level promotion.
283#[derive(Debug, Clone, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct Escalation {
286    /// Hard ceiling on rungs climbed within one request.
287    #[serde(default = "default_max_rungs")]
288    pub max_rungs_per_request: u32,
289    /// Optionally start higher for the rest of a session that keeps failing.
290    #[serde(default)]
291    pub session_promotion: Option<SessionPromotion>,
292    /// Prefetch depth: fire this many rungs ahead concurrently to trade wasted spend for latency.
293    /// `0` (default) is serial — one call at a time. The served result is identical either way.
294    #[serde(default)]
295    pub speculation: u32,
296    /// Calibrated conformal serve threshold: a rung is served iff its aggregate gate score is
297    /// `>=` this value (SPEC §10.1). `None` (default) keeps the original rule — serve iff the
298    /// aggregate gate verdict is `Pass` — byte-identical to today.
299    #[serde(default)]
300    pub serve_threshold: Option<f64>,
301    /// Online/adaptive conformal (Gibbs-Candès ACI): when set, the serve threshold is tracked
302    /// **live** from deferred feedback instead of held fixed, so served-failure stays at target under
303    /// distribution shift. `None` (default) uses the fixed `serve_threshold` above — byte-identical.
304    #[serde(default)]
305    pub adaptive: Option<AdaptiveConfig>,
306    /// Opt-in: route tool-calling / multimodal requests through enforce instead of falling back to
307    /// observe (ADR 0005). `false` (default) is byte-identical to today — such requests pass through
308    /// un-gated. Turn on **only after** verifying enforce faithfully round-trips your tool workload;
309    /// content fidelity is preserved either way, but escalating a live tool turn is operator-gated.
310    #[serde(default)]
311    pub enforce_structured: bool,
312}
313
314/// Config for online/adaptive conformal serving ([`crate::conformal::AdaptiveConformal`]).
315#[derive(Debug, Clone, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct AdaptiveConfig {
318    /// Target served-failure rate the online loop holds to.
319    pub alpha: f64,
320    /// Step size (default 0.02) — larger tracks shift faster but noisier.
321    #[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/// "After N failures within a window, promote this session's starting rung."
347#[derive(Debug, Clone, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct SessionPromotion {
350    /// Failure count that triggers promotion.
351    pub after_failures: u32,
352    /// Sliding window, e.g. `"30m"`.
353    pub window: String,
354}
355
356impl SessionPromotion {
357    /// Parse [`SessionPromotion::window`] into a [`Duration`].
358    ///
359    /// # Errors
360    /// Returns [`Error::BadDuration`] if the window is not `<int><unit>` with unit in `s`/`m`/`h`/`d`.
361    pub fn window_duration(&self) -> Result<Duration> {
362        parse_window(&self.window)
363    }
364}
365
366/// Parse a compact duration like `30m`, `2h`, `90s`, `1d`.
367fn 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/// A parsed `provider/model` reference.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct ModelRef {
390    /// Provider segment, e.g. `anthropic`.
391    pub provider: String,
392    /// Model segment, e.g. `claude-haiku-4-5`.
393    pub model: String,
394}
395
396impl ModelRef {
397    /// Parse a `provider/model` string.
398    ///
399    /// # Errors
400    /// Returns [`Error::BadModelRef`] if the input is not exactly one `provider/model` pair.
401    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    /// Parse a TOML configuration document.
414    ///
415    /// # Errors
416    /// Returns [`Error::Config`] on invalid TOML or unknown fields, or [`Error::InvalidConfig`] on
417    /// an invalid gate definition (not exactly one of `cmd`/`judge`, a judge threshold outside
418    /// `[0,1]`, or a duplicate/blank `id`).
419    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            // Exactly one kind: a subprocess `cmd` or a `judge`, never both, never neither.
427            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    /// The first route whose match claims `f`, or `None` if no route matches.
452    #[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        // claude-code / test-runner -> route 0
516        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        // a code_edit with no agent -> route 1 (route 0's agent constraint fails)
523        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        // anything else -> catch-all observe route
528        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        // The repo ships `firstpass.example.toml` for users to copy. If it drifts
536        // from the schema, this fails in CI rather than at a user's first run.
537        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        // Absent => None (fixed-threshold serving, default byte-identical behavior).
591        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        // A keyless local endpoint (Ollama) parses with no api_key_env.
623        assert_eq!(c.providers[1].id, "ollama");
624        assert!(c.providers[1].api_key_env.is_none());
625        // Absent => no extra providers (built-ins only).
626        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        // Omitting `auth` on an existing provider entry defaults to ApiKey (I1: no behavior
647        // change for today's `[[provider]]` entries).
648        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 empty_match_is_wildcard() {
662        let m = Match::default();
663        assert!(m.matches(&Features::new(TaskKind::Other)));
664    }
665
666    #[test]
667    fn subagent_list_membership() {
668        let c = Config::parse(SPEC_CONFIG).unwrap();
669        let route0 = &c.routes[0];
670        let mut f = Features::new(TaskKind::Other);
671        f.agent = Some("claude-code".into());
672        f.subagent = Some("docs-writer".into()); // not in [test-runner, explore]
673        assert!(!route0.match_.matches(&f));
674        f.subagent = Some("explore".into());
675        assert!(route0.match_.matches(&f));
676    }
677
678    #[test]
679    fn model_ref_parsing() {
680        let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
681        assert_eq!(m.provider, "anthropic");
682        assert_eq!(m.model, "claude-haiku-4-5");
683        assert!(ModelRef::parse("no-slash").is_err());
684        assert!(ModelRef::parse("/model").is_err());
685        assert!(ModelRef::parse("a/b/c").is_err());
686    }
687
688    #[test]
689    fn window_parsing_units_and_errors() {
690        assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
691        assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
692        assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
693        assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
694        assert!(parse_window("30x").is_err());
695        assert!(parse_window("abc").is_err());
696    }
697
698    #[test]
699    fn empty_config_defaults() {
700        let c = Config::parse("").unwrap();
701        assert!(c.routes.is_empty());
702        assert_eq!(c.escalation.max_rungs_per_request, 3);
703        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
704    }
705}