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;