drep-ai 3.0.0

A local commit gate: runs the linters your repo configures, and sends changed code to an LLM for review
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
//! TOML configuration.
//!
//! One file per repository, conventionally `drep.toml` at the root. The shape
//! is deliberate: every field has a documented default, so a partial file
//! works. Missing keys are not an error - the section just inherits the
//! default. Providers are declared as `[[llm]]`, an ordered array of tables:
//! a preference order, tried head first, each enabled entry a fallback for the
//! one before it.
//!
//! The two things this module owns that are not obvious from the field list:
//!
//! - **`${VAR}` expansion.** An api key (or any string value) can name an
//!   environment variable instead of holding the secret. The file gets
//!   committed; the secret does not. An unset variable is an error rather
//!   than an empty string, because a silent empty credential produces a
//!   confusing 401 instead of a clear "API_KEY is not set".
//! - **`max_tokens` defaults to None**, meaning no cap is sent to the model.
//!   Modern reasoning models ship 256k-1M context, and inventing a ceiling
//!   truncates them mid-thought. The option stays available for capping
//!   spend.
//!
//! ## The second layer
//!
//! This file is per-repository and `drep init` gitignores it, so a control
//! written here is per-developer and opt-in. [`site`] is the layer above it: a
//! machine-level policy file a checkout can tighten but never loosen.
//!
//! [`load`] and `validate` know nothing about it and take no site argument.
//! The clamp is applied by the caller, after `load` returns, which is what keeps
//! [`ConfigError`] a statement about this file alone - every one of its messages
//! numbers `[[llm]]` entries in *this* file's order, and a bare `#2` that could
//! mean either file is exactly the ambiguity those messages exist to avoid.

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};

use open_agent::ApiProtocol;
use serde::Deserialize;
use thiserror::Error;
use toml::Value;

mod backend;
mod env;
// A submodule at its own path rather than a re-export: `site::load`,
// `site::default_path` and `site::PATH_VAR` would each collide with a name
// already here, and `config::load` against `config::site::load` is exactly the
// distinction a caller must not blur.
pub mod site;
pub use backend::{BackendKind, LlmConfig, ReasoningEffort};
// Re-exported at the parent's path rather than behind `config::env::`: `doctor`
// and `auth` both call these, and moving them was a file-size split, not a
// change of contract.
use env::{disabled_provider_indices, expand_env_except};
pub use env::{env_var_refs, env_var_refs_in, required_env_var_refs};

pub const DEFAULT_MAX_REVIEW_ROUNDS: u32 = 3;

/// The whole configuration tree, rooted at the file.
///
/// `llm` is an **array of tables** (`[[llm]]`), not a single `[llm]` section,
/// and the list is a *preference order*: [`Self::providers`] is the failover
/// chain.
///
/// `#[serde(default)]` means a file with an empty body deserializes
/// successfully; `validate` is what then rejects it, because a config
/// declaring no provider cannot run the mandatory LLM layer.
///
/// `deny_unknown_fields` for the reason [`LlmConfig`] carries it: a misspelled
/// `max_reveiw_rounds` was accepted, dropped, and run at the default, which is a
/// file that reads as configured and is not. `site_only_field` runs against the
/// raw tree before this deserialization, so `SiteOnlyField` still answers for a
/// policy key rather than being swallowed by a generic unknown-key message.
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub max_review_rounds: u32,
    pub llm: Vec<LlmConfig>,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            max_review_rounds: DEFAULT_MAX_REVIEW_ROUNDS,
            llm: Vec::new(),
        }
    }
}

impl Config {
    /// The failover chain: every enabled provider, in file order.
    ///
    /// The single definition of "which providers are in play". `enabled` is
    /// an opt-*out*, so a disabled entry is skipped wherever it sits - a
    /// disabled head falls through to the entry below it rather than
    /// producing `NotConfigured`, which is what parking the local model was
    /// always meant to do.
    ///
    /// A `Vec` rather than an iterator because every caller wants a length or
    /// an index (the chain numbers its providers in error messages) and the
    /// list is at most a handful of entries.
    pub fn providers(&self) -> Vec<&LlmConfig> {
        self.llm.iter().filter(|p| p.enabled).collect()
    }
}

