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#[derive(Clone, Debug, Default, Eq, PartialEq)]
18pub struct ClientConfig {
19 pub api_key: Option<String>,
21 pub base_url: Option<String>,
23 pub organization: Option<String>,
25 pub project: Option<String>,
27 pub user_agent: Option<String>,
29 pub webhook_secret: Option<String>,
31 pub timeout: Option<Duration>,
33 pub max_retries: Option<u32>,
35}
36
37#[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 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 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 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 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}