Skip to main content

codex_chatgpt/
connectors.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::time::Duration;
4
5use crate::chatgpt_client::chatgpt_get_request_with_timeout;
6use crate::chatgpt_client::chatgpt_post_request_with_timeout;
7
8use codex_connectors::AppInfo;
9use codex_connectors::ConnectorDirectoryCacheContext;
10use codex_connectors::ConnectorDirectoryCacheKey;
11use codex_connectors::ConnectorMetadata;
12use codex_connectors::ConnectorMetadataStore;
13use codex_connectors::ConnectorToolSummary;
14use codex_connectors::DirectoryListResponse;
15use codex_connectors::merge::merge_connectors;
16use codex_connectors::merge::merge_plugin_connectors;
17use codex_core::config::Config;
18pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
19pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;
20pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager;
21pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
22pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
23pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
24pub use codex_core::connectors::with_app_enabled_state;
25use codex_login::AuthManager;
26use codex_login::CodexAuth;
27use codex_plugin::AppConnectorId;
28use serde::Deserialize;
29use serde::Serialize;
30
31const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
32const CONNECTOR_METADATA_TIMEOUT: Duration = Duration::from_secs(60);
33const DEFAULT_APPS_PRODUCT_SKU: &str = "codex";
34
35async fn apps_enabled(config: &Config) -> bool {
36    let auth_manager =
37        AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
38    let auth = auth_manager.auth().await;
39    config
40        .features
41        .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
42}
43
44async fn connector_auth(config: &Config) -> anyhow::Result<CodexAuth> {
45    let auth_manager =
46        AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
47    let auth = auth_manager
48        .auth()
49        .await
50        .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;
51    anyhow::ensure!(
52        auth.uses_codex_backend(),
53        "ChatGPT connectors require Codex backend auth"
54    );
55    Ok(auth)
56}
57
58pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
59    if !apps_enabled(config).await {
60        return Ok(Vec::new());
61    }
62    let (connectors_result, accessible_result) = tokio::join!(
63        list_all_connectors(config),
64        list_accessible_connectors_from_mcp_tools(config),
65    );
66    let connectors = connectors_result?;
67    let accessible = accessible_result?;
68    Ok(with_app_enabled_state(
69        merge_connectors_with_accessible(
70            connectors, accessible, /*all_connectors_loaded*/ true,
71        ),
72        config,
73    ))
74}
75
76pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
77    list_all_connectors_with_options(config, /*force_refetch*/ false, &[]).await
78}
79
80pub async fn list_cached_all_connectors(
81    config: &Config,
82    plugin_apps: &[AppConnectorId],
83) -> Option<Vec<AppInfo>> {
84    if !apps_enabled(config).await {
85        return Some(Vec::new());
86    }
87
88    let auth = connector_auth(config).await.ok()?;
89    let cache_context = connector_directory_cache_context(config, &auth);
90    let connectors = codex_connectors::cached_directory_connectors(&cache_context)?;
91    Some(merge_directory_and_plugin_connectors(
92        connectors,
93        plugin_apps,
94    ))
95}
96
97pub async fn list_all_connectors_with_options(
98    config: &Config,
99    force_refetch: bool,
100    plugin_apps: &[AppConnectorId],
101) -> anyhow::Result<Vec<AppInfo>> {
102    if !apps_enabled(config).await {
103        return Ok(Vec::new());
104    }
105    let auth = connector_auth(config).await?;
106    let cache_context = connector_directory_cache_context(config, &auth);
107    let connectors = codex_connectors::list_all_connectors_with_options(
108        cache_context,
109        auth.is_workspace_account(),
110        force_refetch,
111        |path| async move {
112            chatgpt_get_request_with_timeout::<DirectoryListResponse>(
113                config,
114                path,
115                Some(DIRECTORY_CONNECTORS_TIMEOUT),
116            )
117            .await
118        },
119    )
120    .await?;
121    Ok(merge_directory_and_plugin_connectors(
122        connectors,
123        plugin_apps,
124    ))
125}
126
127pub struct ConnectorMetadataReadResult {
128    pub apps: Vec<ConnectorMetadata>,
129    pub missing_app_ids: Vec<String>,
130}
131
132/// Reads display metadata without loading MCP connector tools or runtime state.
133///
134/// The store is created before awaiting the backend request, so a response that arrives after an
135/// account or backend change can only commit to the scope under which it was requested.
136pub async fn read_connector_metadata(
137    config: &Config,
138    auth: &CodexAuth,
139    app_ids: &[String],
140    include_tools: bool,
141) -> anyhow::Result<ConnectorMetadataReadResult> {
142    anyhow::ensure!(
143        auth.uses_codex_backend(),
144        "ChatGPT backend requests require Codex backend auth"
145    );
146    anyhow::ensure!(
147        auth.get_account_id().is_some(),
148        "ChatGPT account ID not available, please re-run codex login"
149    );
150
151    let store = ConnectorMetadataStore::new(
152        config.chatgpt_base_url.clone(),
153        auth.get_account_id(),
154        auth.get_chatgpt_user_id(),
155        auth.is_workspace_account(),
156    );
157    let mut metadata_by_id = store.fresh_records(app_ids, include_tools);
158    let missing_ids = app_ids
159        .iter()
160        .filter(|app_id| !metadata_by_id.contains_key(app_id.as_str()))
161        .cloned()
162        .collect::<Vec<_>>();
163
164    if !missing_ids.is_empty() {
165        let product_sku = config
166            .apps_mcp_product_sku
167            .as_deref()
168            .unwrap_or(DEFAULT_APPS_PRODUCT_SKU);
169        let response: GetAppsResponse = chatgpt_post_request_with_timeout(
170            config,
171            auth,
172            "/ps/apps/batch".to_string(),
173            &GetAppsRequest {
174                app_ids: &missing_ids,
175                include_tools,
176            },
177            CONNECTOR_METADATA_TIMEOUT,
178            product_sku,
179        )
180        .await?;
181        let mut requested_ids = missing_ids.iter().cloned().collect::<HashSet<_>>();
182        let fetched = response
183            .apps
184            .into_iter()
185            .map(batch_app_to_metadata)
186            .filter(|metadata| requested_ids.remove(&metadata.id))
187            .collect::<Vec<_>>();
188        store.commit(&fetched);
189        metadata_by_id.extend(
190            fetched
191                .into_iter()
192                .map(|metadata| (metadata.id.clone(), metadata)),
193        );
194    }
195
196    let mut apps = Vec::new();
197    let mut missing_app_ids = Vec::new();
198    for app_id in app_ids {
199        if let Some(mut metadata) = metadata_by_id.remove(app_id) {
200            if !include_tools {
201                metadata.tool_summaries = None;
202            }
203            apps.push(metadata);
204        } else {
205            missing_app_ids.push(app_id.clone());
206        }
207    }
208
209    Ok(ConnectorMetadataReadResult {
210        apps,
211        missing_app_ids,
212    })
213}
214
215#[derive(Serialize)]
216struct GetAppsRequest<'a> {
217    app_ids: &'a [String],
218    include_tools: bool,
219}
220
221#[derive(Deserialize)]
222struct GetAppsResponse {
223    apps: Vec<BatchApp>,
224}
225
226/// The explicit metadata-only projection of Plugin Service's public app response.
227///
228/// Serde ignores all other backend fields, including full actions, model descriptions, and
229/// runtime state.
230#[derive(Deserialize)]
231struct BatchApp {
232    id: String,
233    name: String,
234    description: Option<String>,
235    icon_url: Option<String>,
236    #[serde(default, rename = "icon_dark_url", alias = "icon_url_dark")]
237    icon_url_dark: Option<String>,
238    #[serde(default)]
239    distribution_channel: Option<String>,
240    #[serde(default)]
241    tools: Option<Vec<BatchAppToolSummary>>,
242}
243
244#[derive(Deserialize)]
245struct BatchAppToolSummary {
246    name: String,
247    title: Option<String>,
248    description: String,
249}
250
251fn batch_app_to_metadata(app: BatchApp) -> ConnectorMetadata {
252    let BatchApp {
253        id,
254        name,
255        description,
256        icon_url,
257        icon_url_dark,
258        distribution_channel,
259        tools,
260    } = app;
261    ConnectorMetadata {
262        id,
263        name,
264        description,
265        icon_url,
266        icon_url_dark,
267        distribution_channel,
268        tool_summaries: tools.map(|tools| {
269            tools
270                .into_iter()
271                .map(|tool| {
272                    let BatchAppToolSummary {
273                        name,
274                        title,
275                        description,
276                    } = tool;
277                    ConnectorToolSummary {
278                        name,
279                        title,
280                        description,
281                    }
282                })
283                .collect()
284        }),
285    }
286}
287
288fn connector_directory_cache_context(
289    config: &Config,
290    auth: &CodexAuth,
291) -> ConnectorDirectoryCacheContext {
292    ConnectorDirectoryCacheContext::new(
293        config.codex_home.to_path_buf(),
294        ConnectorDirectoryCacheKey::new(
295            config.chatgpt_base_url.clone(),
296            auth.get_account_id(),
297            auth.get_chatgpt_user_id(),
298            auth.is_workspace_account(),
299        ),
300    )
301}
302
303fn merge_directory_and_plugin_connectors(
304    connectors: Vec<AppInfo>,
305    plugin_apps: &[AppConnectorId],
306) -> Vec<AppInfo> {
307    merge_plugin_connectors(
308        connectors,
309        plugin_apps
310            .iter()
311            .map(|connector_id| connector_id.0.clone()),
312    )
313}
314
315pub fn connectors_for_plugin_apps(
316    connectors: Vec<AppInfo>,
317    plugin_apps: &[AppConnectorId],
318) -> Vec<AppInfo> {
319    let connectors = merge_plugin_connectors(
320        connectors,
321        plugin_apps
322            .iter()
323            .map(|connector_id| connector_id.0.clone()),
324    );
325    let mut connectors_by_id = connectors
326        .into_iter()
327        .map(|connector| (connector.id.clone(), connector))
328        .collect::<HashMap<_, _>>();
329
330    plugin_apps
331        .iter()
332        .filter_map(|connector_id| connectors_by_id.remove(connector_id.0.as_str()))
333        .collect()
334}
335
336pub fn merge_connectors_with_accessible(
337    connectors: Vec<AppInfo>,
338    accessible_connectors: Vec<AppInfo>,
339    all_connectors_loaded: bool,
340) -> Vec<AppInfo> {
341    let accessible_connectors = if all_connectors_loaded {
342        let connector_ids: HashSet<&str> = connectors
343            .iter()
344            .map(|connector| connector.id.as_str())
345            .collect();
346        accessible_connectors
347            .into_iter()
348            .filter(|connector| connector_ids.contains(connector.id.as_str()))
349            .collect()
350    } else {
351        accessible_connectors
352    };
353    merge_connectors(connectors, accessible_connectors)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use codex_connectors::metadata::connector_install_url;
360    use codex_plugin::AppConnectorId;
361    use pretty_assertions::assert_eq;
362    use serde_json::json;
363
364    #[test]
365    fn batch_app_accepts_missing_optional_metadata() {
366        let app = serde_json::from_value::<BatchApp>(json!({
367            "id": "alpha",
368            "name": "Alpha",
369            "description": "Alpha description",
370            "icon_url": null,
371            "tools": null,
372        }))
373        .expect("valid legacy batch app");
374
375        assert_eq!(
376            batch_app_to_metadata(app),
377            ConnectorMetadata {
378                id: "alpha".to_string(),
379                name: "Alpha".to_string(),
380                description: Some("Alpha description".to_string()),
381                icon_url: None,
382                icon_url_dark: None,
383                distribution_channel: None,
384                tool_summaries: None,
385            }
386        );
387    }
388
389    fn app(id: &str) -> AppInfo {
390        AppInfo {
391            id: id.to_string(),
392            name: id.to_string(),
393            description: None,
394            logo_url: None,
395            logo_url_dark: None,
396            icon_assets: None,
397            icon_dark_assets: None,
398            distribution_channel: None,
399            branding: None,
400            app_metadata: None,
401            labels: None,
402            install_url: None,
403            is_accessible: false,
404            is_enabled: true,
405            plugin_display_names: Vec::new(),
406        }
407    }
408
409    fn merged_app(id: &str, is_accessible: bool) -> AppInfo {
410        AppInfo {
411            id: id.to_string(),
412            name: id.to_string(),
413            description: None,
414            logo_url: None,
415            logo_url_dark: None,
416            icon_assets: None,
417            icon_dark_assets: None,
418            distribution_channel: None,
419            branding: None,
420            app_metadata: None,
421            labels: None,
422            install_url: Some(connector_install_url(id, id)),
423            is_accessible,
424            is_enabled: true,
425            plugin_display_names: Vec::new(),
426        }
427    }
428
429    #[test]
430    fn excludes_accessible_connectors_not_in_all_when_all_loaded() {
431        let merged = merge_connectors_with_accessible(
432            vec![app("alpha")],
433            vec![app("alpha"), app("beta")],
434            /*all_connectors_loaded*/ true,
435        );
436        assert_eq!(merged, vec![merged_app("alpha", /*is_accessible*/ true)]);
437    }
438
439    #[test]
440    fn keeps_accessible_connectors_not_in_all_while_all_loading() {
441        let merged = merge_connectors_with_accessible(
442            vec![app("alpha")],
443            vec![app("alpha"), app("beta")],
444            /*all_connectors_loaded*/ false,
445        );
446        assert_eq!(
447            merged,
448            vec![
449                merged_app("alpha", /*is_accessible*/ true),
450                merged_app("beta", /*is_accessible*/ true)
451            ]
452        );
453    }
454
455    #[test]
456    fn connectors_for_plugin_apps_returns_only_requested_plugin_apps() {
457        let connectors = connectors_for_plugin_apps(
458            vec![app("alpha"), app("beta")],
459            &[
460                AppConnectorId("gmail".to_string()),
461                AppConnectorId("alpha".to_string()),
462                AppConnectorId("gmail".to_string()),
463            ],
464        );
465        assert_eq!(
466            connectors,
467            vec![merged_app("gmail", /*is_accessible*/ false), app("alpha")]
468        );
469    }
470
471    #[test]
472    fn connectors_for_plugin_apps_preserves_formerly_disallowed_plugin_apps() {
473        let connector_id = "asdk_app_6938a94a61d881918ef32cb999ff937c";
474        let connectors =
475            connectors_for_plugin_apps(Vec::new(), &[AppConnectorId(connector_id.to_string())]);
476        assert_eq!(
477            connectors,
478            vec![merged_app(connector_id, /*is_accessible*/ false)]
479        );
480    }
481}