/// What went wrong reading or validating the configuration.
#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("could not read {0}: {1}")]
    Io(PathBuf, std::io::Error),

    #[error("could not parse {0}: {1}")]
    Parse(PathBuf, String),

    #[error("environment variable `{0}` is not set (referenced by `{1}`)")]
    EnvVarUnset(String, String),

    #[error("environment variable `{0}` is not valid UTF-8 (referenced by `{1}`)")]
    EnvVarNotUnicode(String, String),

    /// The index is the zero-based position in the file; the message renders
    /// it one-based, and says "in file order" because the *chain* numbers only
    /// the enabled entries - with a disabled head the two differ, and
    /// `[[llm]] #1` meaning different tables in the same file is worse than
    /// either convention alone.
    #[error(
        "[[llm]] #{} in file order: temperature {temperature} is outside the allowed range 0.0..=2.0",
        index + 1
    )]
    Temperature { index: usize, temperature: f32 },

    /// `max_concurrent = 0` builds a semaphore with no permits, so every
    /// request waits for one forever. Rejected at load rather than clamped: a
    /// silent bump to 1 would run a gate at a concurrency the user did not ask
    /// for, and the alternative - the documented "callers are expected to set a
    /// positive value" - is a hang with no message at all.
    #[error("[[llm]] #{} in file order: max_concurrent must be at least 1", index + 1)]
    ZeroConcurrency { index: usize },

    #[error("[[llm]] #{} in file order: timeout_secs must be at least 1", index + 1)]
    ZeroTimeout { index: usize },

    #[error("[[llm]] #{} in file order: max_tokens must be at least 1 when set", index + 1)]
    ZeroMaxTokens { index: usize },

    #[error("max_review_rounds must be at least 1")]
    ZeroReviewRounds,

    /// Rejected rather than defaulted: falling back to `openai` would post
    /// chat-completions bytes to a `/messages` endpoint, and the resulting 404
    /// reads as "the provider is down" rather than "this line has a typo".
    #[error(
        "[[llm]] #{} in file order: unknown protocol `{value}`; expected `openai` or `anthropic`",
        index + 1
    )]
    UnknownProtocol { index: usize, value: String },

    #[error(
        "[[llm]] #{} in file order: `{name}` cannot be sent as an HTTP header name",
        index + 1
    )]
    UnusableHeaderName { index: usize, name: String },

    /// Names the header, never its value: the value is the half that carries a
    /// tenant or project token, and an error message is the one place it would
    /// escape into a terminal, a CI log or a bug report. The rule
    /// `KeyCommandError` already follows for a credential helper's output.
    #[error(
        "[[llm]] #{} in file order: the value configured for header `{name}` \
         contains a character that cannot be sent in a header",
        index + 1
    )]
    UnusableHeaderValue { index: usize, name: String },

    /// Both spellings, never either value: which of the two is the credential is
    /// exactly what this error cannot know, so it quotes neither, the way
    /// `UnusableHeaderValue` above quotes none.
    #[error(
        "[[llm]] #{} in file order: `{first}` and `{second}` are one HTTP header name written \
         twice; header names are case-insensitive, so only one of them is sent, and which one \
         is decided by their byte order rather than by anything this file says - remove the \
         spelling you did not mean",
        index + 1
    )]
    DuplicateHeaderName {
        index: usize,
        first: String,
        second: String,
    },

    #[error(
        "[[llm]] #{} in file order: unknown backend `{value}`; expected `http` or `codex`",
        index + 1
    )]
    UnknownBackend { index: usize, value: String },

    #[error(
        "[[llm]] #{} in file order: unknown reasoning_effort `{value}`; expected `minimal`, `low`, `medium`, `high`, or `xhigh`",
        index + 1
    )]
    UnknownReasoningEffort { index: usize, value: String },

    /// Both credential fields answer the same question, so a file setting both
    /// has said two things. Rejected rather than resolved by precedence: the
    /// user who wrote the command wrote it to be run, and a silent "the literal
    /// wins" leaves them debugging a stale credential the file says nothing
    /// about.
    #[error(
        "[[llm]] #{} in file order: `api_key` and `api_key_command` are both set; remove one, \
         because a key that is already there is never re-minted by a command",
        index + 1
    )]
    AmbiguousApiKey { index: usize },

    /// An argv with no first element names no program. Rejected at load because
    /// the alternative is discovering it inside the gate, at the point where
    /// there is nothing to run and nothing useful to say about why.
    #[error(
        "[[llm]] #{} in file order: api_key_command is empty; it must name a program to run, \
         as an argv array such as [\"print-token\", \"--audience\", \"gateway\"]",
        index + 1
    )]
    EmptyApiKeyCommand { index: usize },

    #[error(
        "[[llm]] #{} in file order: backend `{backend}` does not support `{field}`",
        index + 1
    )]
    BackendField {
        index: usize,
        backend: &'static str,
        field: &'static str,
    },

    #[error(
        "[[llm]] #{} in file order: backend `{backend}` requires `{field}`",
        index + 1
    )]
    BackendMissingField {
        index: usize,
        backend: &'static str,
        field: &'static str,
    },

    #[error(
        "{0} declares no `[[llm]]` provider; drep 2.x has no deterministic-only mode. \
         Run `drep init` to write one."
    )]
    NoProviders(PathBuf),

    #[error(
        "every `[[llm]]` provider in {0} has `enabled = false`; drep 2.x has no \
         deterministic-only mode. Re-enable one, or run `drep init` to write another."
    )]
    NoEnabledProviders(PathBuf),

    /// Rejected rather than ignored, which is the one behaviour that would be
    /// worse than either: serde drops an unknown key without a word, so a
    /// developer reads `refuse_markers` in their own config, believes the
    /// repository is protected, and every review still ships its source. It is
    /// refused here rather than honoured because `drep init` gitignores this
    /// file - a copy of the control would be per-developer, and a refusal a
    /// developer can delete is not one.
    #[error(
        "{path} sets `{field}`, which is machine site policy and is read only from the site \
         policy file - {machine} on this platform, or the file `drep doctor` names if this \
         machine keeps it elsewhere; `drep init` gitignores {path}, so a copy of the field there \
         would be per-developer and could be deleted by the developer it constrains",
        machine = site::machine_path().display()
    )]
    SiteOnlyField { path: PathBuf, field: &'static str },
}

