1use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16#[derive(Debug, Clone, PartialEq, Default)]
22pub struct OpiConfig {
23 pub defaults: DefaultsConfig,
24 pub thinking: ThinkingConfig,
25 pub providers: ProvidersConfig,
26 pub keybindings: KeybindingsConfig,
27 pub retry: opi_ai::retry::RetryConfig,
28 pub compaction: CompactionConfigSection,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct DefaultsConfig {
34 pub model: String,
35 pub max_iterations: u32,
36 pub tool_timeout_ms: u64,
37 pub max_image_bytes: u64,
38 pub theme: String,
39 pub allow_mutating_tools: bool,
40}
41
42impl Default for DefaultsConfig {
43 fn default() -> Self {
44 Self {
45 model: "anthropic:claude-sonnet-4".into(),
46 max_iterations: 50,
47 tool_timeout_ms: 30_000,
48 max_image_bytes: crate::image::DEFAULT_MAX_IMAGE_BYTES,
49 theme: "default".into(),
50 allow_mutating_tools: false,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq)]
57pub struct ThinkingConfig {
58 pub enabled: bool,
59 pub budget_tokens: u32,
60}
61
62impl Default for ThinkingConfig {
63 fn default() -> Self {
64 Self {
65 enabled: true,
66 budget_tokens: 10_000,
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Default)]
73pub struct ProvidersConfig {
74 pub anthropic: AnthropicProviderConfig,
75 pub openai: GenericProviderConfig,
76 pub openrouter: OpenRouterProviderConfig,
77 pub mistral: GenericProviderConfig,
78 pub openai_responses: GenericProviderConfig,
79 pub gemini: GenericProviderConfig,
80 pub bedrock: BedrockProviderConfig,
81 pub azure: AzureProviderConfig,
82 pub vertex: VertexProviderConfig,
83}
84
85#[derive(Debug, Clone, PartialEq)]
87pub struct AnthropicProviderConfig {
88 pub api_key_env: String,
89 pub base_url: Option<String>,
90 pub proxy: Option<ProviderProxyConfig>,
91}
92
93impl Default for AnthropicProviderConfig {
94 fn default() -> Self {
95 Self {
96 api_key_env: "ANTHROPIC_API_KEY".into(),
97 base_url: None,
98 proxy: None,
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Default)]
105pub struct GenericProviderConfig {
106 pub api_key_env: String,
107 pub base_url: Option<String>,
108 pub proxy: Option<ProviderProxyConfig>,
109}
110
111#[derive(Debug, Clone, PartialEq, Default)]
113pub struct OpenRouterProviderConfig {
114 pub api_key_env: String,
115 pub base_url: Option<String>,
116 pub referer: Option<String>,
117 pub proxy: Option<ProviderProxyConfig>,
118}
119
120#[derive(Debug, Clone, PartialEq, Default)]
122pub struct BedrockProviderConfig {
123 pub access_key_id: Option<String>,
125 pub secret_access_key_env: Option<String>,
127 pub session_token_env: Option<String>,
129 pub region: Option<String>,
131 pub profile: Option<String>,
133 pub base_url: Option<String>,
135 pub proxy: Option<ProviderProxyConfig>,
137}
138
139#[derive(Debug, Clone, PartialEq, Default)]
141pub struct AzureProviderConfig {
142 pub api_key_env: String,
144 pub endpoint: Option<String>,
146 pub api_version: Option<String>,
148 pub deployments: Vec<String>,
150 pub proxy: Option<ProviderProxyConfig>,
152}
153
154#[derive(Debug, Clone, PartialEq, Default)]
156pub struct VertexProviderConfig {
157 pub access_token_env: String,
159 pub project: Option<String>,
161 pub location: Option<String>,
163 pub models: Vec<String>,
165 pub base_url: Option<String>,
167 pub proxy: Option<ProviderProxyConfig>,
169}
170
171#[derive(Debug, Clone, PartialEq)]
173pub struct ProviderProxyConfig {
174 pub url: String,
175 pub no_proxy: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq)]
180pub struct KeybindingsConfig {
181 pub submit: String,
182 pub abort: String,
183 pub new_line: String,
184}
185
186impl Default for KeybindingsConfig {
187 fn default() -> Self {
188 Self {
189 submit: "enter".into(),
190 abort: "escape".into(),
191 new_line: "alt+enter".into(),
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq)]
198pub struct CompactionConfigSection {
199 pub enabled: bool,
200 pub threshold_tokens: u64,
201}
202
203impl Default for CompactionConfigSection {
204 fn default() -> Self {
205 Self {
206 enabled: true,
207 threshold_tokens: 100_000,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Deserialize, Default)]
217#[serde(default)]
218struct TomlConfig {
219 defaults: TomlDefaults,
220 thinking: TomlThinking,
221 providers: TomlProviders,
222 keybindings: TomlKeybindings,
223 retry: TomlRetry,
224 compaction: TomlCompaction,
225}
226
227#[derive(Debug, Clone, Deserialize, Default)]
228#[serde(default)]
229struct TomlDefaults {
230 model: Option<String>,
231 max_iterations: Option<u32>,
232 tool_timeout_ms: Option<u64>,
233 max_image_bytes: Option<u64>,
234 theme: Option<String>,
235 allow_mutating_tools: Option<bool>,
236}
237
238#[derive(Debug, Clone, Deserialize, Default)]
239#[serde(default)]
240struct TomlThinking {
241 enabled: Option<bool>,
242 budget_tokens: Option<u32>,
243}
244
245#[derive(Debug, Clone, Deserialize, Default)]
246#[serde(default)]
247struct TomlProviders {
248 anthropic: TomlAnthropic,
249 bedrock: TomlBedrockProvider,
250 openai: TomlGenericProvider,
251 openrouter: TomlOpenRouterProvider,
252 mistral: TomlGenericProvider,
253 openai_responses: TomlGenericProvider,
254 gemini: TomlGenericProvider,
255 azure: TomlAzureProvider,
256 vertex: TomlVertexProvider,
257}
258
259#[derive(Debug, Clone, Deserialize, Default)]
260#[serde(default)]
261struct TomlAnthropic {
262 api_key_env: Option<String>,
263 base_url: Option<String>,
264 proxy: Option<TomlProxy>,
265}
266
267#[derive(Debug, Clone, Deserialize, Default)]
268#[serde(default)]
269struct TomlBedrockProvider {
270 access_key_id: Option<String>,
271 secret_access_key_env: Option<String>,
272 session_token_env: Option<String>,
273 region: Option<String>,
274 profile: Option<String>,
275 base_url: Option<String>,
276 proxy: Option<TomlProxy>,
277}
278
279#[derive(Debug, Clone, Deserialize, Default)]
280#[serde(default)]
281struct TomlAzureProvider {
282 api_key_env: Option<String>,
283 endpoint: Option<String>,
284 api_version: Option<String>,
285 deployments: Option<Vec<String>>,
286 proxy: Option<TomlProxy>,
287}
288
289#[derive(Debug, Clone, Deserialize, Default)]
290#[serde(default)]
291struct TomlVertexProvider {
292 access_token_env: Option<String>,
293 project: Option<String>,
294 location: Option<String>,
295 models: Option<Vec<String>>,
296 base_url: Option<String>,
297 proxy: Option<TomlProxy>,
298}
299
300#[derive(Debug, Clone, Deserialize, Default)]
301#[serde(default)]
302struct TomlGenericProvider {
303 api_key_env: Option<String>,
304 base_url: Option<String>,
305 proxy: Option<TomlProxy>,
306}
307
308#[derive(Debug, Clone, Deserialize, Default)]
309#[serde(default)]
310struct TomlOpenRouterProvider {
311 api_key_env: Option<String>,
312 base_url: Option<String>,
313 referer: Option<String>,
314 proxy: Option<TomlProxy>,
315}
316
317#[derive(Debug, Clone, Deserialize, Default)]
318#[serde(default)]
319struct TomlProxy {
320 url: Option<String>,
321 no_proxy: Option<String>,
322}
323
324#[derive(Debug, Clone, Deserialize, Default)]
325#[serde(default)]
326struct TomlKeybindings {
327 submit: Option<String>,
328 abort: Option<String>,
329 new_line: Option<String>,
330}
331
332#[derive(Debug, Clone, Deserialize, Default)]
333#[serde(default)]
334struct TomlRetry {
335 max_attempts: Option<u32>,
336 initial_delay_ms: Option<u64>,
337 max_delay_ms: Option<u64>,
338}
339
340#[derive(Debug, Clone, Deserialize, Default)]
341#[serde(default)]
342struct TomlCompaction {
343 enabled: Option<bool>,
344 threshold_tokens: Option<u64>,
345}
346
347impl TomlConfig {
348 fn merge_into(self, config: &mut OpiConfig) {
349 if let Some(v) = self.defaults.model {
350 config.defaults.model = v;
351 }
352 if let Some(v) = self.defaults.max_iterations {
353 config.defaults.max_iterations = v;
354 }
355 if let Some(v) = self.defaults.tool_timeout_ms {
356 config.defaults.tool_timeout_ms = v;
357 }
358 if let Some(v) = self.defaults.max_image_bytes {
359 config.defaults.max_image_bytes = v;
360 }
361 if let Some(v) = self.defaults.theme {
362 config.defaults.theme = v;
363 }
364 if let Some(v) = self.defaults.allow_mutating_tools {
365 config.defaults.allow_mutating_tools = v;
366 }
367 if let Some(v) = self.thinking.enabled {
368 config.thinking.enabled = v;
369 }
370 if let Some(v) = self.thinking.budget_tokens {
371 config.thinking.budget_tokens = v;
372 }
373 if let Some(v) = self.providers.anthropic.api_key_env {
374 config.providers.anthropic.api_key_env = v;
375 }
376 if let Some(v) = self.providers.anthropic.base_url {
377 config.providers.anthropic.base_url = Some(v);
378 }
379 if let Some(p) = self.providers.anthropic.proxy
380 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
381 {
382 config.providers.anthropic.proxy = Some(ProviderProxyConfig {
383 url,
384 no_proxy: p.no_proxy,
385 });
386 }
387 if let Some(v) = self.providers.bedrock.access_key_id {
388 config.providers.bedrock.access_key_id = Some(v);
389 }
390 if let Some(v) = self.providers.bedrock.secret_access_key_env {
391 config.providers.bedrock.secret_access_key_env = Some(v);
392 }
393 if let Some(v) = self.providers.bedrock.session_token_env {
394 config.providers.bedrock.session_token_env = Some(v);
395 }
396 if let Some(v) = self.providers.bedrock.region {
397 config.providers.bedrock.region = Some(v);
398 }
399 if let Some(v) = self.providers.bedrock.profile {
400 config.providers.bedrock.profile = Some(v);
401 }
402 if let Some(v) = self.providers.bedrock.base_url {
403 config.providers.bedrock.base_url = Some(v);
404 }
405 if let Some(p) = self.providers.bedrock.proxy
406 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
407 {
408 config.providers.bedrock.proxy = Some(ProviderProxyConfig {
409 url,
410 no_proxy: p.no_proxy,
411 });
412 }
413 if let Some(v) = self.providers.azure.api_key_env {
414 config.providers.azure.api_key_env = v;
415 }
416 if let Some(v) = self.providers.azure.endpoint {
417 config.providers.azure.endpoint = Some(v);
418 }
419 if let Some(v) = self.providers.azure.api_version {
420 config.providers.azure.api_version = Some(v);
421 }
422 if let Some(v) = self.providers.azure.deployments {
423 config.providers.azure.deployments = v;
424 }
425 if let Some(p) = self.providers.azure.proxy
426 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
427 {
428 config.providers.azure.proxy = Some(ProviderProxyConfig {
429 url,
430 no_proxy: p.no_proxy,
431 });
432 }
433 if let Some(v) = self.providers.vertex.access_token_env {
434 config.providers.vertex.access_token_env = v;
435 }
436 if let Some(v) = self.providers.vertex.project {
437 config.providers.vertex.project = Some(v);
438 }
439 if let Some(v) = self.providers.vertex.location {
440 config.providers.vertex.location = Some(v);
441 }
442 if let Some(v) = self.providers.vertex.models {
443 config.providers.vertex.models = v;
444 }
445 if let Some(v) = self.providers.vertex.base_url {
446 config.providers.vertex.base_url = Some(v);
447 }
448 if let Some(p) = self.providers.vertex.proxy
449 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
450 {
451 config.providers.vertex.proxy = Some(ProviderProxyConfig {
452 url,
453 no_proxy: p.no_proxy,
454 });
455 }
456 if let Some(v) = self.providers.openai.api_key_env {
457 config.providers.openai.api_key_env = v;
458 }
459 if let Some(v) = self.providers.openai.base_url {
460 config.providers.openai.base_url = Some(v);
461 }
462 if let Some(p) = self.providers.openai.proxy
463 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
464 {
465 config.providers.openai.proxy = Some(ProviderProxyConfig {
466 url,
467 no_proxy: p.no_proxy,
468 });
469 }
470 if let Some(v) = self.providers.openrouter.api_key_env {
471 config.providers.openrouter.api_key_env = v;
472 }
473 if let Some(v) = self.providers.openrouter.base_url {
474 config.providers.openrouter.base_url = Some(v);
475 }
476 if let Some(v) = self.providers.openrouter.referer {
477 config.providers.openrouter.referer = Some(v);
478 }
479 if let Some(p) = self.providers.openrouter.proxy
480 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
481 {
482 config.providers.openrouter.proxy = Some(ProviderProxyConfig {
483 url,
484 no_proxy: p.no_proxy,
485 });
486 }
487 if let Some(v) = self.providers.mistral.api_key_env {
488 config.providers.mistral.api_key_env = v;
489 }
490 if let Some(v) = self.providers.mistral.base_url {
491 config.providers.mistral.base_url = Some(v);
492 }
493 if let Some(p) = self.providers.mistral.proxy
494 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
495 {
496 config.providers.mistral.proxy = Some(ProviderProxyConfig {
497 url,
498 no_proxy: p.no_proxy,
499 });
500 }
501 if let Some(v) = self.providers.openai_responses.api_key_env {
502 config.providers.openai_responses.api_key_env = v;
503 }
504 if let Some(v) = self.providers.openai_responses.base_url {
505 config.providers.openai_responses.base_url = Some(v);
506 }
507 if let Some(p) = self.providers.openai_responses.proxy
508 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
509 {
510 config.providers.openai_responses.proxy = Some(ProviderProxyConfig {
511 url,
512 no_proxy: p.no_proxy,
513 });
514 }
515 if let Some(v) = self.providers.gemini.api_key_env {
516 config.providers.gemini.api_key_env = v;
517 }
518 if let Some(v) = self.providers.gemini.base_url {
519 config.providers.gemini.base_url = Some(v);
520 }
521 if let Some(p) = self.providers.gemini.proxy
522 && let Some(url) = p.url.filter(|s| !s.trim().is_empty())
523 {
524 config.providers.gemini.proxy = Some(ProviderProxyConfig {
525 url,
526 no_proxy: p.no_proxy,
527 });
528 }
529 if let Some(v) = self.keybindings.submit {
530 config.keybindings.submit = v;
531 }
532 if let Some(v) = self.keybindings.abort {
533 config.keybindings.abort = v;
534 }
535 if let Some(v) = self.keybindings.new_line {
536 config.keybindings.new_line = v;
537 }
538 if let Some(v) = self.retry.max_attempts {
539 config.retry.max_attempts = v;
540 }
541 if let Some(v) = self.retry.initial_delay_ms {
542 config.retry.initial_delay_ms = v;
543 }
544 if let Some(v) = self.retry.max_delay_ms {
545 config.retry.max_delay_ms = v;
546 }
547 if let Some(v) = self.compaction.enabled {
548 config.compaction.enabled = v;
549 }
550 if let Some(v) = self.compaction.threshold_tokens {
551 config.compaction.threshold_tokens = v;
552 }
553 }
554}
555
556#[derive(Debug, thiserror::Error)]
562pub enum ConfigError {
563 #[error("failed to parse config file {path}: {source}")]
564 Parse {
565 path: PathBuf,
566 #[source]
567 source: Box<toml::de::Error>,
568 },
569 #[error("failed to read config file {path}: {source}")]
570 Read {
571 path: PathBuf,
572 #[source]
573 source: std::io::Error,
574 },
575}
576
577pub fn load_config_file(path: &Path) -> Result<OpiConfig, ConfigError> {
584 if !path.exists() {
585 return Ok(OpiConfig::default());
586 }
587 let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
588 path: path.to_path_buf(),
589 source,
590 })?;
591 parse_toml(&contents, path)
592}
593
594fn parse_toml(contents: &str, path: &Path) -> Result<OpiConfig, ConfigError> {
595 let raw: TomlConfig = toml::from_str(contents).map_err(|source| ConfigError::Parse {
596 path: path.to_path_buf(),
597 source: Box::new(source),
598 })?;
599 let mut config = OpiConfig::default();
600 raw.merge_into(&mut config);
601 Ok(config)
602}
603
604pub struct ConfigSource {
610 pub cli_model: Option<String>,
612 pub config_path: Option<PathBuf>,
614 pub env_model: Option<String>,
616 pub project_dir: Option<PathBuf>,
618 pub user_config_path: Option<PathBuf>,
621}
622
623pub fn resolve_config(source: ConfigSource) -> Result<OpiConfig, ConfigError> {
626 let user_path = source.user_config_path.unwrap_or_else(user_config_path);
627 let mut config = load_config_file(&user_path)?;
628
629 if let Some(project_dir) = &source.project_dir {
630 let project_config_path = project_dir.join(".opi").join("config.toml");
631 let project_raw = load_raw_config(&project_config_path)?;
632 project_raw.merge_into(&mut config);
633 }
634
635 if let Some(config_path) = &source.config_path {
637 if !config_path.exists() {
638 return Err(ConfigError::Read {
639 path: config_path.clone(),
640 source: std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"),
641 });
642 }
643 let cli_raw = load_raw_config(config_path)?;
644 cli_raw.merge_into(&mut config);
645 }
646
647 if source.config_path.is_none()
650 && let Some(env_model) = &source.env_model
651 {
652 config.defaults.model = env_model.clone();
653 }
654
655 if let Some(cli_model) = &source.cli_model {
656 config.defaults.model = cli_model.clone();
657 }
658
659 Ok(config)
660}
661
662fn load_raw_config(path: &Path) -> Result<TomlConfig, ConfigError> {
663 if !path.exists() {
664 return Ok(TomlConfig::default());
665 }
666 let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
667 path: path.to_path_buf(),
668 source,
669 })?;
670 toml::from_str(&contents).map_err(|source| ConfigError::Parse {
671 path: path.to_path_buf(),
672 source: Box::new(source),
673 })
674}
675
676pub fn user_config_path() -> PathBuf {
678 user_config_dir().join("config.toml")
679}
680
681pub fn user_config_dir() -> PathBuf {
689 if cfg!(windows) {
690 std::env::var("APPDATA")
691 .map(|p| PathBuf::from(p).join("opi"))
692 .unwrap_or_else(|_| PathBuf::from(".opi"))
693 } else {
694 dirs_home()
695 .map(|h| h.join(".config").join("opi"))
696 .unwrap_or_else(|| PathBuf::from(".opi"))
697 }
698}
699
700fn dirs_home() -> Option<PathBuf> {
701 std::env::var("HOME").ok().map(PathBuf::from)
702}
703
704pub fn build_http_client(
714 proxy_config: Option<&ProviderProxyConfig>,
715) -> Result<std::sync::Arc<opi_ai::http::HttpClient>, reqwest::Error> {
716 let mut builder = opi_ai::http::HttpClientBuilder::new();
717 if let Some(proxy) = proxy_config {
718 builder = builder.proxy(opi_ai::http::ProxyConfig {
719 url: Some(proxy.url.clone()),
720 no_proxy: proxy.no_proxy.clone(),
721 });
722 } else {
723 let env_proxy = opi_ai::http::proxy_from_env();
724 if env_proxy.url.is_some() {
725 builder = builder.proxy(env_proxy);
726 }
727 }
728 builder.build().map(std::sync::Arc::new)
729}