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