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;
pub mod site;
pub use backend::{BackendKind, LlmConfig, ReasoningEffort};
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;
#[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 {
pub fn providers(&self) -> Vec<&LlmConfig> {
self.llm.iter().filter(|p| p.enabled).collect()
}
}
#[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),
#[error(
"[[llm]] #{} in file order: temperature {temperature} is outside the allowed range 0.0..=2.0",
index + 1
)]
Temperature { index: usize, temperature: f32 },
#[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,
#[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 },
#[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 },
#[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 },
#[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 },
#[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),
#[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 },
}
pub fn default_config_path() -> PathBuf {
PathBuf::from("drep.toml")
}
pub const DEFAULT_USER_AGENT: &str = concat!("drep/", env!("CARGO_PKG_VERSION"));
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
}
pub fn parse_protocol(raw: Option<&str>) -> Option<ApiProtocol> {
match raw {
None => Some(ApiProtocol::default()),
Some(name) => ApiProtocol::from_wire(name),
}
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
load_with_env(path, |name| std::env::var(name))
}
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))?;
let mut tree: Value = toml::from_str(&content).map_err(|err: toml::de::Error| {
ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
})?;
if let Some(field) = site_only_field(&tree) {
return Err(ConfigError::SiteOnlyField {
path: path.to_path_buf(),
field,
});
}
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)
}
fn site_only_field(tree: &Value) -> Option<&'static str> {
site::SITE_ONLY_FIELDS
.iter()
.copied()
.find(|field| tree.get(field).is_some())
}
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()));
}
if config.providers().is_empty() {
return Err(ConfigError::NoEnabledProviders(path.to_path_buf()));
}
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 });
}
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 });
}
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,
});
}
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;