/// The conventional config file location: `drep.toml` in the current directory.
///
/// Hardcoded to "drep.toml" in cwd by design: `drep init` writes this exact
/// path, so changing it here would break the contract with the init command.
pub fn default_config_path() -> PathBuf {
    PathBuf::from("drep.toml")
}

/// What drep calls itself to an endpoint when the config names no `User-Agent`.
///
/// The crate version rather than a bare name, because the useful question a
/// gateway operator asks of a user agent is which build sent this. reqwest sends
/// none at all by default, and an endpoint that logs or bills per client cannot
/// attribute a request that carries none.
pub const DEFAULT_USER_AGENT: &str = concat!("drep/", env!("CARGO_PKG_VERSION"));

/// The headers a provider will actually send: drep's defaults, then the
/// configured table over the top.
///
/// The single statement of that precedence, for the reason [`crate::auth`] keeps
/// one statement of the credential order: the request path, `LlmClient`'s
/// `Debug` and `drep doctor` all have to answer the same question about the same
/// entry, and when the default was applied inside the request instead, they
/// answered differently. A config naming one header printed one and sent two,
/// and a config naming none printed an empty set and sent a `User-Agent`. The
/// operator debugging a gateway 403 is asking `doctor` exactly that question,
/// and the case where it was silent was the case where they had not set one.
///
/// Case-insensitive, because HTTP header names are: a configured `user-agent`
/// replaces the default rather than joining it, which is what the SDK's own
/// `HeaderMap` would do at send time anyway. Default-against-configured is the
/// only collision this function has to settle - `validate` has already refused a
/// pair of configured spellings, because there the two names are equally the
/// user's and picking one is not the caller's to do.
pub fn effective_headers(configured: &BTreeMap<String, String>) -> BTreeMap<String, String> {
    let mut headers = BTreeMap::new();
    if !configured
        .keys()
        .any(|name| name.eq_ignore_ascii_case("user-agent"))
    {
        headers.insert("User-Agent".to_owned(), DEFAULT_USER_AGENT.to_owned());
    }
    headers.extend(
        configured
            .iter()
            .map(|(name, value)| (name.clone(), value.clone())),
    );
    headers
}

/// Parses a `protocol =` value, or `None` when it names nothing the SDK speaks.
///
/// The single definition of what a protocol name means, and it owns none of the
/// names: [`ApiProtocol::from_wire`] is the SDK's own parser, so drep cannot
/// come to disagree with the layer that acts on the answer. An absent value is
/// the default protocol rather than an error, which is what keeps every config
/// written before 0.9.0 valid.
pub fn parse_protocol(raw: Option<&str>) -> Option<ApiProtocol> {
    match raw {
        None => Some(ApiProtocol::default()),
        Some(name) => ApiProtocol::from_wire(name),
    }
}

