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;