Skip to main content

opi_coding_agent/
config.rs

1//! TOML config loading (S9.1/S9.1.1).
2//!
3//! Loads and resolves opi configuration with precedence:
4//! CLI > env > project config > user config > built-in defaults.
5//!
6//! Phase 1 fields: model, max_iterations, tool_timeout_ms, theme,
7//! thinking, providers.anthropic.api_key_env.
8//!
9//! Phase 2 fields: providers.{openai,openrouter,mistral,openai_responses,gemini}
10//! config with api_key_env, base_url, and OpenRouter-specific referer.
11
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16// ---------------------------------------------------------------------------
17// Resolved config (public API — all fields present)
18// ---------------------------------------------------------------------------
19
20/// Top-level opi configuration (fully resolved).
21#[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/// `[defaults]` section.
34#[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/// `[thinking]` section.
58#[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/// `[providers]` section.
74#[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/// `[providers.anthropic]` section.
88#[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/// Generic provider config (api_key_env + optional base_url + optional proxy).
106#[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/// OpenRouter-specific provider config.
114#[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/// `[providers.bedrock]` section.
123#[derive(Debug, Clone, PartialEq, Default)]
124pub struct BedrockProviderConfig {
125    /// Explicit access key ID (overrides env var).
126    pub access_key_id: Option<String>,
127    /// Env var name for secret access key (default: AWS_SECRET_ACCESS_KEY).
128    pub secret_access_key_env: Option<String>,
129    /// Env var name for session token (default: AWS_SESSION_TOKEN).
130    pub session_token_env: Option<String>,
131    /// AWS region (default: us-east-1).
132    pub region: Option<String>,
133    /// AWS config profile name for credential file lookup.
134    pub profile: Option<String>,
135    /// Override base URL for Bedrock runtime API.
136    pub base_url: Option<String>,
137    /// Proxy configuration.
138    pub proxy: Option<ProviderProxyConfig>,
139}
140
141/// `[providers.azure]` section.
142#[derive(Debug, Clone, PartialEq, Default)]
143pub struct AzureProviderConfig {
144    /// Env var name for the Azure OpenAI API key (default: AZURE_OPENAI_API_KEY).
145    pub api_key_env: String,
146    /// Azure OpenAI endpoint (e.g. `https://myresource.openai.azure.com`).
147    pub endpoint: Option<String>,
148    /// Azure API version (default: 2024-06-01).
149    pub api_version: Option<String>,
150    /// Deployment names to advertise in --list-models.
151    pub deployments: Vec<String>,
152    /// Proxy configuration.
153    pub proxy: Option<ProviderProxyConfig>,
154}
155
156/// `[providers.vertex]` section.
157#[derive(Debug, Clone, PartialEq, Default)]
158pub struct VertexProviderConfig {
159    /// Env var name for the OAuth2 access token (default: VERTEX_ACCESS_TOKEN).
160    pub access_token_env: String,
161    /// GCP project ID.
162    pub project: Option<String>,
163    /// GCP location/region (e.g. `us-central1`).
164    pub location: Option<String>,
165    /// Model names to advertise in --list-models.
166    pub models: Vec<String>,
167    /// Override base URL for Vertex AI API.
168    pub base_url: Option<String>,
169    /// Proxy configuration.
170    pub proxy: Option<ProviderProxyConfig>,
171}
172
173/// Per-provider proxy configuration from `[providers.*.proxy]`.
174#[derive(Debug, Clone, PartialEq)]
175pub struct ProviderProxyConfig {
176    pub url: String,
177    pub no_proxy: Option<String>,
178}
179
180/// `[keybindings]` section.
181#[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/// `[compaction]` section.
199#[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/// `[extensions]` section.
215#[derive(Debug, Clone, PartialEq, Default)]
216pub struct ExtensionsConfig {
217    pub paths: Vec<PathBuf>,
218}
219
220/// `[packages]` section.
221#[derive(Debug, Clone, PartialEq, Default)]
222pub struct PackagesConfig {
223    pub paths: Vec<PathBuf>,
224}
225
226// ---------------------------------------------------------------------------
227// TOML deserialization structs (Option fields detect presence)
228// ---------------------------------------------------------------------------
229
230#[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// ---------------------------------------------------------------------------
585// Error type
586// ---------------------------------------------------------------------------
587
588/// Errors from config loading and parsing.
589#[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
605// ---------------------------------------------------------------------------
606// Loading
607// ---------------------------------------------------------------------------
608
609/// Load and parse a TOML config file. Returns defaults if the file doesn't
610/// exist. Returns a clear error for malformed TOML.
611pub 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
632// ---------------------------------------------------------------------------
633// Resolution
634// ---------------------------------------------------------------------------
635
636/// External configuration sources for precedence resolution.
637pub struct ConfigSource {
638    /// Model from CLI `--model` flag.
639    pub cli_model: Option<String>,
640    /// Explicit config path from CLI `--config` flag.
641    pub config_path: Option<PathBuf>,
642    /// Model from env var `OPI_MODEL`.
643    pub env_model: Option<String>,
644    /// Project root directory (for `.opi/config.toml`).
645    pub project_dir: Option<PathBuf>,
646    /// User config file path override (for testing). When `None`, uses
647    /// the platform-default path from `user_config_path()`.
648    pub user_config_path: Option<PathBuf>,
649}
650
651/// Resolve configuration from all sources with correct precedence:
652/// CLI > env > project config > user config > built-in defaults.
653pub 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    // --config file overrides project and user config
664    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    // Env model only applies when --config was NOT explicitly provided,
676    // so that an explicit config file's model takes precedence over env.
677    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
704/// Return the platform-specific user config file path.
705pub fn user_config_path() -> PathBuf {
706    user_config_dir().join("config.toml")
707}
708
709/// Return the platform-specific user config directory.
710///
711/// This is the directory where `config.toml` and global context files
712/// (`AGENTS.md`, `CLAUDE.md`) live.
713///
714/// - Windows: `%APPDATA%\opi\`
715/// - Unix: `~/.config/opi/`
716pub 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
732// ---------------------------------------------------------------------------
733// HTTP client construction from proxy config
734// ---------------------------------------------------------------------------
735
736/// Build an HTTP client with optional proxy configuration.
737///
738/// When an explicit proxy config is provided, it is used directly.
739/// Otherwise, falls back to environment variable detection
740/// (`HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`).
741pub 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}