Skip to main content

openai_rust/
config.rs

1use std::{collections::BTreeMap, env, time::Duration};
2
3use url::Url;
4
5use crate::{
6    DEFAULT_BASE_URL,
7    error::{ErrorKind, OpenAIError},
8};
9
10pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
11pub const OPENAI_BASE_URL_ENV: &str = "OPENAI_BASE_URL";
12pub const OPENAI_ORG_ID_ENV: &str = "OPENAI_ORG_ID";
13pub const OPENAI_PROJECT_ID_ENV: &str = "OPENAI_PROJECT_ID";
14pub const OPENAI_WEBHOOK_SECRET_ENV: &str = "OPENAI_WEBHOOK_SECRET";
15
16/// Shared immutable client configuration scaffold.
17#[derive(Clone, Debug, Default, Eq, PartialEq)]
18pub struct ClientConfig {
19    /// Optional explicit API key override.
20    pub api_key: Option<String>,
21    /// Optional explicit base URL override.
22    pub base_url: Option<String>,
23    /// Optional explicit organization header.
24    pub organization: Option<String>,
25    /// Optional explicit project header.
26    pub project: Option<String>,
27    /// Optional custom user-agent value.
28    pub user_agent: Option<String>,
29    /// Optional webhook secret for signature verification helpers.
30    pub webhook_secret: Option<String>,
31    /// Optional client-level timeout override.
32    pub timeout: Option<Duration>,
33    /// Optional retry-budget override.
34    pub max_retries: Option<u32>,
35}
36
37/// Fully-resolved client configuration after environment loading and validation.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct ResolvedClientConfig {
40    pub api_key: String,
41    pub base_url: String,
42    pub organization: Option<String>,
43    pub project: Option<String>,
44    pub user_agent: String,
45    pub timeout: Duration,
46    pub max_retries: u32,
47}
48
49impl ClientConfig {
50    /// Captures a configuration snapshot from the current process environment.
51    pub fn from_env() -> Self {
52        Self {
53            api_key: env::var(OPENAI_API_KEY_ENV).ok(),
54            base_url: env::var(OPENAI_BASE_URL_ENV).ok(),
55            organization: env::var(OPENAI_ORG_ID_ENV).ok(),
56            project: env::var(OPENAI_PROJECT_ID_ENV).ok(),
57            user_agent: None,
58            webhook_secret: env::var(OPENAI_WEBHOOK_SECRET_ENV).ok(),
59            timeout: None,
60            max_retries: None,
61        }
62    }
63
64    /// Freezes environment defaults into this config without overriding explicit values.
65    pub fn with_env_defaults(&self) -> Self {
66        let env_config = Self::from_env();
67        Self {
68            api_key: self.api_key.clone().or(env_config.api_key),
69            base_url: self.base_url.clone().or(env_config.base_url),
70            organization: self.organization.clone().or(env_config.organization),
71            project: self.project.clone().or(env_config.project),
72            user_agent: self.user_agent.clone(),
73            webhook_secret: self.webhook_secret.clone().or(env_config.webhook_secret),
74            timeout: self.timeout,
75            max_retries: self.max_retries,
76        }
77    }
78
79    /// Resolves explicit configuration against environment defaults.
80    pub fn resolve(&self) -> Result<ResolvedClientConfig, OpenAIError> {
81        let api_key = self
82            .api_key
83            .as_deref()
84            .map(str::trim)
85            .filter(|value| !value.is_empty())
86            .ok_or_else(|| {
87                OpenAIError::new(
88                    ErrorKind::Configuration,
89                    "missing OpenAI API key: provide api_key or set OPENAI_API_KEY",
90                )
91            })?
92            .to_string();
93
94        let base_url = normalize_base_url(self.base_url.as_deref().unwrap_or(DEFAULT_BASE_URL))?;
95
96        let organization = normalize_optional(self.organization.as_deref());
97        let project = normalize_optional(self.project.as_deref());
98        let user_agent = build_user_agent(self.user_agent.as_deref());
99        let timeout = self
100            .timeout
101            .unwrap_or(crate::core::timeout::TimeoutPolicy::DEFAULT_REQUEST_TIMEOUT);
102        let max_retries = self
103            .max_retries
104            .unwrap_or(crate::core::retry::RetryPolicy::DEFAULT_MAX_RETRIES);
105
106        Ok(ResolvedClientConfig {
107            api_key,
108            base_url,
109            organization,
110            project,
111            user_agent,
112            timeout,
113            max_retries,
114        })
115    }
116}
117
118impl ResolvedClientConfig {
119    /// Builds default request headers for authenticated REST calls.
120    pub fn headers(&self) -> BTreeMap<String, String> {
121        let mut headers = BTreeMap::new();
122        headers.insert(
123            String::from("authorization"),
124            format!("Bearer {}", self.api_key),
125        );
126        headers.insert(String::from("user-agent"), self.user_agent.clone());
127        if let Some(organization) = &self.organization {
128            headers.insert(String::from("openai-organization"), organization.clone());
129        }
130        if let Some(project) = &self.project {
131            headers.insert(String::from("openai-project"), project.clone());
132        }
133        headers
134    }
135}
136
137pub(crate) fn normalize_base_url(input: &str) -> Result<String, OpenAIError> {
138    let trimmed = input.trim();
139    let candidate = if trimmed.is_empty() {
140        DEFAULT_BASE_URL
141    } else {
142        trimmed
143    };
144
145    let parsed = Url::parse(candidate).map_err(|error| {
146        OpenAIError::new(
147            ErrorKind::Configuration,
148            format!("invalid OpenAI base URL `{candidate}`: {error}"),
149        )
150    })?;
151
152    match parsed.scheme() {
153        "http" | "https" => {}
154        other => {
155            return Err(OpenAIError::new(
156                ErrorKind::Configuration,
157                format!("invalid OpenAI base URL scheme `{other}`: expected http or https"),
158            ));
159        }
160    }
161
162    Ok(candidate.trim_end_matches('/').to_string())
163}
164
165pub(crate) fn build_user_agent(custom: Option<&str>) -> String {
166    let default = format!("openai-rust/{}", env!("CARGO_PKG_VERSION"));
167    match normalize_optional(custom) {
168        Some(custom) => format!("{custom} {default}"),
169        None => default,
170    }
171}
172
173fn normalize_optional(value: Option<&str>) -> Option<String> {
174    value
175        .map(str::trim)
176        .filter(|value| !value.is_empty())
177        .map(ToOwned::to_owned)
178}