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}
30
31/// `[defaults]` section.
32#[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/// `[thinking]` section.
56#[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/// `[providers]` section.
72#[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/// `[providers.anthropic]` section.
86#[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/// Generic provider config (api_key_env + optional base_url + optional proxy).
104#[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/// OpenRouter-specific provider config.
112#[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/// `[providers.bedrock]` section.
121#[derive(Debug, Clone, PartialEq, Default)]
122pub struct BedrockProviderConfig {
123    /// Explicit access key ID (overrides env var).
124    pub access_key_id: Option<String>,
125    /// Env var name for secret access key (default: AWS_SECRET_ACCESS_KEY).
126    pub secret_access_key_env: Option<String>,
127    /// Env var name for session token (default: AWS_SESSION_TOKEN).
128    pub session_token_env: Option<String>,
129    /// AWS region (default: us-east-1).
130    pub region: Option<String>,
131    /// AWS config profile name for credential file lookup.
132    pub profile: Option<String>,
133    /// Override base URL for Bedrock runtime API.
134    pub base_url: Option<String>,
135    /// Proxy configuration.
136    pub proxy: Option<ProviderProxyConfig>,
137}
138
139/// `[providers.azure]` section.
140#[derive(Debug, Clone, PartialEq, Default)]
141pub struct AzureProviderConfig {
142    /// Env var name for the Azure OpenAI API key (default: AZURE_OPENAI_API_KEY).
143    pub api_key_env: String,
144    /// Azure OpenAI endpoint (e.g. `https://myresource.openai.azure.com`).
145    pub endpoint: Option<String>,
146    /// Azure API version (default: 2024-06-01).
147    pub api_version: Option<String>,
148    /// Deployment names to advertise in --list-models.
149    pub deployments: Vec<String>,
150    /// Proxy configuration.
151    pub proxy: Option<ProviderProxyConfig>,
152}
153
154/// `[providers.vertex]` section.
155#[derive(Debug, Clone, PartialEq, Default)]
156pub struct VertexProviderConfig {
157    /// Env var name for the OAuth2 access token (default: VERTEX_ACCESS_TOKEN).
158    pub access_token_env: String,
159    /// GCP project ID.
160    pub project: Option<String>,
161    /// GCP location/region (e.g. `us-central1`).
162    pub location: Option<String>,
163    /// Model names to advertise in --list-models.
164    pub models: Vec<String>,
165    /// Override base URL for Vertex AI API.
166    pub base_url: Option<String>,
167    /// Proxy configuration.
168    pub proxy: Option<ProviderProxyConfig>,
169}
170
171/// Per-provider proxy configuration from `[providers.*.proxy]`.
172#[derive(Debug, Clone, PartialEq)]
173pub struct ProviderProxyConfig {
174    pub url: String,
175    pub no_proxy: Option<String>,
176}
177
178/// `[keybindings]` section.
179#[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/// `[compaction]` section.
197#[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// ---------------------------------------------------------------------------
213// TOML deserialization structs (Option fields detect presence)
214// ---------------------------------------------------------------------------
215
216#[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// ---------------------------------------------------------------------------
557// Error type
558// ---------------------------------------------------------------------------
559
560/// Errors from config loading and parsing.
561#[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
577// ---------------------------------------------------------------------------
578// Loading
579// ---------------------------------------------------------------------------
580
581/// Load and parse a TOML config file. Returns defaults if the file doesn't
582/// exist. Returns a clear error for malformed TOML.
583pub 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
604// ---------------------------------------------------------------------------
605// Resolution
606// ---------------------------------------------------------------------------
607
608/// External configuration sources for precedence resolution.
609pub struct ConfigSource {
610    /// Model from CLI `--model` flag.
611    pub cli_model: Option<String>,
612    /// Explicit config path from CLI `--config` flag.
613    pub config_path: Option<PathBuf>,
614    /// Model from env var `OPI_MODEL`.
615    pub env_model: Option<String>,
616    /// Project root directory (for `.opi/config.toml`).
617    pub project_dir: Option<PathBuf>,
618    /// User config file path override (for testing). When `None`, uses
619    /// the platform-default path from `user_config_path()`.
620    pub user_config_path: Option<PathBuf>,
621}
622
623/// Resolve configuration from all sources with correct precedence:
624/// CLI > env > project config > user config > built-in defaults.
625pub 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    // --config file overrides project and user config
636    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    // Env model only applies when --config was NOT explicitly provided,
648    // so that an explicit config file's model takes precedence over env.
649    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
676/// Return the platform-specific user config file path.
677pub fn user_config_path() -> PathBuf {
678    user_config_dir().join("config.toml")
679}
680
681/// Return the platform-specific user config directory.
682///
683/// This is the directory where `config.toml` and global context files
684/// (`AGENTS.md`, `CLAUDE.md`) live.
685///
686/// - Windows: `%APPDATA%\opi\`
687/// - Unix: `~/.config/opi/`
688pub 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
704// ---------------------------------------------------------------------------
705// HTTP client construction from proxy config
706// ---------------------------------------------------------------------------
707
708/// Build an HTTP client with optional proxy configuration.
709///
710/// When an explicit proxy config is provided, it is used directly.
711/// Otherwise, falls back to environment variable detection
712/// (`HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`).
713pub 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}