use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct OpiConfig {
pub defaults: DefaultsConfig,
pub thinking: ThinkingConfig,
pub providers: ProvidersConfig,
pub keybindings: KeybindingsConfig,
pub retry: opi_ai::retry::RetryConfig,
pub compaction: CompactionConfigSection,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DefaultsConfig {
pub model: String,
pub max_iterations: u32,
pub tool_timeout_ms: u64,
pub theme: String,
pub allow_mutating_tools: bool,
}
impl Default for DefaultsConfig {
fn default() -> Self {
Self {
model: "anthropic:claude-sonnet-4".into(),
max_iterations: 50,
tool_timeout_ms: 30_000,
theme: "default".into(),
allow_mutating_tools: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ThinkingConfig {
pub enabled: bool,
pub budget_tokens: u32,
}
impl Default for ThinkingConfig {
fn default() -> Self {
Self {
enabled: true,
budget_tokens: 10_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ProvidersConfig {
pub anthropic: AnthropicProviderConfig,
pub openai: GenericProviderConfig,
pub openrouter: OpenRouterProviderConfig,
pub mistral: GenericProviderConfig,
pub openai_responses: GenericProviderConfig,
pub gemini: GenericProviderConfig,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AnthropicProviderConfig {
pub api_key_env: String,
pub base_url: Option<String>,
}
impl Default for AnthropicProviderConfig {
fn default() -> Self {
Self {
api_key_env: "ANTHROPIC_API_KEY".into(),
base_url: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct GenericProviderConfig {
pub api_key_env: String,
pub base_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct OpenRouterProviderConfig {
pub api_key_env: String,
pub base_url: Option<String>,
pub referer: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct KeybindingsConfig {
pub submit: String,
pub abort: String,
pub new_line: String,
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
submit: "enter".into(),
abort: "escape".into(),
new_line: "alt+enter".into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionConfigSection {
pub enabled: bool,
pub threshold_tokens: u64,
}
impl Default for CompactionConfigSection {
fn default() -> Self {
Self {
enabled: true,
threshold_tokens: 100_000,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlConfig {
defaults: TomlDefaults,
thinking: TomlThinking,
providers: TomlProviders,
keybindings: TomlKeybindings,
retry: TomlRetry,
compaction: TomlCompaction,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlDefaults {
model: Option<String>,
max_iterations: Option<u32>,
tool_timeout_ms: Option<u64>,
theme: Option<String>,
allow_mutating_tools: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlThinking {
enabled: Option<bool>,
budget_tokens: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlProviders {
anthropic: TomlAnthropic,
openai: TomlGenericProvider,
openrouter: TomlOpenRouterProvider,
mistral: TomlGenericProvider,
openai_responses: TomlGenericProvider,
gemini: TomlGenericProvider,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlAnthropic {
api_key_env: Option<String>,
base_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlGenericProvider {
api_key_env: Option<String>,
base_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlOpenRouterProvider {
api_key_env: Option<String>,
base_url: Option<String>,
referer: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlKeybindings {
submit: Option<String>,
abort: Option<String>,
new_line: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlRetry {
max_attempts: Option<u32>,
initial_delay_ms: Option<u64>,
max_delay_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
struct TomlCompaction {
enabled: Option<bool>,
threshold_tokens: Option<u64>,
}
impl TomlConfig {
fn merge_into(self, config: &mut OpiConfig) {
if let Some(v) = self.defaults.model {
config.defaults.model = v;
}
if let Some(v) = self.defaults.max_iterations {
config.defaults.max_iterations = v;
}
if let Some(v) = self.defaults.tool_timeout_ms {
config.defaults.tool_timeout_ms = v;
}
if let Some(v) = self.defaults.theme {
config.defaults.theme = v;
}
if let Some(v) = self.defaults.allow_mutating_tools {
config.defaults.allow_mutating_tools = v;
}
if let Some(v) = self.thinking.enabled {
config.thinking.enabled = v;
}
if let Some(v) = self.thinking.budget_tokens {
config.thinking.budget_tokens = v;
}
if let Some(v) = self.providers.anthropic.api_key_env {
config.providers.anthropic.api_key_env = v;
}
if let Some(v) = self.providers.anthropic.base_url {
config.providers.anthropic.base_url = Some(v);
}
if let Some(v) = self.providers.openai.api_key_env {
config.providers.openai.api_key_env = v;
}
if let Some(v) = self.providers.openai.base_url {
config.providers.openai.base_url = Some(v);
}
if let Some(v) = self.providers.openrouter.api_key_env {
config.providers.openrouter.api_key_env = v;
}
if let Some(v) = self.providers.openrouter.base_url {
config.providers.openrouter.base_url = Some(v);
}
if let Some(v) = self.providers.openrouter.referer {
config.providers.openrouter.referer = Some(v);
}
if let Some(v) = self.providers.mistral.api_key_env {
config.providers.mistral.api_key_env = v;
}
if let Some(v) = self.providers.mistral.base_url {
config.providers.mistral.base_url = Some(v);
}
if let Some(v) = self.providers.openai_responses.api_key_env {
config.providers.openai_responses.api_key_env = v;
}
if let Some(v) = self.providers.openai_responses.base_url {
config.providers.openai_responses.base_url = Some(v);
}
if let Some(v) = self.providers.gemini.api_key_env {
config.providers.gemini.api_key_env = v;
}
if let Some(v) = self.providers.gemini.base_url {
config.providers.gemini.base_url = Some(v);
}
if let Some(v) = self.keybindings.submit {
config.keybindings.submit = v;
}
if let Some(v) = self.keybindings.abort {
config.keybindings.abort = v;
}
if let Some(v) = self.keybindings.new_line {
config.keybindings.new_line = v;
}
if let Some(v) = self.retry.max_attempts {
config.retry.max_attempts = v;
}
if let Some(v) = self.retry.initial_delay_ms {
config.retry.initial_delay_ms = v;
}
if let Some(v) = self.retry.max_delay_ms {
config.retry.max_delay_ms = v;
}
if let Some(v) = self.compaction.enabled {
config.compaction.enabled = v;
}
if let Some(v) = self.compaction.threshold_tokens {
config.compaction.threshold_tokens = v;
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to parse config file {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: Box<toml::de::Error>,
},
#[error("failed to read config file {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub fn load_config_file(path: &Path) -> Result<OpiConfig, ConfigError> {
if !path.exists() {
return Ok(OpiConfig::default());
}
let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
parse_toml(&contents, path)
}
fn parse_toml(contents: &str, path: &Path) -> Result<OpiConfig, ConfigError> {
let raw: TomlConfig = toml::from_str(contents).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
let mut config = OpiConfig::default();
raw.merge_into(&mut config);
Ok(config)
}
pub struct ConfigSource {
pub cli_model: Option<String>,
pub config_path: Option<PathBuf>,
pub env_model: Option<String>,
pub project_dir: Option<PathBuf>,
pub user_config_path: Option<PathBuf>,
}
pub fn resolve_config(source: ConfigSource) -> Result<OpiConfig, ConfigError> {
let user_path = source.user_config_path.unwrap_or_else(user_config_path);
let mut config = load_config_file(&user_path)?;
if let Some(project_dir) = &source.project_dir {
let project_config_path = project_dir.join(".opi").join("config.toml");
let project_raw = load_raw_config(&project_config_path)?;
project_raw.merge_into(&mut config);
}
if let Some(config_path) = &source.config_path {
if !config_path.exists() {
return Err(ConfigError::Read {
path: config_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"),
});
}
let cli_raw = load_raw_config(config_path)?;
cli_raw.merge_into(&mut config);
}
if source.config_path.is_none()
&& let Some(env_model) = &source.env_model
{
config.defaults.model = env_model.clone();
}
if let Some(cli_model) = &source.cli_model {
config.defaults.model = cli_model.clone();
}
Ok(config)
}
fn load_raw_config(path: &Path) -> Result<TomlConfig, ConfigError> {
if !path.exists() {
return Ok(TomlConfig::default());
}
let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
toml::from_str(&contents).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})
}
pub fn user_config_path() -> PathBuf {
if cfg!(windows) {
std::env::var("APPDATA")
.map(|p| PathBuf::from(p).join("opi").join("config.toml"))
.unwrap_or_else(|_| PathBuf::from(".opi").join("config.toml"))
} else {
dirs_home()
.map(|h| h.join(".config").join("opi").join("config.toml"))
.unwrap_or_else(|| PathBuf::from(".opi").join("config.toml"))
}
}
fn dirs_home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}