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