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;