Skip to main content

drep/
config.rs

1//! TOML configuration.
2//!
3//! One file per repository, conventionally `drep.toml` at the root. The shape
4//! is deliberate: every field has a documented default, so a partial file
5//! works. Missing keys are not an error - the section just inherits the
6//! default. Providers are declared as `[[llm]]`, an ordered array of tables:
7//! a preference order, tried head first, each enabled entry a fallback for the
8//! one before it.
9//!
10//! The two things this module owns that are not obvious from the field list:
11//!
12//! - **`${VAR}` expansion.** An api key (or any string value) can name an
13//!   environment variable instead of holding the secret. The file gets
14//!   committed; the secret does not. An unset variable is an error rather
15//!   than an empty string, because a silent empty credential produces a
16//!   confusing 401 instead of a clear "GITHUB_TOKEN is not set".
17//! - **`max_tokens` defaults to None**, meaning no cap is sent to the model.
18//!   Modern reasoning models ship 256k-1M context, and inventing a ceiling
19//!   truncates them mid-thought. The option stays available for capping
20//!   spend.
21
22use std::env;
23use std::path::{Path, PathBuf};
24
25use open_agent::ApiProtocol;
26use serde::Deserialize;
27use thiserror::Error;
28use toml::Value;
29
30mod backend;
31pub use backend::{BackendKind, LlmConfig, ReasoningEffort};
32
33/// The whole configuration tree, rooted at the file.
34///
35/// `llm` is an **array of tables** (`[[llm]]`), not a single `[llm]` section,
36/// and it is that shape from the day `drep init` first wrote one - a phase
37/// before failover could read it, precisely so the file format would not have
38/// to change underneath a file drep itself wrote. The list is a *preference
39/// order*: [`Self::providers`] is the failover chain.
40///
41/// `#[serde(default)]` means a file with an empty body deserializes
42/// successfully; `validate` is what then rejects it, because a config
43/// declaring no provider cannot run the mandatory LLM layer.
44#[derive(Debug, Default, Deserialize)]
45#[serde(default)]
46pub struct Config {
47    pub llm: Vec<LlmConfig>,
48}
49
50impl Config {
51    /// The failover chain: every enabled provider, in file order.
52    ///
53    /// The single definition of "which providers are in play". `enabled` is
54    /// an opt-*out*, so a disabled entry is skipped wherever it sits - a
55    /// disabled head falls through to the entry below it rather than
56    /// producing `NotConfigured`, which is what parking the local model was
57    /// always meant to do.
58    ///
59    /// A `Vec` rather than an iterator because every caller wants a length or
60    /// an index (the chain numbers its providers in error messages) and the
61    /// list is at most a handful of entries.
62    pub fn providers(&self) -> Vec<&LlmConfig> {
63        self.llm.iter().filter(|p| p.enabled).collect()
64    }
65}
66
67/// What went wrong reading or validating the configuration.
68#[derive(Debug, Error)]
69pub enum ConfigError {
70    #[error("could not read {0}: {1}")]
71    Io(PathBuf, std::io::Error),
72
73    #[error("could not parse {0}: {1}")]
74    Parse(PathBuf, String),
75
76    #[error("environment variable `{0}` is not set (referenced by `{1}`)")]
77    EnvVarUnset(String, String),
78
79    #[error("environment variable `{0}` is not valid UTF-8 (referenced by `{1}`)")]
80    EnvVarNotUnicode(String, String),
81
82    /// The index is the zero-based position in the file; the message renders
83    /// it one-based, and says "in file order" because the *chain* numbers only
84    /// the enabled entries - with a disabled head the two differ, and
85    /// `[[llm]] #1` meaning different tables in the same file is worse than
86    /// either convention alone.
87    #[error(
88        "[[llm]] #{} in file order: temperature {temperature} is outside the allowed range 0.0..=2.0",
89        index + 1
90    )]
91    Temperature { index: usize, temperature: f32 },
92
93    /// `max_concurrent = 0` builds a semaphore with no permits, so every
94    /// request waits for one forever. Rejected at load rather than clamped: a
95    /// silent bump to 1 would run a gate at a concurrency the user did not ask
96    /// for, and the alternative - the documented "callers are expected to set a
97    /// positive value" - is a hang with no message at all.
98    #[error("[[llm]] #{} in file order: max_concurrent must be at least 1", index + 1)]
99    ZeroConcurrency { index: usize },
100
101    #[error("[[llm]] #{} in file order: timeout_secs must be at least 1", index + 1)]
102    ZeroTimeout { index: usize },
103
104    #[error("[[llm]] #{} in file order: max_tokens must be at least 1 when set", index + 1)]
105    ZeroMaxTokens { index: usize },
106
107    /// Rejected rather than defaulted: falling back to `openai` would post
108    /// chat-completions bytes to a `/messages` endpoint, and the resulting 404
109    /// reads as "the provider is down" rather than "this line has a typo".
110    #[error(
111        "[[llm]] #{} in file order: unknown protocol `{value}`; expected `openai` or `anthropic`",
112        index + 1
113    )]
114    UnknownProtocol { index: usize, value: String },
115
116    #[error(
117        "[[llm]] #{} in file order: unknown backend `{value}`; expected `http` or `codex`",
118        index + 1
119    )]
120    UnknownBackend { index: usize, value: String },
121
122    #[error(
123        "[[llm]] #{} in file order: unknown reasoning_effort `{value}`; expected `minimal`, `low`, `medium`, `high`, or `xhigh`",
124        index + 1
125    )]
126    UnknownReasoningEffort { index: usize, value: String },
127
128    #[error(
129        "[[llm]] #{} in file order: backend `{backend}` does not support `{field}`",
130        index + 1
131    )]
132    BackendField {
133        index: usize,
134        backend: &'static str,
135        field: &'static str,
136    },
137
138    #[error(
139        "[[llm]] #{} in file order: backend `{backend}` requires `{field}`",
140        index + 1
141    )]
142    BackendMissingField {
143        index: usize,
144        backend: &'static str,
145        field: &'static str,
146    },
147
148    #[error(
149        "{0} declares no `[[llm]]` provider; drep 2.x has no deterministic-only mode. \
150         Run `drep init` to write one."
151    )]
152    NoProviders(PathBuf),
153
154    #[error(
155        "every `[[llm]]` provider in {0} has `enabled = false`; drep 2.x has no \
156         deterministic-only mode. Re-enable one, or run `drep init` to write another."
157    )]
158    NoEnabledProviders(PathBuf),
159}
160
161/// The conventional config file location: `drep.toml` in the current directory.
162///
163/// Hardcoded to "drep.toml" in cwd by design: `drep init` writes this exact
164/// path, so changing it here would break the contract with the init command.
165pub fn default_config_path() -> PathBuf {
166    PathBuf::from("drep.toml")
167}
168
169/// Parses a `protocol =` value, or `None` when it names nothing the SDK speaks.
170///
171/// The single definition of what a protocol name means, and it owns none of the
172/// names: [`ApiProtocol::from_wire`] is the SDK's own parser, so drep cannot
173/// come to disagree with the layer that acts on the answer. An absent value is
174/// the default protocol rather than an error, which is what keeps every config
175/// written before 0.9.0 valid.
176pub fn parse_protocol(raw: Option<&str>) -> Option<ApiProtocol> {
177    match raw {
178        None => Some(ApiProtocol::default()),
179        Some(name) => ApiProtocol::from_wire(name),
180    }
181}
182
183/// Load and validate `path`.
184///
185/// A missing file is an error: the caller decides whether that is fatal
186/// (the binary should bail) or expected (a first-run where `drep init` has
187/// not been run yet). Inventing defaults for a file that does not exist
188/// would silently mask a broken install.
189///
190/// `${VAR}` expansion happens before validation, so an unset variable is
191/// reported with the variable's name rather than as a downstream parse
192/// failure inside the substituted text.
193pub fn load(path: &Path) -> Result<Config, ConfigError> {
194    let content =
195        std::fs::read_to_string(path).map_err(|err| ConfigError::Io(path.to_path_buf(), err))?;
196
197    // `toml::from_str::<Value>` and `<Value as FromStr>::from_str` are NOT
198    // interchangeable in toml 1.x, despite producing the same type. The former
199    // runs the document parser; the latter runs `ValueDeserializer`, which
200    // parses a single TOML *value* (`42`, `"text"`) and rejects a whole document
201    // with "unexpected content, expected nothing".
202    let mut tree: Value = toml::from_str(&content).map_err(|err: toml::de::Error| {
203        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
204    })?;
205
206    // Disabled providers are pruned from expansion, not from the tree: a
207    // parked entry is inert, so an unset `${OPENROUTER_API_KEY}` in the cloud
208    // block a user just switched off must not refuse to load the file. It stays
209    // in `Config.llm` with its `${VAR}` unexpanded, which nothing reads - only
210    // `providers()` is consulted, and it filters the entry out.
211    let disabled = disabled_provider_indices(&tree);
212    expand_env_except(&mut tree, path, &disabled)?;
213    let explicit_fields = backend::explicit_fields(&tree);
214
215    let config: Config = tree.try_into().map_err(|err: toml::de::Error| {
216        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
217    })?;
218
219    validate(&config, path, &explicit_fields)?;
220    Ok(config)
221}
222
223/// Validate what serde cannot enforce from the type alone.
224///
225/// An empty provider list is rejected here rather than tolerated and caught
226/// later at the LLM boundary: the LLM layer is mandatory in 2.x, so a config
227/// naming no provider is a file that can never produce a passing run, and the
228/// earliest place to say so is the place that read the file.
229///
230/// The index is carried into the temperature error because with several
231/// providers "temperature 3.0 is out of range" does not say *which* one.
232fn validate(
233    config: &Config,
234    path: &Path,
235    explicit_fields: &[backend::ExplicitFields],
236) -> Result<(), ConfigError> {
237    if config.llm.is_empty() {
238        return Err(ConfigError::NoProviders(path.to_path_buf()));
239    }
240    // Distinct from `NoProviders` because the fix is different: one needs a
241    // provider written, the other needs one re-enabled. Both are caught here
242    // rather than at the LLM boundary so the message can name the file.
243    if config.providers().is_empty() {
244        return Err(ConfigError::NoEnabledProviders(path.to_path_buf()));
245    }
246    // Disabled entries are skipped. `enabled = false` means "this entry is
247    // inert", and refusing to load the file because a *parked* provider names
248    // an out-of-range temperature contradicts that in the one place a user
249    // would notice: they parked it precisely to stop it mattering.
250    for (index, llm) in config.llm.iter().enumerate().filter(|(_, l)| l.enabled) {
251        backend::validate(
252            llm,
253            explicit_fields.get(index).copied().unwrap_or_default(),
254            index,
255        )?;
256
257        if llm.max_concurrent == 0 {
258            return Err(ConfigError::ZeroConcurrency { index });
259        }
260        if llm.timeout_secs == 0 {
261            return Err(ConfigError::ZeroTimeout { index });
262        }
263        if llm.max_tokens == Some(0) {
264            return Err(ConfigError::ZeroMaxTokens { index });
265        }
266
267        if llm.backend != BackendKind::Http {
268            continue;
269        }
270        if let Some(t) = llm.temperature
271            && !(0.0..=2.0).contains(&t)
272        {
273            return Err(ConfigError::Temperature {
274                index,
275                temperature: t,
276            });
277        }
278        // A misspelled protocol is rejected here rather than defaulted, because
279        // silently falling back to `openai` would send chat-completions bytes to a
280        // `/messages` endpoint and report the 404 as the endpoint being down.
281        if let Some(raw) = llm.protocol.as_deref()
282            && parse_protocol(Some(raw)).is_none()
283        {
284            return Err(ConfigError::UnknownProtocol {
285                index,
286                value: raw.to_owned(),
287            });
288        }
289    }
290    Ok(())
291}
292
293/// The positions of the `[[llm]]` tables that carry `enabled = false`.
294///
295/// Read from the raw tree because expansion runs before deserialization - and
296/// it has to, since an unset variable must be reported with the variable's name
297/// rather than as a downstream parse failure inside the substituted text. The
298/// default comes from `LlmConfig::default()` so this cannot disagree with serde
299/// about what an absent `enabled` key means.
300fn disabled_provider_indices(tree: &Value) -> std::collections::BTreeSet<usize> {
301    let default_enabled = LlmConfig::default().enabled;
302    tree.get("llm")
303        .and_then(Value::as_array)
304        .map(|entries| {
305            entries
306                .iter()
307                .enumerate()
308                .filter(|(_, entry)| {
309                    !entry
310                        .get("enabled")
311                        .and_then(Value::as_bool)
312                        .unwrap_or(default_enabled)
313                })
314                .map(|(index, _)| index)
315                .collect()
316        })
317        .unwrap_or_default()
318}
319
320/// [`expand_env_in`] over the whole tree except the named `[[llm]]` entries.
321fn expand_env_except(
322    tree: &mut Value,
323    source: &Path,
324    skip: &std::collections::BTreeSet<usize>,
325) -> Result<(), ConfigError> {
326    if skip.is_empty() {
327        return expand_env_in(tree, source);
328    }
329    let Some(table) = tree.as_table_mut() else {
330        return expand_env_in(tree, source);
331    };
332    for (key, value) in table.iter_mut() {
333        if key != "llm" {
334            expand_env_in(value, source)?;
335            continue;
336        }
337        let Some(entries) = value.as_array_mut() else {
338            expand_env_in(value, source)?;
339            continue;
340        };
341        for (index, entry) in entries.iter_mut().enumerate() {
342            if !skip.contains(&index) {
343                expand_env_in(entry, source)?;
344            }
345        }
346    }
347    Ok(())
348}
349
350/// Walk every string in the parsed TOML tree and expand `${VAR}` references.
351///
352/// Applied to the whole tree rather than per-field so a future field added
353/// to `LlmConfig` inherits the behaviour without remembering to opt in. The
354/// reference is the path that contained it, so an unset variable's error
355/// message points at the file rather than the variable alone.
356fn expand_env_in(value: &mut Value, source: &Path) -> Result<(), ConfigError> {
357    match value {
358        Value::String(s) => {
359            *s = expand_string(s, source)?;
360        }
361        Value::Table(table) => {
362            for (_, inner) in table.iter_mut() {
363                expand_env_in(inner, source)?;
364            }
365        }
366        Value::Array(items) => {
367            for inner in items.iter_mut() {
368                expand_env_in(inner, source)?;
369            }
370        }
371        _ => {}
372    }
373    Ok(())
374}
375
376/// Every `${NAME}` reference in `s`, in the order they appear.
377///
378/// The single statement of what counts as a variable reference, so a consumer
379/// cannot disagree with the substituter about it. `drep doctor` had its own
380/// regex, `\$\{([A-Z_][A-Z0-9_]*)\}`, which is *narrower* than this: a config
381/// naming `${openrouter_key}` produced no warning from doctor, while
382/// `expand_string` below still failed on it — and doctor suppressed that error
383/// believing it had already reported it. The user was told the config was fine
384/// and `drep check` then refused to load it.
385///
386/// An unterminated `${` yields nothing here; `expand_string` is what reports
387/// it, because only the substituter knows it is an error rather than literal
388/// text.
389pub fn env_var_refs(s: &str) -> Vec<String> {
390    let mut refs = Vec::new();
391    let mut rest = s;
392    // Written with `split_once`/`strip_prefix` rather than `find` plus index
393    // arithmetic. The arithmetic version was correct, but `start + 2` and
394    // `end + 1` are two magic offsets whose only justification is the length
395    // of the delimiters they skip - and the delimiters are right there in the
396    // pattern, so letting the standard library consume them says the same
397    // thing without the chance of an off-by-one.
398    while let Some((_, after_open)) = rest.split_once("${") {
399        let Some((name, after_close)) = after_open.split_once('}') else {
400            // An unterminated `${`. Not this function's error to report:
401            // `expand_string` is what knows whether the text is a reference or
402            // a literal, and it rejects the file.
403            break;
404        };
405        refs.push(name.to_owned());
406        rest = after_close;
407    }
408    refs
409}
410
411/// Every `${NAME}` reference that [`load`] will actually try to substitute.
412///
413/// The same tree as [`env_var_refs_in`], minus the `[[llm]]` entries that
414/// carry `enabled = false` — because `load` skips expanding those, so a
415/// variable named only by a parked provider is not required and reporting it
416/// as missing is a false alarm. This is the shared definition, so `doctor`
417/// cannot warn about a variable `check` does not need; a narrower scanner in
418/// `doctor` is what once made it call a config fine that `check` refused to
419/// load.
420pub fn required_env_var_refs(value: &Value) -> Vec<String> {
421    let disabled = disabled_provider_indices(value);
422    if disabled.is_empty() {
423        return env_var_refs_in(value);
424    }
425    let mut seen = std::collections::BTreeSet::new();
426    let mut out = Vec::new();
427    let Some(table) = value.as_table() else {
428        return env_var_refs_in(value);
429    };
430    for (key, inner) in table {
431        if key != "llm" {
432            collect_env_refs(inner, &mut seen, &mut out);
433            continue;
434        }
435        let Some(entries) = inner.as_array() else {
436            collect_env_refs(inner, &mut seen, &mut out);
437            continue;
438        };
439        for (index, entry) in entries.iter().enumerate() {
440            if !disabled.contains(&index) {
441                collect_env_refs(entry, &mut seen, &mut out);
442            }
443        }
444    }
445    out
446}
447
448/// Every `${NAME}` reference in any string value of a parsed TOML tree.
449///
450/// Deliberately over the *parsed* tree rather than the file text: a `${VAR}`
451/// inside a comment is documentation, not a reference, and reporting it as an
452/// unset variable is a false alarm in the one command whose job is to be
453/// believed. Deduplicated, first-seen order preserved.
454pub fn env_var_refs_in(value: &Value) -> Vec<String> {
455    let mut seen = std::collections::BTreeSet::new();
456    let mut out = Vec::new();
457    collect_env_refs(value, &mut seen, &mut out);
458    out
459}
460
461fn collect_env_refs(
462    value: &Value,
463    seen: &mut std::collections::BTreeSet<String>,
464    out: &mut Vec<String>,
465) {
466    match value {
467        Value::String(s) => {
468            for name in env_var_refs(s) {
469                if seen.insert(name.clone()) {
470                    out.push(name);
471                }
472            }
473        }
474        Value::Table(table) => {
475            for (_, inner) in table {
476                collect_env_refs(inner, seen, out);
477            }
478        }
479        Value::Array(items) => {
480            for inner in items {
481                collect_env_refs(inner, seen, out);
482            }
483        }
484        _ => {}
485    }
486}
487
488/// Substitute every `${NAME}` in `s` with that environment variable's value.
489///
490/// A literal `$` that is not followed by `{` is preserved. An unterminated
491/// `${` (no closing `}`) is also an error, because the alternative - silently
492/// dropping it - leaves the file's contract unstated.
493fn expand_string(s: &str, source: &Path) -> Result<String, ConfigError> {
494    let mut out = String::with_capacity(s.len());
495    let mut chars = s.chars().peekable();
496    while let Some(c) = chars.next() {
497        if c != '$' {
498            out.push(c);
499            continue;
500        }
501        if chars.peek() != Some(&'{') {
502            // Literal `$` not followed by `{`. Preserved verbatim so a path
503            // like `$HOME/x` survives rather than vanishing the `$`.
504            out.push(c);
505            continue;
506        }
507        chars.next();
508        let mut name = String::new();
509        let mut closed = false;
510        for next in chars.by_ref() {
511            if next == '}' {
512                closed = true;
513                break;
514            }
515            name.push(next);
516        }
517        if !closed {
518            return Err(ConfigError::Parse(
519                source.to_path_buf(),
520                format!("unterminated `${{` in `{s}`"),
521            ));
522        }
523        if name.is_empty() {
524            return Err(ConfigError::Parse(
525                source.to_path_buf(),
526                format!("empty environment variable reference in `{s}`"),
527            ));
528        }
529        let value = match env::var(&name) {
530            Ok(value) => value,
531            Err(env::VarError::NotPresent) => {
532                return Err(ConfigError::EnvVarUnset(name, source.display().to_string()));
533            }
534            Err(env::VarError::NotUnicode(_)) => {
535                return Err(ConfigError::EnvVarNotUnicode(
536                    name,
537                    source.display().to_string(),
538                ));
539            }
540        };
541        out.push_str(&value);
542    }
543    Ok(out)
544}
545
546#[cfg(test)]
547mod tests;