Skip to main content

codex_config/
thread_config.rs

1use std::collections::BTreeMap;
2use std::collections::HashMap;
3use std::future::Future;
4use std::pin::Pin;
5
6use crate::ConfigLayerSource;
7use codex_model_provider_info::ModelProviderInfo;
8use codex_utils_absolute_path::AbsolutePathBuf;
9use thiserror::Error;
10use toml::Value as TomlValue;
11
12use crate::ConfigLayerEntry;
13
14mod remote;
15
16pub use remote::RemoteThreadConfigLoader;
17
18/// Context available to implementations when loading thread-scoped config.
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct ThreadConfigContext {
21    pub thread_id: Option<String>,
22    pub cwd: Option<AbsolutePathBuf>,
23}
24
25/// Config values owned by the service that starts or manages the session.
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct SessionThreadConfig {
28    pub model_provider: Option<String>,
29    pub model_providers: HashMap<String, ModelProviderInfo>,
30    pub features: BTreeMap<String, bool>,
31}
32
33/// Config values owned by the authenticated user.
34#[derive(Clone, Debug, Default, Eq, PartialEq)]
35pub struct UserThreadConfig {}
36
37/// A typed config payload paired with the authority that produced it.
38#[derive(Clone, Debug, PartialEq)]
39pub enum ThreadConfigSource {
40    Session(SessionThreadConfig),
41    User(UserThreadConfig),
42}
43
44/// Stable category for failures returned while loading thread config.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum ThreadConfigLoadErrorCode {
47    Auth,
48    Timeout,
49    Parse,
50    RequestFailed,
51    Internal,
52}
53
54#[derive(Clone, Debug, Eq, Error, PartialEq)]
55#[error("{message}")]
56pub struct ThreadConfigLoadError {
57    code: ThreadConfigLoadErrorCode,
58    message: String,
59    status_code: Option<u16>,
60}
61
62impl ThreadConfigLoadError {
63    pub fn new(
64        code: ThreadConfigLoadErrorCode,
65        status_code: Option<u16>,
66        message: impl Into<String>,
67    ) -> Self {
68        Self {
69            code,
70            message: message.into(),
71            status_code,
72        }
73    }
74
75    pub fn code(&self) -> ThreadConfigLoadErrorCode {
76        self.code
77    }
78}
79
80/// Loads typed config sources for a new thread.
81///
82/// Implementations should fetch only the source-specific config they own and
83/// return typed payloads without applying precedence or merge rules. Callers
84/// are responsible for resolving the returned sources into the effective
85/// runtime config.
86pub trait ThreadConfigLoader: Send + Sync {
87    /// Load source-specific typed config.
88    ///
89    /// Implementations should keep this method focused on fetching and parsing
90    /// their owned sources. Most callers should use [`Self::load_config_layers`]
91    /// so precedence and merging continue through the ordinary config layer
92    /// stack.
93    fn load(
94        &self,
95        context: ThreadConfigContext,
96    ) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>>;
97
98    fn load_config_layers(
99        &self,
100        context: ThreadConfigContext,
101    ) -> ThreadConfigLoaderFuture<'_, Vec<ConfigLayerEntry>> {
102        Box::pin(async move {
103            let sources = self.load(context).await?;
104            sources
105                .into_iter()
106                .map(thread_config_source_to_layer)
107                .collect::<Result<Vec<_>, _>>()
108                .map(|layers| layers.into_iter().flatten().collect())
109        })
110    }
111}
112
113pub type ThreadConfigLoaderFuture<'a, T> =
114    Pin<Box<dyn Future<Output = Result<T, ThreadConfigLoadError>> + Send + 'a>>;
115
116/// Loader backed by a static set of typed thread config sources.
117#[derive(Clone, Debug, Default, PartialEq)]
118pub struct StaticThreadConfigLoader {
119    sources: Vec<ThreadConfigSource>,
120}
121
122impl StaticThreadConfigLoader {
123    pub fn new(sources: Vec<ThreadConfigSource>) -> Self {
124        Self { sources }
125    }
126}
127
128impl ThreadConfigLoader for StaticThreadConfigLoader {
129    fn load(
130        &self,
131        _context: ThreadConfigContext,
132    ) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>> {
133        Box::pin(async { Ok(self.sources.clone()) })
134    }
135}
136
137/// Loader used when no external thread config source is configured.
138#[derive(Clone, Debug, Default)]
139pub struct NoopThreadConfigLoader;
140
141impl ThreadConfigLoader for NoopThreadConfigLoader {
142    fn load(
143        &self,
144        _context: ThreadConfigContext,
145    ) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>> {
146        Box::pin(async { Ok(Vec::new()) })
147    }
148}
149
150fn thread_config_source_to_layer(
151    source: ThreadConfigSource,
152) -> Result<Option<ConfigLayerEntry>, ThreadConfigLoadError> {
153    match source {
154        ThreadConfigSource::Session(config) => {
155            let config = session_thread_config_to_toml(config)?;
156            if is_empty_table(&config) {
157                Ok(None)
158            } else {
159                Ok(Some(ConfigLayerEntry::new(
160                    ConfigLayerSource::SessionFlags,
161                    config,
162                )))
163            }
164        }
165        // UserThreadConfig has no TOML-backed fields yet. When it grows one,
166        // fold it into the existing user layer instead of adding another
167        // ConfigLayerSource variant.
168        ThreadConfigSource::User(_config) => Ok(None),
169    }
170}
171
172fn is_empty_table(config: &TomlValue) -> bool {
173    config.as_table().is_some_and(toml::map::Map::is_empty)
174}
175
176fn session_thread_config_to_toml(
177    config: SessionThreadConfig,
178) -> Result<TomlValue, ThreadConfigLoadError> {
179    let mut table = toml::map::Map::new();
180
181    if let Some(model_provider) = config.model_provider {
182        table.insert(
183            "model_provider".to_string(),
184            TomlValue::String(model_provider),
185        );
186    }
187
188    if !config.model_providers.is_empty() {
189        let model_providers = TomlValue::try_from(config.model_providers).map_err(|err| {
190            ThreadConfigLoadError::new(
191                ThreadConfigLoadErrorCode::Parse,
192                /*status_code*/ None,
193                format!("failed to convert session model providers to config TOML: {err}"),
194            )
195        })?;
196        table.insert("model_providers".to_string(), model_providers);
197    }
198
199    if !config.features.is_empty() {
200        let features = config
201            .features
202            .into_iter()
203            .map(|(feature, enabled)| (feature, TomlValue::Boolean(enabled)))
204            .collect();
205        table.insert("features".to_string(), TomlValue::Table(features));
206    }
207
208    Ok(TomlValue::Table(table))
209}
210
211#[cfg(test)]
212mod tests {
213    use codex_model_provider_info::ModelProviderInfo;
214    use codex_model_provider_info::WireApi;
215    use pretty_assertions::assert_eq;
216
217    use super::*;
218
219    #[tokio::test]
220    async fn loader_returns_session_and_user_sources() {
221        let loader = StaticThreadConfigLoader::new(vec![
222            ThreadConfigSource::Session(SessionThreadConfig {
223                model_provider: Some("local".to_string()),
224                model_providers: HashMap::from([("local".to_string(), test_provider("local"))]),
225                features: BTreeMap::from([("plugins".to_string(), false)]),
226            }),
227            ThreadConfigSource::User(UserThreadConfig::default()),
228        ]);
229
230        let sources = loader
231            .load(ThreadConfigContext {
232                thread_id: Some("thread-1".to_string()),
233                ..Default::default()
234            })
235            .await
236            .expect("thread config loads");
237
238        assert_eq!(
239            sources,
240            vec![
241                ThreadConfigSource::Session(SessionThreadConfig {
242                    model_provider: Some("local".to_string()),
243                    model_providers: HashMap::from([("local".to_string(), test_provider("local"))]),
244                    features: BTreeMap::from([("plugins".to_string(), false)]),
245                }),
246                ThreadConfigSource::User(UserThreadConfig::default()),
247            ]
248        );
249    }
250
251    #[tokio::test]
252    async fn loader_translates_sources_to_config_layers() {
253        let loader = StaticThreadConfigLoader::new(vec![
254            ThreadConfigSource::User(UserThreadConfig::default()),
255            ThreadConfigSource::Session(SessionThreadConfig {
256                model_provider: Some("local".to_string()),
257                model_providers: HashMap::from([("local".to_string(), test_provider("local"))]),
258                features: BTreeMap::from([("plugins".to_string(), false)]),
259            }),
260        ]);
261        let layers = loader
262            .load_config_layers(ThreadConfigContext {
263                cwd: Some(
264                    AbsolutePathBuf::from_absolute_path_checked(
265                        std::env::temp_dir().join("project"),
266                    )
267                    .expect("absolute cwd"),
268                ),
269                ..Default::default()
270            })
271            .await
272            .expect("thread config layers load");
273
274        assert_eq!(
275            layers,
276            vec![ConfigLayerEntry::new(
277                ConfigLayerSource::SessionFlags,
278                toml::toml! {
279                    model_provider = "local"
280
281                    [model_providers.local]
282                    name = "local"
283                    base_url = "http://127.0.0.1:8061/api/codex"
284                    wire_api = "responses"
285                    requires_openai_auth = false
286                    supports_websockets = true
287
288                    [features]
289                    plugins = false
290                }
291                .into()
292            )]
293        );
294    }
295
296    fn test_provider(name: &str) -> ModelProviderInfo {
297        ModelProviderInfo {
298            name: name.to_string(),
299            base_url: Some("http://127.0.0.1:8061/api/codex".to_string()),
300            env_key: None,
301            env_key_instructions: None,
302            experimental_bearer_token: None,
303            auth: None,
304            aws: None,
305            wire_api: WireApi::Responses,
306            query_params: None,
307            http_headers: None,
308            env_http_headers: None,
309            request_max_retries: None,
310            stream_max_retries: None,
311            stream_idle_timeout_ms: None,
312            websocket_connect_timeout_ms: None,
313            requires_openai_auth: false,
314            supports_websockets: true,
315        }
316    }
317}