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//!
22//! ## The second layer
23//!
24//! This file is per-repository and `drep init` gitignores it, so a control
25//! written here is per-developer and opt-in. [`site`] is the layer above it: a
26//! machine-level policy file a checkout can tighten but never loosen.
27//!
28//! [`load`] and `validate` know nothing about it and take no site argument.
29//! The clamp is applied by the caller, after `load` returns, which is what keeps
30//! [`ConfigError`] a statement about this file alone - every one of its messages
31//! numbers `[[llm]]` entries in *this* file's order, and a bare `#2` that could
32//! mean either file is exactly the ambiguity those messages exist to avoid.
33
34use std::collections::{BTreeMap, HashMap};
35use std::path::{Path, PathBuf};
36
37use open_agent::ApiProtocol;
38use serde::Deserialize;
39use thiserror::Error;
40use toml::Value;
41
42mod backend;
43mod env;
44// A submodule at its own path rather than a re-export: `site::load`,
45// `site::default_path` and `site::PATH_VAR` would each collide with a name
46// already here, and `config::load` against `config::site::load` is exactly the
47// distinction a caller must not blur.
48pub mod site;
49pub use backend::{BackendKind, LlmConfig, ReasoningEffort};
50// Re-exported at the parent's path rather than behind `config::env::`: `doctor`
51// and `auth` both call these, and moving them was a file-size split, not a
52// change of contract.
53use env::{disabled_provider_indices, expand_env_except};
54pub use env::{env_var_refs, env_var_refs_in, required_env_var_refs};
55
56pub const DEFAULT_MAX_REVIEW_ROUNDS: u32 = 3;
57
58/// The whole configuration tree, rooted at the file.
59///
60/// `llm` is an **array of tables** (`[[llm]]`), not a single `[llm]` section,
61/// and the list is a *preference order*: [`Self::providers`] is the failover
62/// chain.
63///
64/// `#[serde(default)]` means a file with an empty body deserializes
65/// successfully; `validate` is what then rejects it, because a config
66/// declaring no provider cannot run the mandatory LLM layer.
67///
68/// `deny_unknown_fields` for the reason [`LlmConfig`] carries it: a misspelled
69/// `max_reveiw_rounds` was accepted, dropped, and run at the default, which is a
70/// file that reads as configured and is not. `site_only_field` runs against the
71/// raw tree before this deserialization, so `SiteOnlyField` still answers for a
72/// policy key rather than being swallowed by a generic unknown-key message.
73#[derive(Debug, Deserialize)]
74#[serde(default, deny_unknown_fields)]
75pub struct Config {
76    pub max_review_rounds: u32,
77    pub llm: Vec<LlmConfig>,
78}
79
80impl Default for Config {
81    fn default() -> Self {
82        Self {
83            max_review_rounds: DEFAULT_MAX_REVIEW_ROUNDS,
84            llm: Vec::new(),
85        }
86    }
87}
88
89impl Config {
90    /// The failover chain: every enabled provider, in file order.
91    ///
92    /// The single definition of "which providers are in play". `enabled` is
93    /// an opt-*out*, so a disabled entry is skipped wherever it sits - a
94    /// disabled head falls through to the entry below it rather than
95    /// producing `NotConfigured`, which is what parking the local model was
96    /// always meant to do.
97    ///
98    /// A `Vec` rather than an iterator because every caller wants a length or
99    /// an index (the chain numbers its providers in error messages) and the
100    /// list is at most a handful of entries.
101    pub fn providers(&self) -> Vec<&LlmConfig> {
102        self.llm.iter().filter(|p| p.enabled).collect()
103    }
104}
105
106/// What went wrong reading or validating the configuration.
107#[derive(Debug, Error)]
108pub enum ConfigError {
109    #[error("could not read {0}: {1}")]
110    Io(PathBuf, std::io::Error),
111
112    #[error("could not parse {0}: {1}")]
113    Parse(PathBuf, String),
114
115    #[error("environment variable `{0}` is not set (referenced by `{1}`)")]
116    EnvVarUnset(String, String),
117
118    #[error("environment variable `{0}` is not valid UTF-8 (referenced by `{1}`)")]
119    EnvVarNotUnicode(String, String),
120
121    /// The index is the zero-based position in the file; the message renders
122    /// it one-based, and says "in file order" because the *chain* numbers only
123    /// the enabled entries - with a disabled head the two differ, and
124    /// `[[llm]] #1` meaning different tables in the same file is worse than
125    /// either convention alone.
126    #[error(
127        "[[llm]] #{} in file order: temperature {temperature} is outside the allowed range 0.0..=2.0",
128        index + 1
129    )]
130    Temperature { index: usize, temperature: f32 },
131
132    /// `max_concurrent = 0` builds a semaphore with no permits, so every
133    /// request waits for one forever. Rejected at load rather than clamped: a
134    /// silent bump to 1 would run a gate at a concurrency the user did not ask
135    /// for, and the alternative - the documented "callers are expected to set a
136    /// positive value" - is a hang with no message at all.
137    #[error("[[llm]] #{} in file order: max_concurrent must be at least 1", index + 1)]
138    ZeroConcurrency { index: usize },
139
140    #[error("[[llm]] #{} in file order: timeout_secs must be at least 1", index + 1)]
141    ZeroTimeout { index: usize },
142
143    #[error("[[llm]] #{} in file order: max_tokens must be at least 1 when set", index + 1)]
144    ZeroMaxTokens { index: usize },
145
146    #[error("max_review_rounds must be at least 1")]
147    ZeroReviewRounds,
148
149    /// Rejected rather than defaulted: falling back to `openai` would post
150    /// chat-completions bytes to a `/messages` endpoint, and the resulting 404
151    /// reads as "the provider is down" rather than "this line has a typo".
152    #[error(
153        "[[llm]] #{} in file order: unknown protocol `{value}`; expected `openai` or `anthropic`",
154        index + 1
155    )]
156    UnknownProtocol { index: usize, value: String },
157
158    #[error(
159        "[[llm]] #{} in file order: `{name}` cannot be sent as an HTTP header name",
160        index + 1
161    )]
162    UnusableHeaderName { index: usize, name: String },
163
164    /// Names the header, never its value: the value is the half that carries a
165    /// tenant or project token, and an error message is the one place it would
166    /// escape into a terminal, a CI log or a bug report. The rule
167    /// `KeyCommandError` already follows for a credential helper's output.
168    #[error(
169        "[[llm]] #{} in file order: the value configured for header `{name}` \
170         contains a character that cannot be sent in a header",
171        index + 1
172    )]
173    UnusableHeaderValue { index: usize, name: String },
174
175    /// Both spellings, never either value: which of the two is the credential is
176    /// exactly what this error cannot know, so it quotes neither, the way
177    /// `UnusableHeaderValue` above quotes none.
178    #[error(
179        "[[llm]] #{} in file order: `{first}` and `{second}` are one HTTP header name written \
180         twice; header names are case-insensitive, so only one of them is sent, and which one \
181         is decided by their byte order rather than by anything this file says - remove the \
182         spelling you did not mean",
183        index + 1
184    )]
185    DuplicateHeaderName {
186        index: usize,
187        first: String,
188        second: String,
189    },
190
191    #[error(
192        "[[llm]] #{} in file order: unknown backend `{value}`; expected `http` or `codex`",
193        index + 1
194    )]
195    UnknownBackend { index: usize, value: String },
196
197    #[error(
198        "[[llm]] #{} in file order: unknown reasoning_effort `{value}`; expected `minimal`, `low`, `medium`, `high`, or `xhigh`",
199        index + 1
200    )]
201    UnknownReasoningEffort { index: usize, value: String },
202
203    /// Both credential fields answer the same question, so a file setting both
204    /// has said two things. Rejected rather than resolved by precedence: the
205    /// user who wrote the command wrote it to be run, and a silent "the literal
206    /// wins" leaves them debugging a stale credential the file says nothing
207    /// about.
208    #[error(
209        "[[llm]] #{} in file order: `api_key` and `api_key_command` are both set; remove one, \
210         because a key that is already there is never re-minted by a command",
211        index + 1
212    )]
213    AmbiguousApiKey { index: usize },
214
215    /// An argv with no first element names no program. Rejected at load because
216    /// the alternative is discovering it inside the gate, at the point where
217    /// there is nothing to run and nothing useful to say about why.
218    #[error(
219        "[[llm]] #{} in file order: api_key_command is empty; it must name a program to run, \
220         as an argv array such as [\"print-token\", \"--audience\", \"gateway\"]",
221        index + 1
222    )]
223    EmptyApiKeyCommand { index: usize },
224
225    #[error(
226        "[[llm]] #{} in file order: backend `{backend}` does not support `{field}`",
227        index + 1
228    )]
229    BackendField {
230        index: usize,
231        backend: &'static str,
232        field: &'static str,
233    },
234
235    #[error(
236        "[[llm]] #{} in file order: backend `{backend}` requires `{field}`",
237        index + 1
238    )]
239    BackendMissingField {
240        index: usize,
241        backend: &'static str,
242        field: &'static str,
243    },
244
245    #[error(
246        "{0} declares no `[[llm]]` provider; drep 2.x has no deterministic-only mode. \
247         Run `drep init` to write one."
248    )]
249    NoProviders(PathBuf),
250
251    #[error(
252        "every `[[llm]]` provider in {0} has `enabled = false`; drep 2.x has no \
253         deterministic-only mode. Re-enable one, or run `drep init` to write another."
254    )]
255    NoEnabledProviders(PathBuf),
256
257    /// Rejected rather than ignored, which is the one behaviour that would be
258    /// worse than either: serde drops an unknown key without a word, so a
259    /// developer reads `refuse_markers` in their own config, believes the
260    /// repository is protected, and every review still ships its source. It is
261    /// refused here rather than honoured because `drep init` gitignores this
262    /// file - a copy of the control would be per-developer, and a refusal a
263    /// developer can delete is not one.
264    #[error(
265        "{path} sets `{field}`, which is machine site policy and is read only from the site \
266         policy file - {machine} on this platform, or the file `drep doctor` names if this \
267         machine keeps it elsewhere; `drep init` gitignores {path}, so a copy of the field there \
268         would be per-developer and could be deleted by the developer it constrains",
269        machine = site::machine_path().display()
270    )]
271    SiteOnlyField { path: PathBuf, field: &'static str },
272}
273
274/// The conventional config file location: `drep.toml` in the current directory.
275///
276/// Hardcoded to "drep.toml" in cwd by design: `drep init` writes this exact
277/// path, so changing it here would break the contract with the init command.
278pub fn default_config_path() -> PathBuf {
279    PathBuf::from("drep.toml")
280}
281
282/// What drep calls itself to an endpoint when the config names no `User-Agent`.
283///
284/// The crate version rather than a bare name, because the useful question a
285/// gateway operator asks of a user agent is which build sent this. reqwest sends
286/// none at all by default, and an endpoint that logs or bills per client cannot
287/// attribute a request that carries none.
288pub const DEFAULT_USER_AGENT: &str = concat!("drep/", env!("CARGO_PKG_VERSION"));
289
290/// The headers a provider will actually send: drep's defaults, then the
291/// configured table over the top.
292///
293/// The single statement of that precedence, for the reason [`crate::auth`] keeps
294/// one statement of the credential order: the request path, `LlmClient`'s
295/// `Debug` and `drep doctor` all have to answer the same question about the same
296/// entry, and when the default was applied inside the request instead, they
297/// answered differently. A config naming one header printed one and sent two,
298/// and a config naming none printed an empty set and sent a `User-Agent`. The
299/// operator debugging a gateway 403 is asking `doctor` exactly that question,
300/// and the case where it was silent was the case where they had not set one.
301///
302/// Case-insensitive, because HTTP header names are: a configured `user-agent`
303/// replaces the default rather than joining it, which is what the SDK's own
304/// `HeaderMap` would do at send time anyway. Default-against-configured is the
305/// only collision this function has to settle - `validate` has already refused a
306/// pair of configured spellings, because there the two names are equally the
307/// user's and picking one is not the caller's to do.
308pub fn effective_headers(configured: &BTreeMap<String, String>) -> BTreeMap<String, String> {
309    let mut headers = BTreeMap::new();
310    if !configured
311        .keys()
312        .any(|name| name.eq_ignore_ascii_case("user-agent"))
313    {
314        headers.insert("User-Agent".to_owned(), DEFAULT_USER_AGENT.to_owned());
315    }
316    headers.extend(
317        configured
318            .iter()
319            .map(|(name, value)| (name.clone(), value.clone())),
320    );
321    headers
322}
323
324/// Parses a `protocol =` value, or `None` when it names nothing the SDK speaks.
325///
326/// The single definition of what a protocol name means, and it owns none of the
327/// names: [`ApiProtocol::from_wire`] is the SDK's own parser, so drep cannot
328/// come to disagree with the layer that acts on the answer. An absent value is
329/// the default protocol rather than an error, which is what keeps every config
330/// written before 0.9.0 valid.
331pub fn parse_protocol(raw: Option<&str>) -> Option<ApiProtocol> {
332    match raw {
333        None => Some(ApiProtocol::default()),
334        Some(name) => ApiProtocol::from_wire(name),
335    }
336}
337
338/// Load and validate `path`.
339///
340/// A missing file is an error: the caller decides whether that is fatal
341/// (the binary should bail) or expected (a first-run where `drep init` has
342/// not been run yet). Inventing defaults for a file that does not exist
343/// would silently mask a broken install.
344///
345/// `${VAR}` expansion happens before validation, so an unset variable is
346/// reported with the variable's name rather than as a downstream parse
347/// failure inside the substituted text.
348pub fn load(path: &Path) -> Result<Config, ConfigError> {
349    load_with_env(path, |name| std::env::var(name))
350}
351
352/// Test seam for `${VAR}` expansion without mutating process-global state.
353///
354/// `std::env::set_var` is unsafe in edition 2024 because the test harness is
355/// multithreaded. Passing the lookup also proves validation sees the expanded
356/// value through the same production path rather than recreating that ordering
357/// in a test.
358pub(crate) fn load_with_env<F>(path: &Path, lookup: F) -> Result<Config, ConfigError>
359where
360    F: Fn(&str) -> Result<String, std::env::VarError>,
361{
362    let content =
363        std::fs::read_to_string(path).map_err(|err| ConfigError::Io(path.to_path_buf(), err))?;
364
365    // `toml::from_str::<Value>` and `<Value as FromStr>::from_str` are not
366    // interchangeable despite producing the same type. The former runs the
367    // document parser; the latter runs `ValueDeserializer`, which
368    // parses a single TOML *value* (`42`, `"text"`) and rejects a whole document
369    // with "unexpected content, expected nothing".
370    let mut tree: Value = toml::from_str(&content).map_err(|err: toml::de::Error| {
371        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
372    })?;
373
374    // Read off the raw tree, the way `disabled_provider_indices` and
375    // `backend::explicit_fields` are, and before deserialization because this
376    // pass owns both the variant and the wording. `Config` denies unknown
377    // fields, so a `refuse_markers` left to serde is refused - as a
378    // `ConfigError::Parse` reading "unknown field, expected `max_review_rounds`
379    // or `llm`", which tells a developer their line is a typo to delete rather
380    // than that the field is machine policy and lives in another file.
381    if let Some(field) = site_only_field(&tree) {
382        return Err(ConfigError::SiteOnlyField {
383            path: path.to_path_buf(),
384            field,
385        });
386    }
387
388    // Disabled providers are pruned from expansion, not from the tree: a
389    // parked entry is inert, so an unset `${OPENROUTER_API_KEY}` in the cloud
390    // block a user just switched off must not refuse to load the file. It stays
391    // in `Config.llm` with its `${VAR}` unexpanded, which nothing reads - only
392    // `providers()` is consulted, and it filters the entry out.
393    let disabled = disabled_provider_indices(&tree);
394    expand_env_except(&mut tree, path, &disabled, &lookup)?;
395    let explicit_fields = backend::explicit_fields(&tree);
396
397    let config: Config = tree.try_into().map_err(|err: toml::de::Error| {
398        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
399    })?;
400
401    validate(&config, path, &explicit_fields)?;
402    Ok(config)
403}
404
405/// The site-policy key this file declared, if it declared one.
406///
407/// The list itself lives beside `SiteConfig` in [`site::SITE_ONLY_FIELDS`],
408/// because it is a statement about that type's fields and the decision about a
409/// new one belongs where the field is added. This function used to spell
410/// `tree.get("refuse_markers")` here, which meant a policy field added in the
411/// other module was refused nowhere, dropped silently from a `drep.toml` that
412/// named it, and believed by the developer who wrote it.
413fn site_only_field(tree: &Value) -> Option<&'static str> {
414    site::SITE_ONLY_FIELDS
415        .iter()
416        .copied()
417        .find(|field| tree.get(field).is_some())
418}
419
420/// Validate what serde cannot enforce from the type alone.
421///
422/// An empty provider list is rejected here rather than tolerated and caught
423/// later at the LLM boundary: the LLM layer is mandatory in 2.x, so a config
424/// naming no provider is a file that can never produce a passing run, and the
425/// earliest place to say so is the place that read the file.
426///
427/// The index is carried into the temperature error because with several
428/// providers "temperature 3.0 is out of range" does not say *which* one.
429fn validate(
430    config: &Config,
431    path: &Path,
432    explicit_fields: &[backend::ExplicitFields],
433) -> Result<(), ConfigError> {
434    if config.max_review_rounds == 0 {
435        return Err(ConfigError::ZeroReviewRounds);
436    }
437    if config.llm.is_empty() {
438        return Err(ConfigError::NoProviders(path.to_path_buf()));
439    }
440    // Distinct from `NoProviders` because the fix is different: one needs a
441    // provider written, the other needs one re-enabled. Both are caught here
442    // rather than at the LLM boundary so the message can name the file.
443    if config.providers().is_empty() {
444        return Err(ConfigError::NoEnabledProviders(path.to_path_buf()));
445    }
446    // Disabled entries are skipped. `enabled = false` means "this entry is
447    // inert", and refusing to load the file because a *parked* provider names
448    // an out-of-range temperature contradicts that in the one place a user
449    // would notice: they parked it precisely to stop it mattering.
450    for (index, llm) in config.llm.iter().enumerate().filter(|(_, l)| l.enabled) {
451        backend::validate(
452            llm,
453            explicit_fields.get(index).copied().unwrap_or_default(),
454            index,
455        )?;
456
457        if llm.max_concurrent == 0 {
458            return Err(ConfigError::ZeroConcurrency { index });
459        }
460        if llm.timeout_secs == 0 {
461            return Err(ConfigError::ZeroTimeout { index });
462        }
463        if llm.max_tokens == Some(0) {
464            return Err(ConfigError::ZeroMaxTokens { index });
465        }
466        // Checked for every backend rather than only for HTTP, so the rule holds
467        // above the `continue` below. `backend::validate` has already rejected
468        // `api_key_command` on a Codex entry by name, so reaching here with one
469        // means the backend can use it.
470        if llm.api_key.is_some() && llm.api_key_command.is_some() {
471            return Err(ConfigError::AmbiguousApiKey { index });
472        }
473        if llm.api_key_command.as_ref().is_some_and(Vec::is_empty) {
474            return Err(ConfigError::EmptyApiKeyCommand { index });
475        }
476        // Rejected here rather than left to the request, for the reason the
477        // misspelled protocol below is: a header drep cannot encode fails
478        // identically on every file of the run, and `LlmError::NotConfigured`
479        // neither fails over nor sticks, so a 200-file diff reported a typo two
480        // hundred times - each one rendered as a transport failure, which reads
481        // as the endpoint being down.
482        //
483        // After `${VAR}` expansion, so what is checked is what will be sent: a
484        // token whose expansion carries a stray control character is the case a
485        // name-only check would pass and every request would then fail. The
486        // parse is `http`'s own, through the same types the SDK builds its
487        // `HeaderMap` from, so the two cannot come to disagree about what is
488        // sendable.
489        //
490        // `folded` is keyed the way HTTP compares header names, which is what
491        // makes two `BTreeMap` keys one header. A pair of spellings is refused
492        // rather than resolved, for the reason `AmbiguousApiKey` above is: the
493        // SDK's `HeaderMap` keeps the last insertion and this map is walked in
494        // byte order, so `Authorization` and `authorization` both configured
495        // means the credential that goes out was chosen by ASCII - while
496        // `doctor` and both `Debug` impls go on listing the one that does not.
497        let mut folded: HashMap<reqwest::header::HeaderName, &String> = HashMap::new();
498        for (name, value) in &llm.headers {
499            let parsed =
500                reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
501                    ConfigError::UnusableHeaderName {
502                        index,
503                        name: name.clone(),
504                    }
505                })?;
506            if reqwest::header::HeaderValue::from_bytes(value.as_bytes()).is_err() {
507                return Err(ConfigError::UnusableHeaderValue {
508                    index,
509                    name: name.clone(),
510                });
511            }
512            if let Some(first) = folded.insert(parsed, name) {
513                return Err(ConfigError::DuplicateHeaderName {
514                    index,
515                    first: first.clone(),
516                    second: name.clone(),
517                });
518            }
519        }
520
521        if llm.backend != BackendKind::Http {
522            continue;
523        }
524        if let Some(t) = llm.temperature
525            && !(0.0..=2.0).contains(&t)
526        {
527            return Err(ConfigError::Temperature {
528                index,
529                temperature: t,
530            });
531        }
532        // A misspelled protocol is rejected here rather than defaulted, because
533        // silently falling back to `openai` would send chat-completions bytes to a
534        // `/messages` endpoint and report the 404 as the endpoint being down.
535        if let Some(raw) = llm.protocol.as_deref()
536            && parse_protocol(Some(raw)).is_none()
537        {
538            return Err(ConfigError::UnknownProtocol {
539                index,
540                value: raw.to_owned(),
541            });
542        }
543    }
544    Ok(())
545}
546
547#[cfg(test)]
548mod tests;