/// Load and validate `path`.
///
/// A missing file is an error: the caller decides whether that is fatal
/// (the binary should bail) or expected (a first-run where `drep init` has
/// not been run yet). Inventing defaults for a file that does not exist
/// would silently mask a broken install.
///
/// `${VAR}` expansion happens before validation, so an unset variable is
/// reported with the variable's name rather than as a downstream parse
/// failure inside the substituted text.
pub fn load(path: &Path) -> Result<Config, ConfigError> {
    load_with_env(path, |name| std::env::var(name))
}

/// Test seam for `${VAR}` expansion without mutating process-global state.
///
/// `std::env::set_var` is unsafe in edition 2024 because the test harness is
/// multithreaded. Passing the lookup also proves validation sees the expanded
/// value through the same production path rather than recreating that ordering
/// in a test.
pub(crate) fn load_with_env<F>(path: &Path, lookup: F) -> Result<Config, ConfigError>
where
    F: Fn(&str) -> Result<String, std::env::VarError>,
{
    let content =
        std::fs::read_to_string(path).map_err(|err| ConfigError::Io(path.to_path_buf(), err))?;

    // `toml::from_str::<Value>` and `<Value as FromStr>::from_str` are not
    // interchangeable despite producing the same type. The former runs the
    // document parser; the latter runs `ValueDeserializer`, which
    // parses a single TOML *value* (`42`, `"text"`) and rejects a whole document
    // with "unexpected content, expected nothing".
    let mut tree: Value = toml::from_str(&content).map_err(|err: toml::de::Error| {
        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
    })?;

    // Read off the raw tree, the way `disabled_provider_indices` and
    // `backend::explicit_fields` are, and before deserialization because this
    // pass owns both the variant and the wording. `Config` denies unknown
    // fields, so a `refuse_markers` left to serde is refused - as a
    // `ConfigError::Parse` reading "unknown field, expected `max_review_rounds`
    // or `llm`", which tells a developer their line is a typo to delete rather
    // than that the field is machine policy and lives in another file.
    if let Some(field) = site_only_field(&tree) {
        return Err(ConfigError::SiteOnlyField {
            path: path.to_path_buf(),
            field,
        });
    }

    // Disabled providers are pruned from expansion, not from the tree: a
    // parked entry is inert, so an unset `${OPENROUTER_API_KEY}` in the cloud
    // block a user just switched off must not refuse to load the file. It stays
    // in `Config.llm` with its `${VAR}` unexpanded, which nothing reads - only
    // `providers()` is consulted, and it filters the entry out.
    let disabled = disabled_provider_indices(&tree);
    expand_env_except(&mut tree, path, &disabled, &lookup)?;
    let explicit_fields = backend::explicit_fields(&tree);

    let config: Config = tree.try_into().map_err(|err: toml::de::Error| {
        ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
    })?;

    validate(&config, path, &explicit_fields)?;
    Ok(config)
}

/// The site-policy key this file declared, if it declared one.
///
/// The list itself lives beside `SiteConfig` in [`site::SITE_ONLY_FIELDS`],
/// because it is a statement about that type's fields and the decision about a
/// new one belongs where the field is added. This function used to spell
/// `tree.get("refuse_markers")` here, which meant a policy field added in the
/// other module was refused nowhere, dropped silently from a `drep.toml` that
/// named it, and believed by the developer who wrote it.
fn site_only_field(tree: &Value) -> Option<&'static str> {
    site::SITE_ONLY_FIELDS
        .iter()
        .copied()
        .find(|field| tree.get(field).is_some())
}

