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::path::{Path, PathBuf};
35
36use open_agent::ApiProtocol;
37use serde::Deserialize;
38use thiserror::Error;
39use toml::Value;
40
41mod backend;
42mod env;
43// A submodule at its own path rather than a re-export: `site::load`,
44// `site::default_path` and `site::PATH_VAR` would each collide with a name
45// already here, and `config::load` against `config::site::load` is exactly the
46// distinction a caller must not blur.
47pub mod site;
48pub use backend::{BackendKind, LlmConfig, ReasoningEffort};
49// Re-exported at the parent's path rather than behind `config::env::`: `doctor`
50// and `auth` both call these, and moving them was a file-size split, not a
51// change of contract.
52use env::{disabled_provider_indices, expand_env_except};
53pub use env::{env_var_refs, env_var_refs_in, required_env_var_refs};
54
55pub const DEFAULT_MAX_REVIEW_ROUNDS: u32 = 3;
56
57/// The whole configuration tree, rooted at the file.
58///
59/// `llm` is an **array of tables** (`[[llm]]`), not a single `[llm]` section,
60/// and the list is a *preference order*: [`Self::providers`] is the failover
61/// chain.
62///
63/// `#[serde(default)]` means a file with an empty body deserializes
64/// successfully; `validate` is what then rejects it, because a config
65/// declaring no provider cannot run the mandatory LLM layer.
66#[derive(Debug, Deserialize)]
67#[serde(default)]
68pub struct Config {
69    pub max_review_rounds: u32,
70    pub llm: Vec<LlmConfig>,
71}
72
73impl Default for Config {
74    fn default() -> Self {
75        Self {
76            max_review_rounds: DEFAULT_MAX_REVIEW_ROUNDS,
77            llm: Vec::new(),
78        }
79    }
80}
81
82impl Config {
83    /// The failover chain: every enabled provider, in file order.
84    ///
85    /// The single definition of "which providers are in play". `enabled` is
86    /// an opt-*out*, so a disabled entry is skipped wherever it sits - a
87    /// disabled head falls through to the entry below it rather than
88    /// producing `NotConfigured`, which is what parking the local model was
89    /// always meant to do.
90    ///
91    /// A `Vec` rather than an iterator because every caller wants a length or
92    /// an index (the chain numbers its providers in error messages) and the
93    /// list is at most a handful of entries.
94    pub fn providers(&self) -> Vec<&LlmConfig> {
95        self.llm.iter().filter(|p| p.enabled).collect()
96    }
97}
98
99/// What went wrong reading or validating the configuration.
100#[derive(Debug, Error)]
101pub enum ConfigError {
102    #[error("could not read {0}: {1}")]
103    Io(PathBuf, std::io::Error),
104
105    #[error("could not parse {0}: {1}")]
106    Parse(PathBuf, String),
107
108    #[error("environment variable `{0}` is not set (referenced by `{1}`)")]
109    EnvVarUnset(String, String),
110
111    #[error("environment variable `{0}` is not valid UTF-8 (referenced by `{1}`)")]
112    EnvVarNotUnicode(String, String),
113
114    /// The index is the zero-based position in the file; the message renders
115    /// it one-based, and says "in file order" because the *chain* numbers only
116    /// the enabled entries - with a disabled head the two differ, and
117    /// `[[llm]] #1` meaning different tables in the same file is worse than
118    /// either convention alone.
119    #[error(
120        "[[llm]] #{} in file order: temperature {temperature} is outside the allowed range 0.0..=2.0",
121        index + 1
122    )]
123    Temperature { index: usize, temperature: f32 },
124
125    /// `max_concurrent = 0` builds a semaphore with no permits, so every
126    /// request waits for one forever. Rejected at load rather than clamped: a
127    /// silent bump to 1 would run a gate at a concurrency the user did not ask
128    /// for, and the alternative - the documented "callers are expected to set a
129    /// positive value" - is a hang with no message at all.
130    #[error("[[llm]] #{} in file order: max_concurrent must be at least 1", index + 1)]
131    ZeroConcurrency { index: usize },
132
133    #[error("[[llm]] #{} in file order: timeout_secs must be at least 1", index + 1)]
134    ZeroTimeout { index: usize },
135
136    #[error("[[llm]] #{} in file order: max_tokens must be at least 1 when set", index + 1)]
137    ZeroMaxTokens { index: usize },
138
139    #[error("max_review_rounds must be at least 1")]
140    ZeroReviewRounds,
141
142    /// Rejected rather than defaulted: falling back to `openai` would post
143    /// chat-completions bytes to a `/messages` endpoint, and the resulting 404
144    /// reads as "the provider is down" rather than "this line has a typo".
145    #[error(
146        "[[llm]] #{} in file order: unknown protocol `{value}`; expected `openai` or `anthropic`",
147        index + 1
148    )]
149    UnknownProtocol { index: usize, value: String },
150
151    #[error(
152        "[[llm]] #{} in file order: unknown backend `{value}`; expected `http` or `codex`",
153        index + 1
154    )]
155    UnknownBackend { index: usize, value: String },
156
157    #[error(
158        "[[llm]] #{} in file order: unknown reasoning_effort `{value}`; expected `minimal`, `low`, `medium`, `high`, or `xhigh`",
159        index + 1
160    )]
161    UnknownReasoningEffort { index: usize, value: String },
162
163    /// Both credential fields answer the same question, so a file setting both
164    /// has said two things. Rejected rather than resolved by precedence: the
165    /// user who wrote the command wrote it to be run, and a silent "the literal
166    /// wins" leaves them debugging a stale credential the file says nothing
167    /// about.
168    #[error(
169        "[[llm]] #{} in file order: `api_key` and `api_key_command` are both set; remove one, \
170         because a key that is already there is never re-minted by a command",
171        index + 1
172    )]
173    AmbiguousApiKey { index: usize },
174
175    /// An argv with no first element names no program. Rejected at load because
176    /// the alternative is discovering it inside the gate, at the point where
177    /// there is nothing to run and nothing useful to say about why.
178    #[error(
179        "[[llm]] #{} in file order: api_key_command is empty; it must name a program to run, \
180         as an argv array such as [\"print-token\", \"--audience\", \"gateway\"]",
181        index + 1
182    )]
183    EmptyApiKeyCommand { index: usize },
184
185    #[error(
186        "[[llm]] #{} in file order: backend `{backend}` does not support `{field}`",
187        index + 1
188    )]
189    BackendField {
190        index: usize,
191        backend: &'static str,
192        field: &'static str,
193    },
194
195    #[error(
196        "[[llm]] #{} in file order: backend `{backend}` requires `{field}`",
197        index + 1
198    )]
199    BackendMissingField {
200        index: usize,
201        backend: &'static str,
202        field: &'static str,
203    },
204
205    #[error(
206        "{0} declares no `[[llm]]` provider; drep 2.x has no deterministic-only mode. \
207         Run `drep init` to write one."
208    )]
209    NoProviders(PathBuf),
210
211    #[error(
212        "every `[[llm]]` provider in {0} has `enabled = false`; drep 2.x has no \
213         deterministic-only mode. Re-enable one, or run `drep init` to write another."
214    )]
215    NoEnabledProviders(PathBuf),
216
217    /// Rejected rather than ignored, which is the one behaviour that would be
218    /// worse than either: serde drops an unknown key without a word, so a
219    /// developer reads `refuse_markers` in their own config, believes the
220    /// repository is protected, and every review still ships its source. It is
221    /// refused here rather than honoured because `drep init` gitignores this
222    /// file - a copy of the control would be per-developer, and a refusal a
223    /// developer can delete is not one.
224    #[error(
225        "{path} sets `{field}`, which is machine site policy and is read only from the site \
226         policy file - {machine} on this platform, or the file `drep doctor` names if this \
227         machine keeps it elsewhere; `drep init` gitignores {path}, so a copy of the field there \
228         would be per-developer and could be deleted by the developer it constrains",
229        machine = site::machine_path().display()
230    )]
231    SiteOnlyField { path: PathBuf, field: &'static str },
232}
233
234/// The conventional config file location: `drep.toml` in the current directory.
235///
236/// Hardcoded to "drep.toml" in cwd by design: `drep init` writes this exact
237/// path, so changing it here would break the contract with the init command.
238pub fn default_config_path() -> PathBuf {
239    PathBuf::from("drep.toml")
240}
241
242/// Parses a `protocol =` value, or `None` when it names nothing the SDK speaks.
243///
244/// The single definition of what a protocol name means, and it owns none of the
245/// names: [`ApiProtocol::from_wire`] is the SDK's own parser, so drep cannot
246/// come to disagree with the layer that acts on the answer. An absent value is
247/// the default protocol rather than an error, which is what keeps every config
248/// written before 0.9.0 valid.
249pub fn parse_protocol(raw: Option<&str>) -> Option<ApiProtocol> {
250    match raw {
251        None => Some(ApiProtocol::default()),
252        Some(name) => ApiProtocol::from_wire(name),
253    }
254}
255
256/// Load and validate `path`.
257///
258/// A missing file is an error: the caller decides whether that is fatal
259/// (the binary should bail) or expected (a first-run where `drep init` has
260/// not been run yet). Inventing defaults for a file that does not exist
261/// would silently mask a broken install.
262///
263/// `${VAR}` expansion happens before validation, so an unset variable is
264/// reported with the variable's name rather than as a downstream parse
265/// failure inside the substituted text.
266pub fn load(path: &Path) -> Result<Config, ConfigError> {
267    let content =
268        std::fs::read_to_string(path).map_err(|err| ConfigError::Io(path.to_path_buf(), err))?;
269
270    // `toml::from_str::<Value>` and `<Value as FromStr>::from_str` are not
271    // interchangeable despite producing the same type. The former runs the
272    // document parser; the latter runs `ValueDeserializer`, which
273    // parses a single TOML *value* (`42`, `"text"`) and rejects a whole document
274    // with "unexpected content, expected nothing".
275    let mut tree: Value = toml::from_str(&content).map_err(|err: toml::de::Error| {
276        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
277    })?;
278
279    // Read off the raw tree, the way `disabled_provider_indices` and
280    // `backend::explicit_fields` are: `Config` has no `refuse_markers` field and
281    // no `deny_unknown_fields`, so serde would deserialize this file happily and
282    // say nothing.
283    if let Some(field) = site_only_field(&tree) {
284        return Err(ConfigError::SiteOnlyField {
285            path: path.to_path_buf(),
286            field,
287        });
288    }
289
290    // Disabled providers are pruned from expansion, not from the tree: a
291    // parked entry is inert, so an unset `${OPENROUTER_API_KEY}` in the cloud
292    // block a user just switched off must not refuse to load the file. It stays
293    // in `Config.llm` with its `${VAR}` unexpanded, which nothing reads - only
294    // `providers()` is consulted, and it filters the entry out.
295    let disabled = disabled_provider_indices(&tree);
296    expand_env_except(&mut tree, path, &disabled)?;
297    let explicit_fields = backend::explicit_fields(&tree);
298
299    let config: Config = tree.try_into().map_err(|err: toml::de::Error| {
300        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
301    })?;
302
303    validate(&config, path, &explicit_fields)?;
304    Ok(config)
305}
306
307/// The site-policy key this file declared, if it declared one.
308///
309/// The list itself lives beside `SiteConfig` in [`site::SITE_ONLY_FIELDS`],
310/// because it is a statement about that type's fields and the decision about a
311/// new one belongs where the field is added. This function used to spell
312/// `tree.get("refuse_markers")` here, which meant a policy field added in the
313/// other module was refused nowhere, dropped silently from a `drep.toml` that
314/// named it, and believed by the developer who wrote it.
315fn site_only_field(tree: &Value) -> Option<&'static str> {
316    site::SITE_ONLY_FIELDS
317        .iter()
318        .copied()
319        .find(|field| tree.get(field).is_some())
320}
321
322/// Validate what serde cannot enforce from the type alone.
323///
324/// An empty provider list is rejected here rather than tolerated and caught
325/// later at the LLM boundary: the LLM layer is mandatory in 2.x, so a config
326/// naming no provider is a file that can never produce a passing run, and the
327/// earliest place to say so is the place that read the file.
328///
329/// The index is carried into the temperature error because with several
330/// providers "temperature 3.0 is out of range" does not say *which* one.
331fn validate(
332    config: &Config,
333    path: &Path,
334    explicit_fields: &[backend::ExplicitFields],
335) -> Result<(), ConfigError> {
336    if config.max_review_rounds == 0 {
337        return Err(ConfigError::ZeroReviewRounds);
338    }
339    if config.llm.is_empty() {
340        return Err(ConfigError::NoProviders(path.to_path_buf()));
341    }
342    // Distinct from `NoProviders` because the fix is different: one needs a
343    // provider written, the other needs one re-enabled. Both are caught here
344    // rather than at the LLM boundary so the message can name the file.
345    if config.providers().is_empty() {
346        return Err(ConfigError::NoEnabledProviders(path.to_path_buf()));
347    }
348    // Disabled entries are skipped. `enabled = false` means "this entry is
349    // inert", and refusing to load the file because a *parked* provider names
350    // an out-of-range temperature contradicts that in the one place a user
351    // would notice: they parked it precisely to stop it mattering.
352    for (index, llm) in config.llm.iter().enumerate().filter(|(_, l)| l.enabled) {
353        backend::validate(
354            llm,
355            explicit_fields.get(index).copied().unwrap_or_default(),
356            index,
357        )?;
358
359        if llm.max_concurrent == 0 {
360            return Err(ConfigError::ZeroConcurrency { index });
361        }
362        if llm.timeout_secs == 0 {
363            return Err(ConfigError::ZeroTimeout { index });
364        }
365        if llm.max_tokens == Some(0) {
366            return Err(ConfigError::ZeroMaxTokens { index });
367        }
368        // Checked for every backend rather than only for HTTP, so the rule holds
369        // above the `continue` below. `backend::validate` has already rejected
370        // `api_key_command` on a Codex entry by name, so reaching here with one
371        // means the backend can use it.
372        if llm.api_key.is_some() && llm.api_key_command.is_some() {
373            return Err(ConfigError::AmbiguousApiKey { index });
374        }
375        if llm.api_key_command.as_ref().is_some_and(Vec::is_empty) {
376            return Err(ConfigError::EmptyApiKeyCommand { index });
377        }
378
379        if llm.backend != BackendKind::Http {
380            continue;
381        }
382        if let Some(t) = llm.temperature
383            && !(0.0..=2.0).contains(&t)
384        {
385            return Err(ConfigError::Temperature {
386                index,
387                temperature: t,
388            });
389        }
390        // A misspelled protocol is rejected here rather than defaulted, because
391        // silently falling back to `openai` would send chat-completions bytes to a
392        // `/messages` endpoint and report the 404 as the endpoint being down.
393        if let Some(raw) = llm.protocol.as_deref()
394            && parse_protocol(Some(raw)).is_none()
395        {
396            return Err(ConfigError::UnknownProtocol {
397                index,
398                value: raw.to_owned(),
399            });
400        }
401    }
402    Ok(())
403}
404
405#[cfg(test)]
406mod tests;