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#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct ThreadConfigContext {
21 pub thread_id: Option<String>,
22 pub cwd: Option<AbsolutePathBuf>,
23}
24
25#[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
35pub struct UserThreadConfig {}
36
37#[derive(Clone, Debug, PartialEq)]
39pub enum ThreadConfigSource {
40 Session(SessionThreadConfig),
41 User(UserThreadConfig),
42}
43
44#[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
80pub trait ThreadConfigLoader: Send + Sync {
87 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#[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#[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 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 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}