/// Validate what serde cannot enforce from the type alone.
///
/// An empty provider list is rejected here rather than tolerated and caught
/// later at the LLM boundary: the LLM layer is mandatory in 2.x, so a config
/// naming no provider is a file that can never produce a passing run, and the
/// earliest place to say so is the place that read the file.
///
/// The index is carried into the temperature error because with several
/// providers "temperature 3.0 is out of range" does not say *which* one.
fn validate(
    config: &Config,
    path: &Path,
    explicit_fields: &[backend::ExplicitFields],
) -> Result<(), ConfigError> {
    if config.max_review_rounds == 0 {
        return Err(ConfigError::ZeroReviewRounds);
    }
    if config.llm.is_empty() {
        return Err(ConfigError::NoProviders(path.to_path_buf()));
    }
    // Distinct from `NoProviders` because the fix is different: one needs a
    // provider written, the other needs one re-enabled. Both are caught here
    // rather than at the LLM boundary so the message can name the file.
    if config.providers().is_empty() {
        return Err(ConfigError::NoEnabledProviders(path.to_path_buf()));
    }
    // Disabled entries are skipped. `enabled = false` means "this entry is
    // inert", and refusing to load the file because a *parked* provider names
    // an out-of-range temperature contradicts that in the one place a user
    // would notice: they parked it precisely to stop it mattering.
    for (index, llm) in config.llm.iter().enumerate().filter(|(_, l)| l.enabled) {
        backend::validate(
            llm,
            explicit_fields.get(index).copied().unwrap_or_default(),
            index,
        )?;

        if llm.max_concurrent == 0 {
            return Err(ConfigError::ZeroConcurrency { index });
        }
        if llm.timeout_secs == 0 {
            return Err(ConfigError::ZeroTimeout { index });
        }
        if llm.max_tokens == Some(0) {
            return Err(ConfigError::ZeroMaxTokens { index });
        }
        // Checked for every backend rather than only for HTTP, so the rule holds
        // above the `continue` below. `backend::validate` has already rejected
        // `api_key_command` on a Codex entry by name, so reaching here with one
        // means the backend can use it.
        if llm.api_key.is_some() && llm.api_key_command.is_some() {
            return Err(ConfigError::AmbiguousApiKey { index });
        }
        if llm.api_key_command.as_ref().is_some_and(Vec::is_empty) {
            return Err(ConfigError::EmptyApiKeyCommand { index });
        }
        // Rejected here rather than left to the request, for the reason the
        // misspelled protocol below is: a header drep cannot encode fails
        // identically on every file of the run, and `LlmError::NotConfigured`
        // neither fails over nor sticks, so a 200-file diff reported a typo two
        // hundred times - each one rendered as a transport failure, which reads
        // as the endpoint being down.
        //
        // After `${VAR}` expansion, so what is checked is what will be sent: a
        // token whose expansion carries a stray control character is the case a
        // name-only check would pass and every request would then fail. The
        // parse is `http`'s own, through the same types the SDK builds its
        // `HeaderMap` from, so the two cannot come to disagree about what is
        // sendable.
        //
        // `folded` is keyed the way HTTP compares header names, which is what
        // makes two `BTreeMap` keys one header. A pair of spellings is refused
        // rather than resolved, for the reason `AmbiguousApiKey` above is: the
        // SDK's `HeaderMap` keeps the last insertion and this map is walked in
        // byte order, so `Authorization` and `authorization` both configured
        // means the credential that goes out was chosen by ASCII - while
        // `doctor` and both `Debug` impls go on listing the one that does not.
        let mut folded: HashMap<reqwest::header::HeaderName, &String> = HashMap::new();
        for (name, value) in &llm.headers {
            let parsed =
                reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
                    ConfigError::UnusableHeaderName {
                        index,
                        name: name.clone(),
                    }
                })?;
            if reqwest::header::HeaderValue::from_bytes(value.as_bytes()).is_err() {
                return Err(ConfigError::UnusableHeaderValue {
                    index,
                    name: name.clone(),
                });
            }
            if let Some(first) = folded.insert(parsed, name) {
                return Err(ConfigError::DuplicateHeaderName {
                    index,
                    first: first.clone(),
                    second: name.clone(),
                });
            }
        }

        if llm.backend != BackendKind::Http {
            continue;
        }
        if let Some(t) = llm.temperature
            && !(0.0..=2.0).contains(&t)
        {
            return Err(ConfigError::Temperature {
                index,
                temperature: t,
            });
        }
        // A misspelled protocol is rejected here rather than defaulted, because
        // silently falling back to `openai` would send chat-completions bytes to a
        // `/messages` endpoint and report the 404 as the endpoint being down.
        if let Some(raw) = llm.protocol.as_deref()
            && parse_protocol(Some(raw)).is_none()
        {
            return Err(ConfigError::UnknownProtocol {
                index,
                value: raw.to_owned(),
            });
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests;