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)]
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: 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 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> {
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)?;
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 });
}
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;