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;