Skip to main content

providers_kit/config/
openai.rs

1use reqwest::header::HeaderValue;
2use url::Url;
3
4use super::ApiKey;
5use crate::KitError;
6use crate::error::ValidationViolation;
7
8const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
9
10/// OpenAI credentials and endpoint configuration.
11///
12/// ```
13/// let config = providers_kit::config::OpenAiConfig::new(
14///     providers_kit::config::ApiKey::new("sk-test")?,
15/// );
16/// let _ = config;
17///
18/// # Ok::<(), providers_kit::KitError>(())
19/// ```
20#[derive(Clone)]
21pub struct OpenAiConfig {
22    api_key: ApiKey,
23    base_url: Url,
24    organization: Option<String>,
25    project: Option<String>,
26}
27
28impl OpenAiConfig {
29    /// Creates a configuration for the public OpenAI endpoint.
30    ///
31    /// This only constructs configuration; provider operations perform network I/O.
32    ///
33    /// # Examples
34    ///
35    /// ```
36    /// use providers_kit::config::{ApiKey, OpenAiConfig};
37    /// let config = OpenAiConfig::new(ApiKey::new("example-key")?);
38    /// let _owned_configuration = config;
39    ///
40    /// # Ok::<(), providers_kit::KitError>(())
41    /// ```
42    ///
43    #[must_use]
44    pub fn new(api_key: ApiKey) -> Self {
45        Self {
46            api_key,
47            base_url: Url::parse(DEFAULT_OPENAI_BASE_URL)
48                .expect("crate-owned OpenAI base URL should parse"),
49            organization: None,
50            project: None,
51        }
52    }
53
54    /// Replaces the API base URL.
55    ///
56    /// This only constructs configuration; provider operations perform network I/O.
57    ///
58    /// # Examples
59    ///
60    /// ```
61    /// use providers_kit::config::{ApiKey, OpenAiConfig};
62    /// let config = OpenAiConfig::new(ApiKey::new("example-key")?);
63    /// let config = config.with_base_url("https://example.com/api".parse().unwrap());
64    /// let _owned_configuration = config;
65    ///
66    /// # Ok::<(), providers_kit::KitError>(())
67    /// ```
68    ///
69    #[must_use]
70    pub fn with_base_url(mut self, base_url: Url) -> Self {
71        self.base_url = base_url;
72        self
73    }
74
75    /// Sets the optional organization header.
76    ///
77    /// # Errors
78    ///
79    /// Returns a configuration error for an empty or padded value, or one that cannot be represented as an HTTP header value.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use providers_kit::config::{ApiKey, OpenAiConfig};
85    /// let config = OpenAiConfig::new(ApiKey::new("example-key")?);
86    /// assert!(config.clone().with_organization(" padded ").is_err());
87    /// let _config = config.with_organization("team-1")?;
88    ///
89    /// # Ok::<(), providers_kit::KitError>(())
90    /// ```
91    ///
92    pub fn with_organization(mut self, organization: impl Into<String>) -> Result<Self, KitError> {
93        self.organization = Some(Self::scope(
94            "credentials.organization",
95            organization.into(),
96        )?);
97        Ok(self)
98    }
99
100    /// Sets the optional project header.
101    ///
102    /// # Errors
103    ///
104    /// Returns a configuration error for an empty or padded value, or one that cannot be represented as an HTTP header value.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use providers_kit::config::{ApiKey, OpenAiConfig};
110    /// let config = OpenAiConfig::new(ApiKey::new("example-key")?);
111    /// assert!(config.clone().with_project(" padded ").is_err());
112    /// let _config = config.with_project("team-1")?;
113    ///
114    /// # Ok::<(), providers_kit::KitError>(())
115    /// ```
116    ///
117    pub fn with_project(mut self, project: impl Into<String>) -> Result<Self, KitError> {
118        self.project = Some(Self::scope("credentials.project", project.into())?);
119        Ok(self)
120    }
121
122    pub(crate) fn api_key(&self) -> &ApiKey {
123        &self.api_key
124    }
125
126    pub(crate) fn base_url(&self) -> &Url {
127        &self.base_url
128    }
129
130    pub(crate) fn organization(&self) -> Option<&str> {
131        self.organization.as_deref()
132    }
133
134    pub(crate) fn project(&self) -> Option<&str> {
135        self.project.as_deref()
136    }
137
138    fn scope(label: &'static str, value: String) -> Result<String, KitError> {
139        if value.is_empty() || value.trim() != value || HeaderValue::try_from(&value).is_err() {
140            Err(KitError::config(
141                ValidationViolation::InvalidFormat {
142                    field: label,
143                    rule: "nonblank, unpadded HTTP header value",
144                },
145                format!("{label} must be a nonblank, unpadded HTTP header value"),
146            ))
147        } else {
148            Ok(value)
149        }
150    }
151}