Skip to main content

codex_ws/
provider.rs

1use std::path::Path;
2
3use rusqlite::Connection;
4use serde::Deserialize;
5use serde_json::Value;
6use thiserror::Error;
7
8/// Codex provider configuration loaded from the local configuration database.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct CodexProvider {
11    name: String,
12    auth_json: String,
13    config_toml: String,
14}
15
16impl CodexProvider {
17    /// Create a Codex provider configuration.
18    ///
19    /// # Arguments
20    ///
21    /// * `name` - Human-readable provider name from the configuration database.
22    /// * `auth_json` - Auth JSON payload for the Codex CLI.
23    /// * `config_toml` - Runtime TOML configuration payload for the Codex CLI.
24    ///
25    /// # Returns
26    ///
27    /// A provider value with owned fields.
28    #[must_use]
29    pub fn new(name: String, auth_json: String, config_toml: String) -> Self {
30        Self {
31            name,
32            auth_json,
33            config_toml,
34        }
35    }
36
37    /// Return the provider name.
38    ///
39    /// # Returns
40    ///
41    /// The provider name as a borrowed string slice.
42    #[must_use]
43    pub fn name(&self) -> &str {
44        &self.name
45    }
46
47    /// Return the provider auth JSON payload.
48    ///
49    /// # Returns
50    ///
51    /// The provider auth JSON payload as a borrowed string slice.
52    #[must_use]
53    pub fn auth_json(&self) -> &str {
54        &self.auth_json
55    }
56
57    /// Return the provider config TOML payload.
58    ///
59    /// # Returns
60    ///
61    /// The provider config TOML payload as a borrowed string slice.
62    #[must_use]
63    pub fn config_toml(&self) -> &str {
64        &self.config_toml
65    }
66}
67
68/// Errors returned while loading Codex provider configuration.
69#[derive(Debug, Error)]
70pub enum ProviderError {
71    /// The configuration database could not be opened or queried.
72    #[error("configuration database error: {0}")]
73    Database(#[from] rusqlite::Error),
74
75    /// The provider settings JSON did not match the expected shape.
76    #[error("invalid settings JSON for provider '{provider_name}': {source}")]
77    SettingsJson {
78        /// Provider name associated with the invalid settings payload.
79        provider_name: String,
80        /// JSON parsing error returned by `serde_json`.
81        source: serde_json::Error,
82    },
83
84    /// The providers table does not expose a supported Codex configuration shape.
85    #[error("providers table must contain a settings_config column")]
86    UnsupportedSchema,
87}
88
89#[derive(Debug, Deserialize)]
90struct ProviderSettingsConfig {
91    auth: Value,
92    config: String,
93}
94
95/// Load all Codex providers from a local configuration database.
96///
97/// # Arguments
98///
99/// * `database_path` - Path to the SQLite database containing a `providers` table.
100///
101/// # Returns
102///
103/// A vector of Codex provider configurations, preserving database row order.
104///
105/// # Errors
106///
107/// Returns [`ProviderError::Database`] when the database cannot be opened or queried.
108/// Returns [`ProviderError::SettingsJson`] when a Codex provider has invalid settings JSON.
109pub fn load_codex_providers(database_path: &Path) -> Result<Vec<CodexProvider>, ProviderError> {
110    let connection = Connection::open(database_path)?;
111    load_codex_providers_from_connection(&connection)
112}
113
114/// Load all Codex providers from an existing SQLite connection.
115///
116/// # Arguments
117///
118/// * `connection` - Open SQLite connection containing a `providers` table.
119///
120/// # Returns
121///
122/// A vector of Codex provider configurations, preserving database row order.
123///
124/// # Errors
125///
126/// Returns [`ProviderError::Database`] when the table cannot be queried.
127/// Returns [`ProviderError::SettingsJson`] when a Codex provider has invalid settings JSON.
128pub fn load_codex_providers_from_connection(
129    connection: &Connection,
130) -> Result<Vec<CodexProvider>, ProviderError> {
131    let columns = provider_table_columns(connection)?;
132    if columns.iter().any(|column| column == "settings_config") {
133        return load_codex_providers_from_settings_config_column(connection);
134    }
135
136    Err(ProviderError::UnsupportedSchema)
137}
138
139fn provider_table_columns(connection: &Connection) -> Result<Vec<String>, ProviderError> {
140    let mut statement = connection.prepare("PRAGMA table_info(providers)")?;
141    let rows = statement.query_map([], |row| row.get::<_, String>(1))?;
142    let mut columns = Vec::new();
143    for row in rows {
144        columns.push(row?);
145    }
146    Ok(columns)
147}
148
149fn load_codex_providers_from_settings_config_column(
150    connection: &Connection,
151) -> Result<Vec<CodexProvider>, ProviderError> {
152    let mut statement = connection.prepare(
153        "SELECT name, settings_config FROM providers WHERE app_type = ?1 ORDER BY rowid ASC",
154    )?;
155    let rows = statement.query_map(["codex"], |row| {
156        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
157    })?;
158
159    let mut providers = Vec::new();
160    for row in rows {
161        let (name, settings_json) = row?;
162        let settings = parse_settings(&name, &settings_json)?;
163        let auth_json = serde_json::to_string_pretty(&settings.auth).map_err(|source| {
164            ProviderError::SettingsJson {
165                provider_name: name.clone(),
166                source,
167            }
168        })?;
169        providers.push(CodexProvider::new(name, auth_json, settings.config));
170    }
171
172    Ok(providers)
173}
174
175fn parse_settings(
176    provider_name: &str,
177    settings_json: &str,
178) -> Result<ProviderSettingsConfig, ProviderError> {
179    serde_json::from_str(settings_json).map_err(|source| ProviderError::SettingsJson {
180        provider_name: provider_name.to_owned(),
181        source,
182    })
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn connection_with_providers() -> Connection {
190        let connection = Connection::open_in_memory().expect("in-memory SQLite should open");
191        connection
192            .execute(
193                "CREATE TABLE providers (
194                    name TEXT NOT NULL,
195                    app_type TEXT NOT NULL,
196                    settings_config TEXT NOT NULL
197                )",
198                [],
199            )
200            .expect("providers table should be created");
201        connection
202    }
203
204    #[test]
205    fn load_codex_providers_filters_and_maps_rows() {
206        let connection = connection_with_providers();
207        connection
208            .execute(
209                "INSERT INTO providers (name, app_type, settings_config) VALUES (?1, ?2, ?3)",
210                [
211                    "primary",
212                    "codex",
213                    r#"{"auth":{"OPENAI_API_KEY":"test-key"},"config":"model = \"gpt-5.5\"\n"}"#,
214                ],
215            )
216            .expect("codex provider row should insert");
217        connection
218            .execute(
219                "INSERT INTO providers (name, app_type, settings_config) VALUES (?1, ?2, ?3)",
220                [
221                    "other",
222                    "claude",
223                    r#"{"auth":{"OPENAI_API_KEY":"ignored"},"config":"ignored"}"#,
224                ],
225            )
226            .expect("non-codex provider row should insert");
227
228        let providers =
229            load_codex_providers_from_connection(&connection).expect("providers should load");
230
231        assert_eq!(
232            providers,
233            vec![CodexProvider::new(
234                "primary".to_owned(),
235                "{\n  \"OPENAI_API_KEY\": \"test-key\"\n}".to_owned(),
236                "model = \"gpt-5.5\"\n".to_owned()
237            )]
238        );
239    }
240
241    #[test]
242    fn load_codex_providers_reports_invalid_settings_json() {
243        let connection = connection_with_providers();
244        connection
245            .execute(
246                "INSERT INTO providers (name, app_type, settings_config) VALUES (?1, ?2, ?3)",
247                ["broken", "codex", "{}"],
248            )
249            .expect("broken provider row should insert");
250
251        let error = load_codex_providers_from_connection(&connection)
252            .expect_err("invalid settings should fail");
253
254        assert!(matches!(
255            error,
256            ProviderError::SettingsJson { provider_name, .. } if provider_name == "broken"
257        ));
258    }
259}