From 95845cf6ceb85a5793aeb227b5a03056cb508756 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 27 Mar 2026 13:04:34 -0700 Subject: [PATCH 1/6] fix: disable plugins in SDK integration tests (#16036) ## Why The TypeScript SDK tests create a fresh `CODEX_HOME` for each Jest case and delete it during teardown. That cleanup has been flaking because the real `codex` binary can still be doing background curated-plugin startup sync under `.tmp/plugins-clone-*`, which races the test harness's recursive delete and leaves `ENOTEMPTY` failures behind. This path is unrelated to what the SDK tests are exercising, so letting plugin startup run during these tests only adds nondeterministic filesystem activity. This showed up recently in the `sdk` CI lane for [#16031](https://github.com/openai/codex/pull/16031). ## What Changed - updated `sdk/typescript/tests/testCodex.ts` to merge test config through a single helper - disabled `features.plugins` unconditionally for SDK integration tests so the CLI does not start curated-plugin sync in the temporary `CODEX_HOME` - preserved other explicit feature overrides from individual tests while forcing `plugins` back to `false` - kept the existing mock-provider override behavior intact for SSE-backed tests ## Verification - `pnpm test --runInBand` - `pnpm lint` --- sdk/typescript/tests/testCodex.ts | 44 +++++++++++++++++++------------ 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/sdk/typescript/tests/testCodex.ts b/sdk/typescript/tests/testCodex.ts index d73b519b65..a4cac8b143 100644 --- a/sdk/typescript/tests/testCodex.ts +++ b/sdk/typescript/tests/testCodex.ts @@ -44,33 +44,43 @@ export function createTestClient(options: CreateTestClientOptions = {}): TestCli codexPathOverride: codexExecPath, baseUrl: options.baseUrl, apiKey: options.apiKey, - config: mergeTestProviderConfig(options.baseUrl, options.config), + config: mergeTestConfig(options.baseUrl, options.config), env, }), }; } -function mergeTestProviderConfig( +function mergeTestConfig( baseUrl: string | undefined, config: CodexConfigObject | undefined, ): CodexConfigObject | undefined { - if (!baseUrl || hasExplicitProviderConfig(config)) { - return config; - } + const mergedConfig: CodexConfigObject | undefined = + !baseUrl || hasExplicitProviderConfig(config) + ? config + : { + ...config, + // Built-in providers are merged before user config, so tests need a + // custom provider entry to force SSE against the local mock server. + model_provider: "mock", + model_providers: { + mock: { + name: "Mock provider for test", + base_url: baseUrl, + wire_api: "responses", + supports_websockets: false, + }, + }, + }; + const featureOverrides = mergedConfig?.features; - // Built-in providers are merged before user config, so tests need a custom - // provider entry to force SSE against the local mock server. return { - ...config, - model_provider: "mock", - model_providers: { - mock: { - name: "Mock provider for test", - base_url: baseUrl, - wire_api: "responses", - supports_websockets: false, - }, - }, + ...mergedConfig, + // Disable plugins in SDK integration tests so background curated-plugin + // sync does not race temp CODEX_HOME cleanup. + features: + featureOverrides && typeof featureOverrides === "object" && !Array.isArray(featureOverrides) + ? { ...featureOverrides, plugins: false } + : { plugins: false }, }; } From 8002594ee3ad32bc7dd9c2465949212bef1c017d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 27 Mar 2026 13:58:29 -0700 Subject: [PATCH 2/6] Normalize /mcp tool grouping for hyphenated server names (#15946) Fix display for servers with special characters. --- codex-rs/core/src/mcp/mod.rs | 26 +++ codex-rs/core/src/mcp/mod_tests.rs | 8 + codex-rs/core/src/mcp_connection_manager.rs | 21 +- codex-rs/tui/src/history_cell.rs | 170 +++++++++++---- ...sts_tools_for_hyphenated_server_names.snap | 16 ++ codex-rs/tui_app_server/src/history_cell.rs | 195 ++++++++++++------ ...sts_tools_for_hyphenated_server_names.snap | 16 ++ 7 files changed, 333 insertions(+), 119 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap create mode 100644 codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index a9d2388f70..afeb5647ea 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -33,6 +33,32 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; +/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. +/// MCP server/tool names are user-controlled, so sanitize the fully-qualified +/// name we expose to the model by replacing any disallowed character with `_`. +pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String { + let mut sanitized = String::with_capacity(name.len()); + for c in name.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + sanitized.push(c); + } else { + sanitized.push('_'); + } + } + + if sanitized.is_empty() { + "_".to_string() + } else { + sanitized + } +} + +pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String { + sanitize_responses_api_tool_name(&format!( + "{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}" + )) +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap>, diff --git a/codex-rs/core/src/mcp/mod_tests.rs b/codex-rs/core/src/mcp/mod_tests.rs index 855e71e1f2..d1bbf68f60 100644 --- a/codex-rs/core/src/mcp/mod_tests.rs +++ b/codex-rs/core/src/mcp/mod_tests.rs @@ -52,6 +52,14 @@ fn split_qualified_tool_name_returns_server_and_tool() { ); } +#[test] +fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() { + assert_eq!( + qualified_mcp_tool_name_prefix("Some-Server"), + "mcp__Some_Server__".to_string() + ); +} + #[test] fn split_qualified_tool_name_rejects_invalid_names() { assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index eac8485048..b03f1a7a12 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -22,6 +22,7 @@ use std::time::Instant; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; use crate::mcp::auth::McpAuthStatusEntry; +use crate::mcp::sanitize_responses_api_tool_name; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; @@ -104,26 +105,6 @@ const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms"; -/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. -/// MCP server/tool names are user-controlled, so sanitize the fully-qualified -/// name we expose to the model by replacing any disallowed character with `_`. -fn sanitize_responses_api_tool_name(name: &str) -> String { - let mut sanitized = String::with_capacity(name.len()); - for c in name.chars() { - if c.is_ascii_alphanumeric() || c == '_' { - sanitized.push(c); - } else { - sanitized.push('_'); - } - } - - if sanitized.is_empty() { - "_".to_string() - } else { - sanitized - } -} - fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); hasher.update(s.as_bytes()); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2dee1e846b..1be1c5342b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -42,6 +42,7 @@ use base64::Engine; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; use codex_core::mcp::McpManager; +use codex_core::mcp::qualified_mcp_tool_name_prefix; use codex_core::plugins::PluginsManager; use codex_core::web_search::web_search_detail; use codex_otel::RuntimeMetricsSummary; @@ -1824,7 +1825,7 @@ pub(crate) fn new_mcp_tools_output( servers.sort_by(|(a, _), (b, _)| a.cmp(b)); for (server, cfg) in servers { - let prefix = format!("mcp__{server}__"); + let prefix = qualified_mcp_tool_name_prefix(server); let mut names: Vec = tools .keys() .filter(|k| k.starts_with(&prefix)) @@ -2544,7 +2545,6 @@ mod tests { use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::types::McpServerConfig; - use codex_core::config::types::McpServerTransportConfig; use codex_otel::RuntimeMetricTotals; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; @@ -2582,6 +2582,88 @@ mod tests { std::env::temp_dir() } + fn stdio_server_config( + command: &str, + args: Vec<&str>, + env: Option>, + env_vars: Vec<&str>, + ) -> McpServerConfig { + let mut table = toml::Table::new(); + table.insert( + "command".to_string(), + toml::Value::String(command.to_string()), + ); + if !args.is_empty() { + table.insert( + "args".to_string(), + toml::Value::Array( + args.into_iter() + .map(|arg| toml::Value::String(arg.to_string())) + .collect(), + ), + ); + } + if let Some(env) = env { + table.insert("env".to_string(), string_map_to_toml_value(env)); + } + if !env_vars.is_empty() { + table.insert( + "env_vars".to_string(), + toml::Value::Array( + env_vars + .into_iter() + .map(|name| toml::Value::String(name.to_string())) + .collect(), + ), + ); + } + + toml::Value::Table(table) + .try_into() + .expect("test stdio MCP config should deserialize") + } + + fn streamable_http_server_config( + url: &str, + bearer_token_env_var: Option<&str>, + http_headers: Option>, + env_http_headers: Option>, + ) -> McpServerConfig { + let mut table = toml::Table::new(); + table.insert("url".to_string(), toml::Value::String(url.to_string())); + if let Some(bearer_token_env_var) = bearer_token_env_var { + table.insert( + "bearer_token_env_var".to_string(), + toml::Value::String(bearer_token_env_var.to_string()), + ); + } + if let Some(http_headers) = http_headers { + table.insert( + "http_headers".to_string(), + string_map_to_toml_value(http_headers), + ); + } + if let Some(env_http_headers) = env_http_headers { + table.insert( + "env_http_headers".to_string(), + string_map_to_toml_value(env_http_headers), + ); + } + + toml::Value::Table(table) + .try_into() + .expect("test streamable_http MCP config should deserialize") + } + + fn string_map_to_toml_value(entries: HashMap) -> toml::Value { + toml::Value::Table( + entries + .into_iter() + .map(|(key, value)| (key, toml::Value::String(value))) + .collect(), + ) + } + fn render_lines(lines: &[Line<'static>]) -> Vec { lines .iter() @@ -2897,25 +2979,7 @@ mod tests { let mut config = test_config().await; let mut env = HashMap::new(); env.insert("TOKEN".to_string(), "secret".to_string()); - let stdio_config = McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "docs-server".to_string(), - args: vec![], - env: Some(env), - env_vars: vec!["APP_TOKEN".to_string()], - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }; + let stdio_config = stdio_server_config("docs-server", vec![], Some(env), vec!["APP_TOKEN"]); let mut servers = config.mcp_servers.get().clone(); servers.insert("docs".to_string(), stdio_config); @@ -2923,24 +2987,12 @@ mod tests { headers.insert("Authorization".to_string(), "Bearer secret".to_string()); let mut env_headers = HashMap::new(); env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string()); - let http_config = McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: Some("MCP_TOKEN".to_string()), - http_headers: Some(headers), - env_http_headers: Some(env_headers), - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }; + let http_config = streamable_http_server_config( + "https://example.com/mcp", + Some("MCP_TOKEN"), + Some(headers), + Some(env_headers), + ); servers.insert("http".to_string(), http_config); config .mcp_servers @@ -2988,6 +3040,46 @@ mod tests { insta::assert_snapshot!(rendered); } + #[tokio::test] + async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() { + let mut config = test_config().await; + let mut servers = config.mcp_servers.get().clone(); + servers.insert( + "some-server".to_string(), + stdio_server_config("docs-server", vec!["--stdio"], None, vec![]), + ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + + let tools = HashMap::from([( + "mcp__some_server__lookup".to_string(), + Tool { + description: None, + name: "lookup".to_string(), + title: None, + input_schema: serde_json::json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + )]); + + let auth_statuses: HashMap = HashMap::new(); + let cell = new_mcp_tools_output( + &config, + tools, + HashMap::new(), + HashMap::new(), + &auth_statuses, + ); + let rendered = render_lines(&cell.display_lines(120)).join("\n"); + + insta::assert_snapshot!(rendered); + } + #[test] fn empty_agent_message_cell_transcript() { let cell = AgentMessageCell::new(vec![Line::default()], false); diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap new file mode 100644 index 0000000000..d3ca4c3984 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 3080 +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • some-server + • Status: enabled + • Auth: Unsupported + • Command: docs-server --stdio + • Tools: lookup + • Resources: (none) + • Resource templates: (none) diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 7e37a6bf98..37d52b4f9d 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -45,6 +45,8 @@ use codex_core::config::types::McpServerTransportConfig; #[cfg(test)] use codex_core::mcp::McpManager; #[cfg(test)] +use codex_core::mcp::qualified_mcp_tool_name_prefix; +#[cfg(test)] use codex_core::plugins::PluginsManager; use codex_core::web_search::web_search_detail; use codex_otel::RuntimeMetricsSummary; @@ -1831,7 +1833,7 @@ pub(crate) fn new_mcp_tools_output( servers.sort_by(|(a, _), (b, _)| a.cmp(b)); for (server, cfg) in servers { - let prefix = format!("mcp__{server}__"); + let prefix = qualified_mcp_tool_name_prefix(server); let mut names: Vec = tools .keys() .filter(|k| k.starts_with(&prefix)) @@ -2773,7 +2775,6 @@ mod tests { use codex_core::config::ConfigBuilder; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerDisabledReason; - use codex_core::config::types::McpServerTransportConfig; use codex_otel::RuntimeMetricTotals; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; @@ -2811,6 +2812,88 @@ mod tests { std::env::temp_dir() } + fn stdio_server_config( + command: &str, + args: Vec<&str>, + env: Option>, + env_vars: Vec<&str>, + ) -> McpServerConfig { + let mut table = toml::Table::new(); + table.insert( + "command".to_string(), + toml::Value::String(command.to_string()), + ); + if !args.is_empty() { + table.insert( + "args".to_string(), + toml::Value::Array( + args.into_iter() + .map(|arg| toml::Value::String(arg.to_string())) + .collect(), + ), + ); + } + if let Some(env) = env { + table.insert("env".to_string(), string_map_to_toml_value(env)); + } + if !env_vars.is_empty() { + table.insert( + "env_vars".to_string(), + toml::Value::Array( + env_vars + .into_iter() + .map(|name| toml::Value::String(name.to_string())) + .collect(), + ), + ); + } + + toml::Value::Table(table) + .try_into() + .expect("test stdio MCP config should deserialize") + } + + fn streamable_http_server_config( + url: &str, + bearer_token_env_var: Option<&str>, + http_headers: Option>, + env_http_headers: Option>, + ) -> McpServerConfig { + let mut table = toml::Table::new(); + table.insert("url".to_string(), toml::Value::String(url.to_string())); + if let Some(bearer_token_env_var) = bearer_token_env_var { + table.insert( + "bearer_token_env_var".to_string(), + toml::Value::String(bearer_token_env_var.to_string()), + ); + } + if let Some(http_headers) = http_headers { + table.insert( + "http_headers".to_string(), + string_map_to_toml_value(http_headers), + ); + } + if let Some(env_http_headers) = env_http_headers { + table.insert( + "env_http_headers".to_string(), + string_map_to_toml_value(env_http_headers), + ); + } + + toml::Value::Table(table) + .try_into() + .expect("test streamable_http MCP config should deserialize") + } + + fn string_map_to_toml_value(entries: HashMap) -> toml::Value { + toml::Value::Table( + entries + .into_iter() + .map(|(key, value)| (key, toml::Value::String(value))) + .collect(), + ) + } + fn render_lines(lines: &[Line<'static>]) -> Vec { lines .iter() @@ -3126,25 +3209,7 @@ mod tests { let mut config = test_config().await; let mut env = HashMap::new(); env.insert("TOKEN".to_string(), "secret".to_string()); - let stdio_config = McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "docs-server".to_string(), - args: vec![], - env: Some(env), - env_vars: vec!["APP_TOKEN".to_string()], - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }; + let stdio_config = stdio_server_config("docs-server", vec![], Some(env), vec!["APP_TOKEN"]); let mut servers = config.mcp_servers.get().clone(); servers.insert("docs".to_string(), stdio_config); @@ -3152,24 +3217,12 @@ mod tests { headers.insert("Authorization".to_string(), "Bearer secret".to_string()); let mut env_headers = HashMap::new(); env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string()); - let http_config = McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: Some("MCP_TOKEN".to_string()), - http_headers: Some(headers), - env_http_headers: Some(env_headers), - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }; + let http_config = streamable_http_server_config( + "https://example.com/mcp", + Some("MCP_TOKEN"), + Some(headers), + Some(env_headers), + ); servers.insert("http".to_string(), http_config); config .mcp_servers @@ -3218,30 +3271,52 @@ mod tests { } #[tokio::test] - async fn mcp_tools_output_from_statuses_renders_status_only_servers() { + async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() { let mut config = test_config().await; - let servers = HashMap::from([( - "plugin_docs".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: "docs-server".to_string(), - args: vec!["--stdio".to_string()], - env: None, - env_vars: vec![], - cwd: None, - }, - enabled: false, - required: false, - disabled_reason: Some(McpServerDisabledReason::Unknown), - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), + let mut servers = config.mcp_servers.get().clone(); + servers.insert( + "some-server".to_string(), + stdio_server_config("docs-server", vec!["--stdio"], None, vec![]), + ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + + let tools = HashMap::from([( + "mcp__some_server__lookup".to_string(), + Tool { + description: None, + name: "lookup".to_string(), + title: None, + input_schema: serde_json::json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + icons: None, + meta: None, }, )]); + + let auth_statuses: HashMap = HashMap::new(); + let cell = new_mcp_tools_output( + &config, + tools, + HashMap::new(), + HashMap::new(), + &auth_statuses, + ); + let rendered = render_lines(&cell.display_lines(120)).join("\n"); + + insta::assert_snapshot!(rendered); + } + + #[tokio::test] + async fn mcp_tools_output_from_statuses_renders_status_only_servers() { + let mut config = test_config().await; + let mut plugin_docs = stdio_server_config("docs-server", vec!["--stdio"], None, vec![]); + plugin_docs.enabled = false; + plugin_docs.disabled_reason = Some(McpServerDisabledReason::Unknown); + let servers = HashMap::from([("plugin_docs".to_string(), plugin_docs)]); config .mcp_servers .set(servers) diff --git a/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap new file mode 100644 index 0000000000..c859344b32 --- /dev/null +++ b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap @@ -0,0 +1,16 @@ +--- +source: tui_app_server/src/history_cell.rs +assertion_line: 3310 +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • some-server + • Status: enabled + • Auth: Unsupported + • Command: docs-server --stdio + • Tools: lookup + • Resources: (none) + • Resource templates: (none) From 81abb44f686359719da32974fc3ceec5c1c52db6 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 27 Mar 2026 14:21:18 -0700 Subject: [PATCH 3/6] plugins: Clean up stale curated plugin sync temp dirs and add sync metrics (#16035) 1. Keep curated plugin staging directories under TempDir ownership until activation succeeds, so failed git/HTTP sync attempts do not leak plugins-clone-*. 2. Best-effort clean up stale plugins-clone-* directories before creating a new staged repo, using a conservative age threshold. 3. Emit OTEL counters for curated plugin startup sync transport attempts and final outcome across git and HTTP paths. --- codex-rs/core/src/plugins/startup_sync.rs | 157 ++++++++++++++++-- .../core/src/plugins/startup_sync_tests.rs | 147 ++++++++++++++++ codex-rs/otel/src/metrics/names.rs | 2 + 3 files changed, 292 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/plugins/startup_sync.rs b/codex-rs/core/src/plugins/startup_sync.rs index 3511c10c58..ceeb1d0f81 100644 --- a/codex-rs/core/src/plugins/startup_sync.rs +++ b/codex-rs/core/src/plugins/startup_sync.rs @@ -7,8 +7,11 @@ use std::process::Stdio; use std::sync::Arc; use std::time::Duration; +use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC; +use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_METRIC; use reqwest::Client; use serde::Deserialize; +use tempfile::TempDir; use tracing::info; use tracing::warn; use zip::ZipArchive; @@ -27,6 +30,8 @@ const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); +// Keep this comfortably above a normal sync attempt so we do not race another Codex process. +const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60); const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(5); @@ -63,14 +68,23 @@ fn sync_openai_plugins_repo_with_transport_overrides( api_base_url: &str, ) -> Result { match sync_openai_plugins_repo_via_git(codex_home, git_binary) { - Ok(remote_sha) => Ok(remote_sha), + Ok(remote_sha) => { + emit_curated_plugins_startup_sync_metric("git", "success"); + emit_curated_plugins_startup_sync_final_metric("git", "success"); + Ok(remote_sha) + } Err(err) => { + emit_curated_plugins_startup_sync_metric("git", "failure"); warn!( error = %err, git_binary, "git sync failed for curated plugin sync; falling back to GitHub HTTP" ); - sync_openai_plugins_repo_via_http(codex_home, api_base_url) + let result = sync_openai_plugins_repo_via_http(codex_home, api_base_url); + let status = if result.is_ok() { "success" } else { "failure" }; + emit_curated_plugins_startup_sync_metric("http", status); + emit_curated_plugins_startup_sync_final_metric("http", status); + result } } } @@ -85,7 +99,7 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu return Ok(remote_sha); } - let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; let clone_output = run_git_command_with_timeout( Command::new(git_binary) .env("GIT_OPTIONAL_LOCKS", "0") @@ -93,21 +107,21 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu .arg("--depth") .arg("1") .arg("https://github.com/openai/plugins.git") - .arg(&cloned_repo_path), + .arg(staged_repo_dir.path()), "git clone curated plugins repo", CURATED_PLUGINS_GIT_TIMEOUT, )?; ensure_git_success(&clone_output, "git clone curated plugins repo")?; - let cloned_sha = git_head_sha(&cloned_repo_path, git_binary)?; + let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; if cloned_sha != remote_sha { return Err(format!( "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" )); } - ensure_marketplace_manifest_exists(&cloned_repo_path)?; - activate_curated_repo(&repo_path, &cloned_repo_path)?; + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + activate_curated_repo(&repo_path, staged_repo_dir)?; write_curated_plugins_sha(&sha_path, &remote_sha)?; Ok(remote_sha) } @@ -129,11 +143,11 @@ fn sync_openai_plugins_repo_via_http( return Ok(remote_sha); } - let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; - extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?; - ensure_marketplace_manifest_exists(&cloned_repo_path)?; - activate_curated_repo(&repo_path, &cloned_repo_path)?; + extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + activate_curated_repo(&repo_path, staged_repo_dir)?; write_curated_plugins_sha(&sha_path, &remote_sha)?; Ok(remote_sha) } @@ -227,7 +241,7 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io:: tokio::fs::write(marker_path, b"ok\n").await } -fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result { +fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result { let Some(parent) = repo_path.parent() else { return Err(format!( "failed to determine curated plugins parent directory for {}", @@ -240,6 +254,7 @@ fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result Result entries, + Err(err) => { + warn!( + error = %err, + parent = %parent.display(), + "failed to list curated plugins temp directory parent for stale cleanup" + ); + return; + } + }; + + for entry in entries.flatten() { + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + warn!( + error = %err, + path = %entry.path().display(), + "failed to inspect curated plugins temp directory entry" + ); + continue; + } + }; + if !file_type.is_dir() { + continue; + } + + let path = entry.path(); + let is_plugins_clone_dir = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("plugins-clone-")); + if !is_plugins_clone_dir { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to read curated plugins temp directory metadata" + ); + continue; + } + }; + let modified = match metadata.modified() { + Ok(modified) => modified, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to read curated plugins temp directory modification time" + ); + continue; + } + }; + let age = match modified.elapsed() { + Ok(age) => age, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to compute curated plugins temp directory age" + ); + continue; + } + }; + if age < max_age { + continue; + } + + if let Err(err) = std::fs::remove_dir_all(&path) { + warn!( + error = %err, + path = %path.display(), + "failed to remove stale curated plugins temp directory" + ); + } + } +} + +fn emit_curated_plugins_startup_sync_metric(transport: &'static str, status: &'static str) { + emit_curated_plugins_startup_sync_counter( + CURATED_PLUGINS_STARTUP_SYNC_METRIC, + transport, + status, + ); +} + +fn emit_curated_plugins_startup_sync_final_metric(transport: &'static str, status: &'static str) { + emit_curated_plugins_startup_sync_counter( + CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC, + transport, + status, + ); +} + +fn emit_curated_plugins_startup_sync_counter( + metric_name: &str, + transport: &'static str, + status: &'static str, +) { + let Some(metrics) = codex_otel::metrics::global() else { + return; + }; + let tags = [("transport", transport), ("status", status)]; + let _ = metrics.counter(metric_name, /*inc*/ 1, &tags); } fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> { @@ -263,7 +391,8 @@ fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> { )) } -fn activate_curated_repo(repo_path: &Path, staged_repo_path: &Path) -> Result<(), String> { +fn activate_curated_repo(repo_path: &Path, staged_repo_dir: TempDir) -> Result<(), String> { + let staged_repo_path = staged_repo_dir.path(); if repo_path.exists() { let parent = repo_path.parent().ok_or_else(|| { format!( diff --git a/codex-rs/core/src/plugins/startup_sync_tests.rs b/codex-rs/core/src/plugins/startup_sync_tests.rs index 66c02c38f0..c3d11572ca 100644 --- a/codex-rs/core/src/plugins/startup_sync_tests.rs +++ b/codex-rs/core/src/plugins/startup_sync_tests.rs @@ -7,6 +7,7 @@ use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use pretty_assertions::assert_eq; use std::io::Write; +use std::path::Path; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; @@ -17,6 +18,21 @@ use wiremock::matchers::path; use zip::ZipWriter; use zip::write::SimpleFileOptions; +fn has_plugins_clone_dirs(codex_home: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(codex_home.join(".tmp")) else { + return false; + }; + + entries.flatten().any(|entry| { + let path = entry.path(); + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("plugins-clone-")) + }) +} + #[test] fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { let tmp = tempdir().expect("tempdir"); @@ -38,6 +54,49 @@ fn read_curated_plugins_sha_reads_trimmed_sha_file() { ); } +#[cfg(unix)] +#[test] +fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { + use std::os::unix::ffi::OsStrExt; + use std::time::SystemTime; + + fn set_dir_mtime(path: &Path, age: Duration) -> Result<(), Box> { + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?; + let modified_at = now.saturating_sub(age); + let tv_sec = i64::try_from(modified_at.as_secs())?; + let ts = libc::timespec { tv_sec, tv_nsec: 0 }; + let times = [ts, ts]; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?; + let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) }; + if result != 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) + } + + let tmp = tempdir().expect("tempdir"); + let parent = tmp.path().join(".tmp"); + let stale_clone_dir = parent.join("plugins-clone-stale"); + let fresh_clone_dir = parent.join("plugins-clone-fresh"); + let unrelated_dir = parent.join("plugins-cache"); + + std::fs::create_dir_all(&stale_clone_dir).expect("create stale clone dir"); + std::fs::create_dir_all(&fresh_clone_dir).expect("create fresh clone dir"); + std::fs::create_dir_all(&unrelated_dir).expect("create unrelated dir"); + set_dir_mtime( + &stale_clone_dir, + CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE + Duration::from_secs(60), + ) + .expect("age stale clone dir"); + set_dir_mtime(&fresh_clone_dir, Duration::ZERO).expect("age fresh clone dir"); + + remove_stale_curated_repo_temp_dirs(&parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE); + + assert!(!stale_clone_dir.exists()); + assert!(fresh_clone_dir.is_dir()); + assert!(unrelated_dir.is_dir()); +} + #[cfg(unix)] #[test] fn sync_openai_plugins_repo_prefers_git_when_available() { @@ -229,6 +288,94 @@ exit 1 assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); } +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-partial-fail-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + std::fs::write( + &git_path, + format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{sha}" + exit 0 +fi +if [ "$1" = "clone" ]; then + dest="$5" + mkdir -p "$dest/.git" + echo "fatal: early EOF" >&2 + exit 128 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"# + ), + ) + .expect("write fake git"); + let mut permissions = std::fs::metadata(&git_path) + .expect("metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&git_path, permissions).expect("chmod"); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("git sync should fail"); + + assert!(err.contains("fatal: early EOF")); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(b"not a zip archive".to_vec()), + ) + .mount(&server) + .await; + + let server_uri = server.uri(); + let tmp_path = tmp.path().to_path_buf(); + let err = tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_via_http(tmp_path.as_path(), &server_uri) + }) + .await + .expect("sync task should join") + .expect_err("http sync should fail"); + + assert!(err.contains("failed to open curated plugins zip archive")); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + #[tokio::test] async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index d4f5760d5c..8dbf60b122 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -27,6 +27,8 @@ pub const TURN_NETWORK_PROXY_METRIC: &str = "codex.turn.network_proxy"; pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call"; pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage"; pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; +pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_sync"; +pub const CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC: &str = "codex.plugins.startup_sync.final"; /// Total runtime of a startup prewarm attempt until it completes, tagged by final status. pub const STARTUP_PREWARM_DURATION_METRIC: &str = "codex.startup_prewarm.duration_ms"; /// Age of the startup prewarm attempt when the first real turn resolves it, tagged by outcome. From 82e8031338a5a94172d985662ca1f0cd0a0d3bfb Mon Sep 17 00:00:00 2001 From: bwanner-oai Date: Fri, 27 Mar 2026 14:25:13 -0700 Subject: [PATCH 4/6] Add usage-based business plan types (#15934) ## Summary - add `self_serve_business_usage_based` and `enterprise_cbp_usage_based` to the public/internal plan enums and regenerate the app-server + Python SDK artifacts - map both plans through JWT login and backend rate-limit payloads, then bucket them with the existing Team/Business entitlement behavior in cloud requirements, usage-limit copy, tooltips, and status display - keep the earlier display-label remap commit on this branch so the new Team-like and Business-like plans render consistently in the UI ## Testing - `just write-app-server-schema` - `uv run --project sdk/python python sdk/python/scripts/update_sdk_artifacts.py generate-types` - `just fix -p codex-protocol -p codex-login -p codex-core -p codex-backend-client -p codex-cloud-requirements -p codex-tui -p codex-tui-app-server -p codex-backend-openapi-models` - `just fmt` - `just argument-comment-lint` - `cargo test -p codex-protocol usage_based_plan_types_use_expected_wire_names` - `cargo test -p codex-login usage_based` - `cargo test -p codex-backend-client usage_based` - `cargo test -p codex-cloud-requirements usage_based` - `cargo test -p codex-core usage_limit_reached_error_formats_` - `cargo test -p codex-tui plan_type_display_name_remaps_display_labels` - `cargo test -p codex-tui remapped` - `cargo test -p codex-tui-app-server plan_type_display_name_remaps_display_labels` - `cargo test -p codex-tui-app-server remapped` - `cargo test -p codex-tui-app-server preserves_usage_based_plan_type_wire_name` ## Notes - a broader multi-crate `cargo test` run still hits unrelated existing guardian-approval config failures in `codex-rs/core/src/config/config_tests.rs` --- .../schema/json/ServerNotification.json | 2 + .../codex_app_server_protocol.schemas.json | 2 + .../codex_app_server_protocol.v2.schemas.json | 2 + .../AccountRateLimitsUpdatedNotification.json | 2 + .../json/v2/AccountUpdatedNotification.json | 2 + .../json/v2/GetAccountRateLimitsResponse.json | 2 + .../schema/json/v2/GetAccountResponse.json | 2 + .../schema/typescript/PlanType.ts | 2 +- codex-rs/backend-client/src/client.rs | 18 + codex-rs/cloud-requirements/src/lib.rs | 58 +- .../src/models/rate_limit_status_payload.rs | 4 + codex-rs/core/src/error.rs | 22 +- codex-rs/core/src/error_tests.rs | 28 + codex-rs/login/src/auth/auth_tests.rs | 46 + codex-rs/login/src/auth/manager.rs | 4 + codex-rs/login/src/token_data.rs | 67 +- codex-rs/login/src/token_data_tests.rs | 38 + codex-rs/protocol/src/account.rs | 57 + codex-rs/tui/src/status/helpers.rs | 61 +- codex-rs/tui/src/tooltips.rs | 12 +- .../tui_app_server/src/app_server_session.rs | 45 +- .../tui_app_server/src/local_chatgpt_auth.rs | 30 +- codex-rs/tui_app_server/src/status/helpers.rs | 50 + codex-rs/tui_app_server/src/status/mod.rs | 1 + codex-rs/tui_app_server/src/tooltips.rs | 12 +- .../generated/notification_registry.py | 6 + .../src/codex_app_server/generated/v2_all.py | 990 ++++++++++++------ 27 files changed, 1177 insertions(+), 388 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8ca93137c6..e1a7cd3f3d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1777,7 +1777,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 91490ce26f..084da013e4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9503,7 +9503,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0f31f5b3ec..59dab32b19 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6317,7 +6317,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 91879645de..d1812f069f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -29,7 +29,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index 8348b774ca..f2cf7cb3ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -34,7 +34,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 244156269d..23f7d3cfdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -29,7 +29,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 6646bd8c97..acaa779184 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -52,7 +52,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts index 9f622d0f1b..ad5bcab380 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "business" | "enterprise" | "edu" | "unknown"; +export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown"; diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e7fe145987..cb1e003ff2 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -474,7 +474,13 @@ impl Client { crate::types::PlanType::Plus => AccountPlanType::Plus, crate::types::PlanType::Pro => AccountPlanType::Pro, crate::types::PlanType::Team => AccountPlanType::Team, + crate::types::PlanType::SelfServeBusinessUsageBased => { + AccountPlanType::SelfServeBusinessUsageBased + } crate::types::PlanType::Business => AccountPlanType::Business, + crate::types::PlanType::EnterpriseCbpUsageBased => { + AccountPlanType::EnterpriseCbpUsageBased + } crate::types::PlanType::Enterprise => AccountPlanType::Enterprise, crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu, crate::types::PlanType::Guest @@ -499,6 +505,18 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn map_plan_type_supports_usage_based_business_variants() { + assert_eq!( + Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased), + AccountPlanType::SelfServeBusinessUsageBased + ); + assert_eq!( + Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased), + AccountPlanType::EnterpriseCbpUsageBased + ); + } + #[test] fn usage_payload_maps_primary_and_additional_rate_limits() { let payload = RateLimitStatusPayload { diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 12e62d880f..65476432a5 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -327,11 +327,11 @@ impl CloudRequirementsService { let Some(auth) = self.auth_manager.auth().await else { return Ok(None); }; + let Some(plan_type) = auth.account_plan_type() else { + return Ok(None); + }; if !auth.is_chatgpt_auth() - || !matches!( - auth.account_plan_type(), - Some(PlanType::Business | PlanType::Enterprise) - ) + || !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) { return Ok(None); } @@ -547,11 +547,11 @@ impl CloudRequirementsService { let Some(auth) = self.auth_manager.auth().await else { return false; }; + let Some(plan_type) = auth.account_plan_type() else { + return false; + }; if !auth.is_chatgpt_auth() - || !matches!( - auth.account_plan_type(), - Some(PlanType::Business | PlanType::Enterprise) - ) + || !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) { return false; } @@ -1125,6 +1125,20 @@ mod tests { assert_eq!(result, Ok(None)); } + #[tokio::test] + async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("self_serve_business_usage_based"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!(service.fetch().await, Ok(None)); + } + #[tokio::test] async fn fetch_cloud_requirements_allows_business_plan() { let codex_home = tempdir().expect("tempdir"); @@ -1153,6 +1167,34 @@ mod tests { ); } + #[tokio::test] + async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise_cbp_usage_based"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + guardian_developer_instructions: None, + feature_requirements: None, + mcp_servers: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + })) + ); + } + #[tokio::test] async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 3d0492fa83..300674357a 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -69,8 +69,12 @@ pub enum PlanType { FreeWorkspace, #[serde(rename = "team")] Team, + #[serde(rename = "self_serve_business_usage_based")] + SelfServeBusinessUsageBased, #[serde(rename = "business")] Business, + #[serde(rename = "enterprise_cbp_usage_based")] + EnterpriseCbpUsageBased, #[serde(rename = "education")] Education, #[serde(rename = "quorum")] diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 1fef42e0ad..f8058915b8 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -439,7 +439,12 @@ impl std::fmt::Display for UsageLimitReachedError { "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}", retry_suffix_after_or(self.resets_at.as_ref()) ), - Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { + Some(PlanType::Known( + KnownPlan::Team + | KnownPlan::SelfServeBusinessUsageBased + | KnownPlan::Business + | KnownPlan::EnterpriseCbpUsageBased, + )) => { format!( "You've hit your usage limit. To get more access now, send a request to your admin{}", retry_suffix_after_or(self.resets_at.as_ref()) @@ -460,21 +465,6 @@ impl std::fmt::Display for UsageLimitReachedError { "You've hit your usage limit.{}", retry_suffix(self.resets_at.as_ref()) ), - Some(PlanType::Unknown(plan)) - if plan.eq_ignore_ascii_case("self_serve_business_usage_based") => - { - match self - .rate_limits - .as_ref() - .and_then(|snapshot| snapshot.credits.as_ref()) - .map(|credits| credits.has_credits) - { - Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue." - .to_string(), - Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue." - .to_string(), - } - } Some(PlanType::Unknown(_)) | None => format!( "You've hit your usage limit.{}", retry_suffix(self.resets_at.as_ref()) diff --git a/codex-rs/core/src/error_tests.rs b/codex-rs/core/src/error_tests.rs index 51cbf42dde..623831b2cf 100644 --- a/codex-rs/core/src/error_tests.rs +++ b/codex-rs/core/src/error_tests.rs @@ -243,6 +243,34 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() { ); } +#[test] +fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again later." + ); +} + +#[test] +fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again later." + ); +} + #[test] fn usage_limit_reached_error_formats_default_for_other_plans() { let err = UsageLimitReachedError { diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 60511caa4d..d467e07dea 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -458,6 +458,52 @@ fn plan_type_maps_known_plan() { pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); } +#[test] +fn plan_type_maps_self_serve_business_usage_based_plan() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("self_serve_business_usage_based".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!( + auth.account_plan_type(), + Some(AccountPlanType::SelfServeBusinessUsageBased) + ); +} + +#[test] +fn plan_type_maps_enterprise_cbp_usage_based_plan() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("enterprise_cbp_usage_based".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!( + auth.account_plan_type(), + Some(AccountPlanType::EnterpriseCbpUsageBased) + ); +} + #[test] fn plan_type_maps_unknown_to_unknown() { let codex_home = tempdir().unwrap(); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 34c0e7e27c..19d13fc08a 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -266,7 +266,11 @@ impl CodexAuth { InternalKnownPlan::Plus => AccountPlanType::Plus, InternalKnownPlan::Pro => AccountPlanType::Pro, InternalKnownPlan::Team => AccountPlanType::Team, + InternalKnownPlan::SelfServeBusinessUsageBased => { + AccountPlanType::SelfServeBusinessUsageBased + } InternalKnownPlan::Business => AccountPlanType::Business, + InternalKnownPlan::EnterpriseCbpUsageBased => AccountPlanType::EnterpriseCbpUsageBased, InternalKnownPlan::Enterprise => AccountPlanType::Enterprise, InternalKnownPlan::Edu => AccountPlanType::Edu, }; diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs index a05b153a89..6d05187d80 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/login/src/token_data.rs @@ -41,7 +41,14 @@ pub struct IdTokenInfo { impl IdTokenInfo { pub fn get_chatgpt_plan_type(&self) -> Option { self.chatgpt_plan_type.as_ref().map(|t| match t { - PlanType::Known(plan) => format!("{plan:?}"), + PlanType::Known(plan) => plan.display_name().to_string(), + PlanType::Unknown(s) => s.clone(), + }) + } + + pub fn get_chatgpt_plan_type_raw(&self) -> Option { + self.chatgpt_plan_type.as_ref().map(|t| match t { + PlanType::Known(plan) => plan.raw_value().to_string(), PlanType::Unknown(s) => s.clone(), }) } @@ -49,9 +56,7 @@ impl IdTokenInfo { pub fn is_workspace_account(&self) -> bool { matches!( self.chatgpt_plan_type, - Some(PlanType::Known( - KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu - )) + Some(PlanType::Known(plan)) if plan.is_workspace_account() ) } } @@ -71,7 +76,11 @@ impl PlanType { "plus" => Self::Known(KnownPlan::Plus), "pro" => Self::Known(KnownPlan::Pro), "team" => Self::Known(KnownPlan::Team), + "self_serve_business_usage_based" => { + Self::Known(KnownPlan::SelfServeBusinessUsageBased) + } "business" => Self::Known(KnownPlan::Business), + "enterprise_cbp_usage_based" => Self::Known(KnownPlan::EnterpriseCbpUsageBased), "enterprise" | "hc" => Self::Known(KnownPlan::Enterprise), "education" | "edu" => Self::Known(KnownPlan::Edu), _ => Self::Unknown(raw.to_string()), @@ -79,7 +88,7 @@ impl PlanType { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum KnownPlan { Free, @@ -87,12 +96,60 @@ pub enum KnownPlan { Plus, Pro, Team, + #[serde(rename = "self_serve_business_usage_based")] + SelfServeBusinessUsageBased, Business, + #[serde(rename = "enterprise_cbp_usage_based")] + EnterpriseCbpUsageBased, #[serde(alias = "hc")] Enterprise, Edu, } +impl KnownPlan { + pub fn display_name(self) -> &'static str { + match self { + Self::Free => "Free", + Self::Go => "Go", + Self::Plus => "Plus", + Self::Pro => "Pro", + Self::Team => "Team", + Self::SelfServeBusinessUsageBased => "Self Serve Business Usage Based", + Self::Business => "Business", + Self::EnterpriseCbpUsageBased => "Enterprise CBP Usage Based", + Self::Enterprise => "Enterprise", + Self::Edu => "Edu", + } + } + + pub fn raw_value(self) -> &'static str { + match self { + Self::Free => "free", + Self::Go => "go", + Self::Plus => "plus", + Self::Pro => "pro", + Self::Team => "team", + Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based", + Self::Business => "business", + Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based", + Self::Enterprise => "enterprise", + Self::Edu => "edu", + } + } + + pub fn is_workspace_account(self) -> bool { + matches!( + self, + Self::Team + | Self::SelfServeBusinessUsageBased + | Self::Business + | Self::EnterpriseCbpUsageBased + | Self::Enterprise + | Self::Edu + ) + } +} + #[derive(Deserialize)] struct IdClaims { #[serde(default)] diff --git a/codex-rs/login/src/token_data_tests.rs b/codex-rs/login/src/token_data_tests.rs index 336f1032bd..f15528b482 100644 --- a/codex-rs/login/src/token_data_tests.rs +++ b/codex-rs/login/src/token_data_tests.rs @@ -68,6 +68,44 @@ fn id_token_info_parses_hc_plan_as_enterprise() { assert_eq!(info.is_workspace_account(), true); } +#[test] +fn id_token_info_parses_usage_based_business_plans() { + let self_serve_business_jwt = fake_jwt(serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "self_serve_business_usage_based" + } + })); + let self_serve_business = + parse_chatgpt_jwt_claims(&self_serve_business_jwt).expect("should parse"); + assert_eq!( + self_serve_business.get_chatgpt_plan_type().as_deref(), + Some("Self Serve Business Usage Based") + ); + assert_eq!( + self_serve_business.get_chatgpt_plan_type_raw().as_deref(), + Some("self_serve_business_usage_based") + ); + assert_eq!(self_serve_business.is_workspace_account(), true); + + let enterprise_cbp_jwt = fake_jwt(serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "enterprise_cbp_usage_based" + } + })); + let enterprise_cbp = parse_chatgpt_jwt_claims(&enterprise_cbp_jwt).expect("should parse"); + assert_eq!( + enterprise_cbp.get_chatgpt_plan_type().as_deref(), + Some("Enterprise CBP Usage Based") + ); + assert_eq!( + enterprise_cbp.get_chatgpt_plan_type_raw().as_deref(), + Some("enterprise_cbp_usage_based") + ); + assert_eq!(enterprise_cbp.is_workspace_account(), true); +} + #[test] fn id_token_info_handles_missing_fields() { let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" })); diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index 1cb58a020f..e9f2fc9ec3 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -13,9 +13,66 @@ pub enum PlanType { Plus, Pro, Team, + #[serde(rename = "self_serve_business_usage_based")] + #[ts(rename = "self_serve_business_usage_based")] + SelfServeBusinessUsageBased, Business, + #[serde(rename = "enterprise_cbp_usage_based")] + #[ts(rename = "enterprise_cbp_usage_based")] + EnterpriseCbpUsageBased, Enterprise, Edu, #[serde(other)] Unknown, } + +impl PlanType { + pub fn is_team_like(self) -> bool { + matches!(self, Self::Team | Self::SelfServeBusinessUsageBased) + } + + pub fn is_business_like(self) -> bool { + matches!(self, Self::Business | Self::EnterpriseCbpUsageBased) + } +} + +#[cfg(test)] +mod tests { + use super::PlanType; + use pretty_assertions::assert_eq; + + #[test] + fn usage_based_plan_types_use_expected_wire_names() { + assert_eq!( + serde_json::to_string(&PlanType::SelfServeBusinessUsageBased) + .expect("self-serve business usage based should serialize"), + "\"self_serve_business_usage_based\"" + ); + assert_eq!( + serde_json::to_string(&PlanType::EnterpriseCbpUsageBased) + .expect("enterprise cbp usage based should serialize"), + "\"enterprise_cbp_usage_based\"" + ); + assert_eq!( + serde_json::from_str::("\"self_serve_business_usage_based\"") + .expect("self-serve business usage based should deserialize"), + PlanType::SelfServeBusinessUsageBased + ); + assert_eq!( + serde_json::from_str::("\"enterprise_cbp_usage_based\"") + .expect("enterprise cbp usage based should deserialize"), + PlanType::EnterpriseCbpUsageBased + ); + } + + #[test] + fn plan_family_helpers_group_usage_based_variants_with_existing_plans() { + assert_eq!(PlanType::Team.is_team_like(), true); + assert_eq!(PlanType::SelfServeBusinessUsageBased.is_team_like(), true); + assert_eq!(PlanType::Business.is_team_like(), false); + + assert_eq!(PlanType::Business.is_business_like(), true); + assert_eq!(PlanType::EnterpriseCbpUsageBased.is_business_like(), true); + assert_eq!(PlanType::Team.is_business_like(), false); + } +} diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index e14f27fdcc..6c0759ac1e 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -94,14 +94,23 @@ pub(crate) fn compose_account_display( CoreAuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey), CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => { let email = auth.get_account_email(); - let plan = plan - .map(|plan_type| title_case(format!("{plan_type:?}").as_str())) - .or_else(|| Some("Unknown".to_string())); + let plan = plan.map(plan_type_display_name); + let plan = plan.or_else(|| Some("Unknown".to_string())); Some(StatusAccountDisplay::ChatGpt { email, plan }) } } } +pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String { + if plan_type.is_team_like() { + "Business".to_string() + } else if plan_type.is_business_like() { + "Enterprise".to_string() + } else { + title_case(format!("{plan_type:?}").as_str()) + } +} + pub(crate) fn format_tokens_compact(value: i64) -> String { let value = value.max(0); if value == 0 { @@ -187,3 +196,49 @@ pub(crate) fn title_case(s: &str) -> String { let rest: String = chars.as_str().to_ascii_lowercase(); first.to_uppercase().collect::() + &rest } + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::auth::CodexAuth; + use pretty_assertions::assert_eq; + + #[test] + fn plan_type_display_name_remaps_display_labels() { + let cases = [ + (PlanType::Free, "Free"), + (PlanType::Go, "Go"), + (PlanType::Plus, "Plus"), + (PlanType::Pro, "Pro"), + (PlanType::Team, "Business"), + (PlanType::SelfServeBusinessUsageBased, "Business"), + (PlanType::Business, "Enterprise"), + (PlanType::EnterpriseCbpUsageBased, "Enterprise"), + (PlanType::Enterprise, "Enterprise"), + (PlanType::Edu, "Edu"), + (PlanType::Unknown, "Unknown"), + ]; + + for (plan_type, expected) in cases { + assert_eq!(plan_type_display_name(plan_type), expected); + } + } + + #[test] + fn compose_account_display_uses_remapped_plan_label() { + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + + let display = compose_account_display( + auth_manager.as_ref(), + Some(PlanType::SelfServeBusinessUsageBased), + ); + assert!(matches!( + display, + Some(StatusAccountDisplay::ChatGpt { + email: None, + plan: Some(ref plan), + }) if plan == "Business" + )); + } +} diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index b5064c8e7e..c91584a5bc 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Op // Leave small chance for a random tooltip to be shown. if rng.random_ratio(8, 10) { match plan { - Some(PlanType::Plus) - | Some(PlanType::Business) - | Some(PlanType::Team) - | Some(PlanType::Enterprise) - | Some(PlanType::Pro) => { + Some(plan_type) + if matches!( + plan_type, + PlanType::Plus | PlanType::Enterprise | PlanType::Pro + ) || plan_type.is_team_like() + || plan_type.is_business_like() => + { return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string()); } Some(PlanType::Go) | Some(PlanType::Free) => { diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index 3e7eddd62a..ff5fad3737 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -1,5 +1,6 @@ use crate::bottom_pane::FeedbackAudience; use crate::status::StatusAccountDisplay; +use crate::status::plan_type_display_name; use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; @@ -228,7 +229,7 @@ impl AppServerSession { Some(TelemetryAuthMode::Chatgpt), Some(StatusAccountDisplay::ChatGpt { email: Some(email), - plan: Some(title_case(format!("{plan_type:?}").as_str())), + plan: Some(plan_type_display_name(plan_type)), }), Some(plan_type), feedback_audience, @@ -733,19 +734,6 @@ impl AppServerSession { } } -fn title_case(s: &str) -> String { - if s.is_empty() { - return String::new(); - } - - let mut chars = s.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - let rest = chars.as_str().to_ascii_lowercase(); - first.to_uppercase().collect::() + &rest -} - pub(crate) fn status_account_display_from_auth_mode( auth_mode: Option, plan_type: Option, @@ -755,7 +743,7 @@ pub(crate) fn status_account_display_from_auth_mode( Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) => { Some(StatusAccountDisplay::ChatGpt { email: None, - plan: plan_type.map(|plan_type| title_case(format!("{plan_type:?}").as_str())), + plan: plan_type.map(plan_type_display_name), }) } None => None, @@ -1264,4 +1252,31 @@ mod tests { assert_ne!(session.history_log_id, 0); assert_eq!(session.history_entry_count, 2); } + + #[test] + fn status_account_display_from_auth_mode_uses_remapped_plan_labels() { + let business = status_account_display_from_auth_mode( + Some(AuthMode::Chatgpt), + Some(codex_protocol::account::PlanType::EnterpriseCbpUsageBased), + ); + assert!(matches!( + business, + Some(StatusAccountDisplay::ChatGpt { + email: None, + plan: Some(ref plan), + }) if plan == "Enterprise" + )); + + let team = status_account_display_from_auth_mode( + Some(AuthMode::Chatgpt), + Some(codex_protocol::account::PlanType::SelfServeBusinessUsageBased), + ); + assert!(matches!( + team, + Some(StatusAccountDisplay::ChatGpt { + email: None, + plan: Some(ref plan), + }) if plan == "Business" + )); + } } diff --git a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs index cc4af28ea7..85e920f1d7 100644 --- a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs +++ b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs @@ -41,7 +41,7 @@ pub(crate) fn load_local_chatgpt_auth( let chatgpt_plan_type = tokens .id_token - .get_chatgpt_plan_type() + .get_chatgpt_plan_type_raw() .map(|plan_type| plan_type.to_ascii_lowercase()); Ok(LocalChatgptAuth { @@ -92,9 +92,9 @@ mod tests { format!("{header_b64}.{payload_b64}.{signature_b64}") } - fn write_chatgpt_auth(codex_home: &Path) { - let id_token = fake_jwt("user@example.com", "workspace-1", "business"); - let access_token = fake_jwt("user@example.com", "workspace-1", "business"); + fn write_chatgpt_auth(codex_home: &Path, plan_type: &str) { + let id_token = fake_jwt("user@example.com", "workspace-1", plan_type); + let access_token = fake_jwt("user@example.com", "workspace-1", plan_type); let auth = AuthDotJson { auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, @@ -114,7 +114,7 @@ mod tests { #[test] fn loads_local_chatgpt_auth_from_managed_auth() { let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); + write_chatgpt_auth(codex_home.path(), "business"); let auth = load_local_chatgpt_auth( codex_home.path(), @@ -162,7 +162,7 @@ mod tests { #[test] fn prefers_managed_auth_over_external_ephemeral_tokens() { let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); + write_chatgpt_auth(codex_home.path(), "business"); login_with_chatgpt_auth_tokens( codex_home.path(), &fake_jwt("user@example.com", "workspace-2", "enterprise"), @@ -181,4 +181,22 @@ mod tests { assert_eq!(auth.chatgpt_account_id, "workspace-1"); assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business")); } + + #[test] + fn preserves_usage_based_plan_type_wire_name() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path(), "self_serve_business_usage_based"); + + let auth = load_local_chatgpt_auth( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + ) + .expect("chatgpt auth should load"); + + assert_eq!( + auth.chatgpt_plan_type.as_deref(), + Some("self_serve_business_usage_based") + ); + } } diff --git a/codex-rs/tui_app_server/src/status/helpers.rs b/codex-rs/tui_app_server/src/status/helpers.rs index 90c045fb9c..7813094c35 100644 --- a/codex-rs/tui_app_server/src/status/helpers.rs +++ b/codex-rs/tui_app_server/src/status/helpers.rs @@ -5,6 +5,7 @@ use chrono::DateTime; use chrono::Local; use codex_core::config::Config; use codex_core::project_doc::discover_project_doc_paths; +use codex_protocol::account::PlanType; use std::path::Path; use unicode_width::UnicodeWidthStr; @@ -86,6 +87,16 @@ pub(crate) fn compose_account_display( account_display.cloned() } +pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String { + if plan_type.is_team_like() { + "Business".to_string() + } else if plan_type.is_business_like() { + "Enterprise".to_string() + } else { + title_case(format!("{plan_type:?}").as_str()) + } +} + pub(crate) fn format_tokens_compact(value: i64) -> String { let value = value.max(0); if value == 0 { @@ -158,3 +169,42 @@ pub(crate) fn format_reset_timestamp(dt: DateTime, captured_at: DateTime< format!("{time} on {}", dt.format("%-d %b")) } } + +fn title_case(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let rest = chars.as_str().to_ascii_lowercase(); + first.to_uppercase().collect::() + &rest +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn plan_type_display_name_remaps_display_labels() { + let cases = [ + (PlanType::Free, "Free"), + (PlanType::Go, "Go"), + (PlanType::Plus, "Plus"), + (PlanType::Pro, "Pro"), + (PlanType::Team, "Business"), + (PlanType::SelfServeBusinessUsageBased, "Business"), + (PlanType::Business, "Enterprise"), + (PlanType::EnterpriseCbpUsageBased, "Enterprise"), + (PlanType::Enterprise, "Enterprise"), + (PlanType::Edu, "Edu"), + (PlanType::Unknown, "Unknown"), + ]; + + for (plan_type, expected) in cases { + assert_eq!(plan_type_display_name(plan_type), expected); + } + } +} diff --git a/codex-rs/tui_app_server/src/status/mod.rs b/codex-rs/tui_app_server/src/status/mod.rs index 20da69471b..6483e8bc10 100644 --- a/codex-rs/tui_app_server/src/status/mod.rs +++ b/codex-rs/tui_app_server/src/status/mod.rs @@ -18,6 +18,7 @@ pub(crate) use card::new_status_output; pub(crate) use card::new_status_output_with_rate_limits; pub(crate) use helpers::format_directory_display; pub(crate) use helpers::format_tokens_compact; +pub(crate) use helpers::plan_type_display_name; pub(crate) use rate_limits::RateLimitSnapshotDisplay; pub(crate) use rate_limits::RateLimitWindowDisplay; #[cfg(test)] diff --git a/codex-rs/tui_app_server/src/tooltips.rs b/codex-rs/tui_app_server/src/tooltips.rs index b5064c8e7e..c91584a5bc 100644 --- a/codex-rs/tui_app_server/src/tooltips.rs +++ b/codex-rs/tui_app_server/src/tooltips.rs @@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Op // Leave small chance for a random tooltip to be shown. if rng.random_ratio(8, 10) { match plan { - Some(PlanType::Plus) - | Some(PlanType::Business) - | Some(PlanType::Team) - | Some(PlanType::Enterprise) - | Some(PlanType::Pro) => { + Some(plan_type) + if matches!( + plan_type, + PlanType::Plus | PlanType::Enterprise | PlanType::Pro + ) || plan_type.is_team_like() + || plan_type.is_business_like() => + { return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string()); } Some(PlanType::Go) | Some(PlanType::Free) => { diff --git a/sdk/python/src/codex_app_server/generated/notification_registry.py b/sdk/python/src/codex_app_server/generated/notification_registry.py index fa5217affe..ca885d92de 100644 --- a/sdk/python/src/codex_app_server/generated/notification_registry.py +++ b/sdk/python/src/codex_app_server/generated/notification_registry.py @@ -17,6 +17,7 @@ from .v2_all import ContextCompactedNotification from .v2_all import DeprecationNoticeNotification from .v2_all import ErrorNotification from .v2_all import FileChangeOutputDeltaNotification +from .v2_all import FsChangedNotification from .v2_all import FuzzyFileSearchSessionCompletedNotification from .v2_all import FuzzyFileSearchSessionUpdatedNotification from .v2_all import HookCompletedNotification @@ -26,6 +27,7 @@ from .v2_all import ItemGuardianApprovalReviewCompletedNotification from .v2_all import ItemGuardianApprovalReviewStartedNotification from .v2_all import ItemStartedNotification from .v2_all import McpServerOauthLoginCompletedNotification +from .v2_all import McpServerStatusUpdatedNotification from .v2_all import McpToolCallProgressNotification from .v2_all import ModelReroutedNotification from .v2_all import PlanDeltaNotification @@ -43,6 +45,7 @@ from .v2_all import ThreadRealtimeErrorNotification from .v2_all import ThreadRealtimeItemAddedNotification from .v2_all import ThreadRealtimeOutputAudioDeltaNotification from .v2_all import ThreadRealtimeStartedNotification +from .v2_all import ThreadRealtimeTranscriptUpdatedNotification from .v2_all import ThreadStartedNotification from .v2_all import ThreadStatusChangedNotification from .v2_all import ThreadTokenUsageUpdatedNotification @@ -63,6 +66,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = { "configWarning": ConfigWarningNotification, "deprecationNotice": DeprecationNoticeNotification, "error": ErrorNotification, + "fs/changed": FsChangedNotification, "fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification, "fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification, "hook/completed": HookCompletedNotification, @@ -81,6 +85,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = { "item/reasoning/textDelta": ReasoningTextDeltaNotification, "item/started": ItemStartedNotification, "mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification, + "mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification, "model/rerouted": ModelReroutedNotification, "serverRequest/resolved": ServerRequestResolvedNotification, "skills/changed": SkillsChangedNotification, @@ -93,6 +98,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = { "thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification, "thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification, "thread/realtime/started": ThreadRealtimeStartedNotification, + "thread/realtime/transcriptUpdated": ThreadRealtimeTranscriptUpdatedNotification, "thread/started": ThreadStartedNotification, "thread/status/changed": ThreadStatusChangedNotification, "thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification, diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py index 0ff2c5897d..21d4968c33 100644 --- a/sdk/python/src/codex_app_server/generated/v2_all.py +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -52,6 +52,13 @@ class AgentMessageDeltaNotification(BaseModel): turn_id: Annotated[str, Field(alias="turnId")] +class AgentPath(RootModel[str]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: str + + class AnalyticsConfig(BaseModel): model_config = ConfigDict( extra="allow", @@ -96,6 +103,7 @@ class AppSummary(BaseModel): id: str install_url: Annotated[str | None, Field(alias="installUrl")] = None name: str + needs_auth: Annotated[bool, Field(alias="needsAuth")] class AppToolApproval(Enum): @@ -312,30 +320,6 @@ class ResponseTooManyFailedAttemptsCodexErrorInfo(BaseModel): ] -class CodexErrorInfo( - RootModel[ - CodexErrorInfoValue - | HttpConnectionFailedCodexErrorInfo - | ResponseStreamConnectionFailedCodexErrorInfo - | ResponseStreamDisconnectedCodexErrorInfo - | ResponseTooManyFailedAttemptsCodexErrorInfo - ] -): - model_config = ConfigDict( - populate_by_name=True, - ) - root: Annotated[ - CodexErrorInfoValue - | HttpConnectionFailedCodexErrorInfo - | ResponseStreamConnectionFailedCodexErrorInfo - | ResponseStreamDisconnectedCodexErrorInfo - | ResponseTooManyFailedAttemptsCodexErrorInfo, - Field( - description="This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant." - ), - ] - - class CollabAgentStatus(Enum): pending_init = "pendingInit" running = "running" @@ -519,6 +503,13 @@ class CommandExecutionOutputDeltaNotification(BaseModel): turn_id: Annotated[str, Field(alias="turnId")] +class CommandExecutionSource(Enum): + agent = "agent" + user_shell = "userShell" + unified_exec_startup = "unifiedExecStartup" + unified_exec_interaction = "unifiedExecInteraction" + + class CommandExecutionStatus(Enum): in_progress = "inProgress" completed = "completed" @@ -753,6 +744,28 @@ class DynamicToolSpec(BaseModel): name: str +class ExperimentalFeatureEnablementSetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + enablement: Annotated[ + dict[str, bool], + Field( + description="Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op." + ), + ] + + +class ExperimentalFeatureEnablementSetResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + enablement: Annotated[ + dict[str, bool], + Field(description="Feature enablement entries updated by this request."), + ] + + class ExperimentalFeatureListParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -844,6 +857,23 @@ class ForcedLoginMethod(Enum): api = "api" +class FsChangedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + changed_paths: Annotated[ + list[AbsolutePathBuf], + Field( + alias="changedPaths", + description="File or directory paths associated with this event.", + ), + ] + watch_id: Annotated[ + str, + Field(alias="watchId", description="Watch identifier returned by `fs/watch`."), + ] + + class FsCopyParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -1018,6 +1048,49 @@ class FsRemoveResponse(BaseModel): ) +class FsUnwatchParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + watch_id: Annotated[ + str, + Field(alias="watchId", description="Watch identifier returned by `fs/watch`."), + ] + + +class FsUnwatchResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + +class FsWatchParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + path: Annotated[ + AbsolutePathBuf, Field(description="Absolute file or directory path to watch.") + ] + + +class FsWatchResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + path: Annotated[ + AbsolutePathBuf, + Field(description="Canonicalized path associated with the watch."), + ] + watch_id: Annotated[ + str, + Field( + alias="watchId", + description="Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + ), + ] + + class FsWriteFileParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -1045,6 +1118,11 @@ class InputTextFunctionCallOutputContentItem(BaseModel): ] +class FuzzyFileSearchMatchType(Enum): + file = "file" + directory = "directory" + + class FuzzyFileSearchParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -1067,6 +1145,7 @@ class FuzzyFileSearchResult(BaseModel): ) file_name: str indices: list[Indice] | None = None + match_type: FuzzyFileSearchMatchType path: str root: str score: Annotated[int, Field(ge=0)] @@ -1134,7 +1213,10 @@ class GuardianRiskLevel(Enum): class HookEventName(Enum): + pre_tool_use = "preToolUse" + post_tool_use = "postToolUse" session_start = "sessionStart" + user_prompt_submit = "userPromptSubmit" stop = "stop" @@ -1157,6 +1239,14 @@ class HookOutputEntryKind(Enum): error = "error" +class HookPromptFragment(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + hook_run_id: Annotated[str, Field(alias="hookRunId")] + text: str + + class HookRunStatus(Enum): running = "running" completed = "completed" @@ -1385,6 +1475,14 @@ class MarketplaceInterface(BaseModel): display_name: Annotated[str | None, Field(alias="displayName")] = None +class MarketplaceLoadErrorInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + marketplace_path: Annotated[AbsolutePathBuf, Field(alias="marketplacePath")] + message: str + + class McpAuthStatus(Enum): unsupported = "unsupported" not_logged_in = "notLoggedIn" @@ -1424,6 +1522,22 @@ class McpServerRefreshResponse(BaseModel): ) +class McpServerStartupState(Enum): + starting = "starting" + ready = "ready" + failed = "failed" + cancelled = "cancelled" + + +class McpServerStatusUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: str | None = None + name: str + status: McpServerStartupState + + class McpToolCallError(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -1455,6 +1569,16 @@ class McpToolCallStatus(Enum): failed = "failed" +class MemoryCitationEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + line_end: Annotated[int, Field(alias="lineEnd", ge=0)] + line_start: Annotated[int, Field(alias="lineStart", ge=0)] + note: str + path: str + + class MergeStrategy(Enum): replace = "replace" upsert = "upsert" @@ -1558,6 +1682,11 @@ class NetworkRequirements(BaseModel): socks_port: Annotated[int | None, Field(alias="socksPort", ge=0)] = None +class NonSteerableTurnKind(Enum): + review = "review" + compact = "compact" + + class PatchApplyStatus(Enum): in_progress = "inProgress" completed = "completed" @@ -1618,7 +1747,9 @@ class PlanType(Enum): plus = "plus" pro = "pro" team = "team" + self_serve_business_usage_based = "self_serve_business_usage_based" business = "business" + enterprise_cbp_usage_based = "enterprise_cbp_usage_based" enterprise = "enterprise" edu = "edu" unknown = "unknown" @@ -1799,6 +1930,11 @@ class ReadOnlyAccess(RootModel[RestrictedReadOnlyAccess | FullAccessReadOnlyAcce root: RestrictedReadOnlyAccess | FullAccessReadOnlyAccess +class RealtimeConversationVersion(Enum): + v1 = "v1" + v2 = "v2" + + class ReasoningEffort(Enum): none = "none" minimal = "minimal" @@ -2364,6 +2500,27 @@ class McpServerOauthLoginCompletedServerNotification(BaseModel): params: McpServerOauthLoginCompletedNotification +class McpServerStartupStatusUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["mcpServer/startupStatus/updated"], + Field(title="McpServer/startupStatus/updatedNotificationMethod"), + ] + params: McpServerStatusUpdatedNotification + + +class FsChangedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["fs/changed"], Field(title="Fs/changedNotificationMethod") + ] + params: FsChangedNotification + + class ItemReasoningSummaryTextDeltaServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -2481,6 +2638,14 @@ class SessionSourceValue(Enum): unknown = "unknown" +class CustomSessionSource(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + custom: str + + class Settings(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -2522,6 +2687,7 @@ class SkillSummary(BaseModel): populate_by_name=True, ) description: str + enabled: bool interface: SkillInterface | None = None name: str path: str @@ -2552,7 +2718,10 @@ class SkillsConfigWriteParams(BaseModel): populate_by_name=True, ) enabled: bool - path: str + name: Annotated[str | None, Field(description="Name-based selector.")] = None + path: Annotated[ + AbsolutePathBuf | None, Field(description="Path-based selector.") + ] = None class SkillsConfigWriteResponse(BaseModel): @@ -2744,14 +2913,13 @@ class ThreadId(RootModel[str]): root: str -class AgentMessageThreadItem(BaseModel): +class HookPromptThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + fragments: list[HookPromptFragment] id: str - phase: MessagePhase | None = None - text: str - type: Annotated[Literal["agentMessage"], Field(title="AgentMessageThreadItemType")] + type: Annotated[Literal["hookPrompt"], Field(title="HookPromptThreadItemType")] class PlanThreadItem(BaseModel): @@ -2811,6 +2979,7 @@ class CommandExecutionThreadItem(BaseModel): description="Identifier for the underlying PTY process (when available).", ), ] = None + source: CommandExecutionSource | None = "agent" status: CommandExecutionStatus type: Annotated[ Literal["commandExecution"], Field(title="CommandExecutionThreadItemType") @@ -2878,6 +3047,7 @@ class ImageGenerationThreadItem(BaseModel): id: str result: str revised_prompt: Annotated[str | None, Field(alias="revisedPrompt")] = None + saved_path: Annotated[str | None, Field(alias="savedPath")] = None status: str type: Annotated[ Literal["imageGeneration"], Field(title="ImageGenerationThreadItemType") @@ -3058,6 +3228,16 @@ class ThreadRealtimeStartedNotification(BaseModel): ) session_id: Annotated[str | None, Field(alias="sessionId")] = None thread_id: Annotated[str, Field(alias="threadId")] + version: RealtimeConversationVersion + + +class ThreadRealtimeTranscriptUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + role: str + text: str + thread_id: Annotated[str, Field(alias="threadId")] class ThreadResumeParams(BaseModel): @@ -3121,6 +3301,26 @@ class ThreadSetNameResponse(BaseModel): ) +class ThreadShellCommandParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + command: Annotated[ + str, + Field( + description="Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy." + ), + ] + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadShellCommandResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + class ThreadSortKey(Enum): created_at = "created_at" updated_at = "updated_at" @@ -3285,17 +3485,6 @@ class TurnDiffUpdatedNotification(BaseModel): turn_id: Annotated[str, Field(alias="turnId")] -class TurnError(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - additional_details: Annotated[str | None, Field(alias="additionalDetails")] = None - codex_error_info: Annotated[ - CodexErrorInfo | None, Field(alias="codexErrorInfo") - ] = None - message: str - - class TurnInterruptParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3703,6 +3892,17 @@ class ThreadCompactStartRequest(BaseModel): params: ThreadCompactStartParams +class ThreadShellCommandRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["thread/shellCommand"], Field(title="Thread/shellCommandRequestMethod") + ] + params: ThreadShellCommandParams + + class ThreadRollbackRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3839,6 +4039,24 @@ class FsCopyRequest(BaseModel): params: FsCopyParams +class FsWatchRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["fs/watch"], Field(title="Fs/watchRequestMethod")] + params: FsWatchParams + + +class FsUnwatchRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["fs/unwatch"], Field(title="Fs/unwatchRequestMethod")] + params: FsUnwatchParams + + class SkillsConfigWriteRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3904,6 +4122,18 @@ class ExperimentalFeatureListRequest(BaseModel): params: ExperimentalFeatureListParams +class ExperimentalFeatureEnablementSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["experimentalFeature/enablement/set"], + Field(title="ExperimentalFeature/enablement/setRequestMethod"), + ] + params: ExperimentalFeatureEnablementSetParams + + class McpServerOauthLoginRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4085,6 +4315,49 @@ class FuzzyFileSearchRequest(BaseModel): params: FuzzyFileSearchParams +class ActiveTurnNotSteerable(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + turn_kind: Annotated[NonSteerableTurnKind, Field(alias="turnKind")] + + +class ActiveTurnNotSteerableCodexErrorInfo(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + active_turn_not_steerable: Annotated[ + ActiveTurnNotSteerable, Field(alias="activeTurnNotSteerable") + ] + + +class CodexErrorInfo( + RootModel[ + CodexErrorInfoValue + | HttpConnectionFailedCodexErrorInfo + | ResponseStreamConnectionFailedCodexErrorInfo + | ResponseStreamDisconnectedCodexErrorInfo + | ResponseTooManyFailedAttemptsCodexErrorInfo + | ActiveTurnNotSteerableCodexErrorInfo + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + CodexErrorInfoValue + | HttpConnectionFailedCodexErrorInfo + | ResponseStreamConnectionFailedCodexErrorInfo + | ResponseStreamDisconnectedCodexErrorInfo + | ResponseTooManyFailedAttemptsCodexErrorInfo + | ActiveTurnNotSteerableCodexErrorInfo, + Field( + description="This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant." + ), + ] + + class CollabAgentState(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4340,16 +4613,6 @@ class ConfigWarningNotification(BaseModel): summary: Annotated[str, Field(description="Concise summary of the warning.")] -class ErrorNotification(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - error: TurnError - thread_id: Annotated[str, Field(alias="threadId")] - turn_id: Annotated[str, Field(alias="turnId")] - will_retry: Annotated[bool, Field(alias="willRetry")] - - class ExperimentalFeature(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4550,6 +4813,14 @@ class McpServerStatus(BaseModel): tools: dict[str, Tool] +class MemoryCitation(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + entries: list[MemoryCitationEntry] + thread_ids: Annotated[list[str], Field(alias="threadIds")] + + class Model(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4670,14 +4941,6 @@ class ReviewStartParams(BaseModel): thread_id: Annotated[str, Field(alias="threadId")] -class ErrorServerNotification(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - method: Annotated[Literal["error"], Field(title="ErrorNotificationMethod")] - params: ErrorNotification - - class ThreadStatusChangedServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4857,6 +5120,17 @@ class ThreadRealtimeItemAddedServerNotification(BaseModel): params: ThreadRealtimeItemAddedNotification +class ThreadRealtimeTranscriptUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/realtime/transcriptUpdated"], + Field(title="Thread/realtime/transcriptUpdatedNotificationMethod"), + ] + params: ThreadRealtimeTranscriptUpdatedNotification + + class ThreadRealtimeOutputAudioDeltaServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4949,6 +5223,7 @@ class ThreadSpawn(BaseModel): populate_by_name=True, ) agent_nickname: str | None = None + agent_path: AgentPath | None = None agent_role: str | None = None depth: int parent_thread_id: ThreadId @@ -4980,6 +5255,19 @@ class UserMessageThreadItem(BaseModel): type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")] +class AgentMessageThreadItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + memory_citation: Annotated[MemoryCitation | None, Field(alias="memoryCitation")] = ( + None + ) + phase: MessagePhase | None = None + text: str + type: Annotated[Literal["agentMessage"], Field(title="AgentMessageThreadItemType")] + + class FileChangeThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5060,6 +5348,7 @@ class WebSearchThreadItem(BaseModel): class ThreadItem( RootModel[ UserMessageThreadItem + | HookPromptThreadItem | AgentMessageThreadItem | PlanThreadItem | ReasoningThreadItem @@ -5081,6 +5370,7 @@ class ThreadItem( ) root: ( UserMessageThreadItem + | HookPromptThreadItem | AgentMessageThreadItem | PlanThreadItem | ReasoningThreadItem @@ -5189,30 +5479,15 @@ class ToolsV2(BaseModel): web_search: WebSearchToolConfig | None = None -class Turn(BaseModel): +class TurnError(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - error: Annotated[ - TurnError | None, - Field(description="Only populated when the Turn's status is failed."), + additional_details: Annotated[str | None, Field(alias="additionalDetails")] = None + codex_error_info: Annotated[ + CodexErrorInfo | None, Field(alias="codexErrorInfo") ] = None - id: str - items: Annotated[ - list[ThreadItem], - Field( - description="Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list." - ), - ] - status: TurnStatus - - -class TurnCompletedNotification(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - thread_id: Annotated[str, Field(alias="threadId")] - turn: Turn + message: str class TurnPlanStep(BaseModel): @@ -5304,21 +5579,6 @@ class TurnStartParams(BaseModel): thread_id: Annotated[str, Field(alias="threadId")] -class TurnStartResponse(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - turn: Turn - - -class TurnStartedNotification(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - thread_id: Annotated[str, Field(alias="threadId")] - turn: Turn - - class TurnSteerParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5507,6 +5767,16 @@ class ConfigWriteResponse(BaseModel): version: str +class ErrorNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: TurnError + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + will_retry: Annotated[bool, Field(alias="willRetry")] + + class ExternalAgentConfigDetectResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5595,6 +5865,12 @@ class PluginListResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + featured_plugin_ids: Annotated[ + list[str] | None, Field(alias="featuredPluginIds") + ] = [] + marketplace_load_errors: Annotated[ + list[MarketplaceLoadErrorInfo] | None, Field(alias="marketplaceLoadErrors") + ] = [] marketplaces: list[PluginMarketplaceEntry] remote_sync_error: Annotated[str | None, Field(alias="remoteSyncError")] = None @@ -5639,6 +5915,7 @@ class CustomToolCallOutputResponseItem(BaseModel): populate_by_name=True, ) call_id: str + name: str | None = None output: FunctionCallOutputBody type: Annotated[ Literal["custom_tool_call_output"], @@ -5685,18 +5962,12 @@ class ResponseItem( ) -class ReviewStartResponse(BaseModel): +class ErrorServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - review_thread_id: Annotated[ - str, - Field( - alias="reviewThreadId", - description="Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.", - ), - ] - turn: Turn + method: Annotated[Literal["error"], Field(title="ErrorNotificationMethod")] + params: ErrorNotification class ThreadTokenUsageUpdatedServerNotification(BaseModel): @@ -5710,26 +5981,6 @@ class ThreadTokenUsageUpdatedServerNotification(BaseModel): params: ThreadTokenUsageUpdatedNotification -class TurnStartedServerNotification(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - method: Annotated[ - Literal["turn/started"], Field(title="Turn/startedNotificationMethod") - ] - params: TurnStartedNotification - - -class TurnCompletedServerNotification(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - method: Annotated[ - Literal["turn/completed"], Field(title="Turn/completedNotificationMethod") - ] - params: TurnCompletedNotification - - class HookCompletedServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5810,11 +6061,296 @@ class SubAgentSessionSource(BaseModel): sub_agent: Annotated[SubAgentSource, Field(alias="subAgent")] -class SessionSource(RootModel[SessionSourceValue | SubAgentSessionSource]): +class SessionSource( + RootModel[SessionSourceValue | CustomSessionSource | SubAgentSessionSource] +): model_config = ConfigDict( populate_by_name=True, ) - root: SessionSourceValue | SubAgentSessionSource + root: SessionSourceValue | CustomSessionSource | SubAgentSessionSource + + +class Turn(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + error: Annotated[ + TurnError | None, + Field(description="Only populated when the Turn's status is failed."), + ] = None + id: str + items: Annotated[ + list[ThreadItem], + Field( + description="Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list." + ), + ] + status: TurnStatus + + +class TurnCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + turn: Turn + + +class TurnStartResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + turn: Turn + + +class TurnStartedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + turn: Turn + + +class ExternalAgentConfigImportRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["externalAgentConfig/import"], + Field(title="ExternalAgentConfig/importRequestMethod"), + ] + params: ExternalAgentConfigImportParams + + +class ConfigBatchWriteRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["config/batchWrite"], Field(title="Config/batchWriteRequestMethod") + ] + params: ConfigBatchWriteParams + + +class ClientRequest( + RootModel[ + InitializeRequest + | ThreadStartRequest + | ThreadResumeRequest + | ThreadForkRequest + | ThreadArchiveRequest + | ThreadUnsubscribeRequest + | ThreadNameSetRequest + | ThreadMetadataUpdateRequest + | ThreadUnarchiveRequest + | ThreadCompactStartRequest + | ThreadShellCommandRequest + | ThreadRollbackRequest + | ThreadListRequest + | ThreadLoadedListRequest + | ThreadReadRequest + | SkillsListRequest + | PluginListRequest + | PluginReadRequest + | AppListRequest + | FsReadFileRequest + | FsWriteFileRequest + | FsCreateDirectoryRequest + | FsGetMetadataRequest + | FsReadDirectoryRequest + | FsRemoveRequest + | FsCopyRequest + | FsWatchRequest + | FsUnwatchRequest + | SkillsConfigWriteRequest + | PluginInstallRequest + | PluginUninstallRequest + | TurnStartRequest + | TurnSteerRequest + | TurnInterruptRequest + | ReviewStartRequest + | ModelListRequest + | ExperimentalFeatureListRequest + | ExperimentalFeatureEnablementSetRequest + | McpServerOauthLoginRequest + | ConfigMcpServerReloadRequest + | McpServerStatusListRequest + | WindowsSandboxSetupStartRequest + | AccountLoginStartRequest + | AccountLoginCancelRequest + | AccountLogoutRequest + | AccountRateLimitsReadRequest + | FeedbackUploadRequest + | CommandExecRequest + | CommandExecWriteRequest + | CommandExecTerminateRequest + | CommandExecResizeRequest + | ConfigReadRequest + | ExternalAgentConfigDetectRequest + | ExternalAgentConfigImportRequest + | ConfigValueWriteRequest + | ConfigBatchWriteRequest + | ConfigRequirementsReadRequest + | AccountReadRequest + | FuzzyFileSearchRequest + ] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + InitializeRequest + | ThreadStartRequest + | ThreadResumeRequest + | ThreadForkRequest + | ThreadArchiveRequest + | ThreadUnsubscribeRequest + | ThreadNameSetRequest + | ThreadMetadataUpdateRequest + | ThreadUnarchiveRequest + | ThreadCompactStartRequest + | ThreadShellCommandRequest + | ThreadRollbackRequest + | ThreadListRequest + | ThreadLoadedListRequest + | ThreadReadRequest + | SkillsListRequest + | PluginListRequest + | PluginReadRequest + | AppListRequest + | FsReadFileRequest + | FsWriteFileRequest + | FsCreateDirectoryRequest + | FsGetMetadataRequest + | FsReadDirectoryRequest + | FsRemoveRequest + | FsCopyRequest + | FsWatchRequest + | FsUnwatchRequest + | SkillsConfigWriteRequest + | PluginInstallRequest + | PluginUninstallRequest + | TurnStartRequest + | TurnSteerRequest + | TurnInterruptRequest + | ReviewStartRequest + | ModelListRequest + | ExperimentalFeatureListRequest + | ExperimentalFeatureEnablementSetRequest + | McpServerOauthLoginRequest + | ConfigMcpServerReloadRequest + | McpServerStatusListRequest + | WindowsSandboxSetupStartRequest + | AccountLoginStartRequest + | AccountLoginCancelRequest + | AccountLogoutRequest + | AccountRateLimitsReadRequest + | FeedbackUploadRequest + | CommandExecRequest + | CommandExecWriteRequest + | CommandExecTerminateRequest + | CommandExecResizeRequest + | ConfigReadRequest + | ExternalAgentConfigDetectRequest + | ExternalAgentConfigImportRequest + | ConfigValueWriteRequest + | ConfigBatchWriteRequest + | ConfigRequirementsReadRequest + | AccountReadRequest + | FuzzyFileSearchRequest, + Field( + description="Request from the client to the server.", title="ClientRequest" + ), + ] + + +class Config(BaseModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + analytics: AnalyticsConfig | None = None + approval_policy: AskForApproval | None = None + approvals_reviewer: Annotated[ + ApprovalsReviewer | None, + Field( + description="[UNSTABLE] Optional default for where approval requests are routed for review." + ), + ] = None + compact_prompt: str | None = None + developer_instructions: str | None = None + forced_chatgpt_workspace_id: str | None = None + forced_login_method: ForcedLoginMethod | None = None + instructions: str | None = None + model: str | None = None + model_auto_compact_token_limit: int | None = None + model_context_window: int | None = None + model_provider: str | None = None + model_reasoning_effort: ReasoningEffort | None = None + model_reasoning_summary: ReasoningSummary | None = None + model_verbosity: Verbosity | None = None + profile: str | None = None + profiles: dict[str, ProfileV2] | None = {} + review_model: str | None = None + sandbox_mode: SandboxMode | None = None + sandbox_workspace_write: SandboxWorkspaceWrite | None = None + service_tier: ServiceTier | None = None + tools: ToolsV2 | None = None + web_search: WebSearchMode | None = None + + +class ConfigReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + config: Config + layers: list[ConfigLayer] | None = None + origins: dict[str, ConfigLayerMetadata] + + +class RawResponseItemCompletedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + item: ResponseItem + thread_id: Annotated[str, Field(alias="threadId")] + turn_id: Annotated[str, Field(alias="turnId")] + + +class ReviewStartResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + review_thread_id: Annotated[ + str, + Field( + alias="reviewThreadId", + description="Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.", + ), + ] + turn: Turn + + +class TurnStartedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["turn/started"], Field(title="Turn/startedNotificationMethod") + ] + params: TurnStartedNotification + + +class TurnCompletedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["turn/completed"], Field(title="Turn/completedNotificationMethod") + ] + params: TurnCompletedNotification class Thread(BaseModel): @@ -6030,206 +6566,6 @@ class ThreadUnarchiveResponse(BaseModel): thread: Thread -class ExternalAgentConfigImportRequest(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - id: RequestId - method: Annotated[ - Literal["externalAgentConfig/import"], - Field(title="ExternalAgentConfig/importRequestMethod"), - ] - params: ExternalAgentConfigImportParams - - -class ConfigBatchWriteRequest(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - id: RequestId - method: Annotated[ - Literal["config/batchWrite"], Field(title="Config/batchWriteRequestMethod") - ] - params: ConfigBatchWriteParams - - -class ClientRequest( - RootModel[ - InitializeRequest - | ThreadStartRequest - | ThreadResumeRequest - | ThreadForkRequest - | ThreadArchiveRequest - | ThreadUnsubscribeRequest - | ThreadNameSetRequest - | ThreadMetadataUpdateRequest - | ThreadUnarchiveRequest - | ThreadCompactStartRequest - | ThreadRollbackRequest - | ThreadListRequest - | ThreadLoadedListRequest - | ThreadReadRequest - | SkillsListRequest - | PluginListRequest - | PluginReadRequest - | AppListRequest - | FsReadFileRequest - | FsWriteFileRequest - | FsCreateDirectoryRequest - | FsGetMetadataRequest - | FsReadDirectoryRequest - | FsRemoveRequest - | FsCopyRequest - | SkillsConfigWriteRequest - | PluginInstallRequest - | PluginUninstallRequest - | TurnStartRequest - | TurnSteerRequest - | TurnInterruptRequest - | ReviewStartRequest - | ModelListRequest - | ExperimentalFeatureListRequest - | McpServerOauthLoginRequest - | ConfigMcpServerReloadRequest - | McpServerStatusListRequest - | WindowsSandboxSetupStartRequest - | AccountLoginStartRequest - | AccountLoginCancelRequest - | AccountLogoutRequest - | AccountRateLimitsReadRequest - | FeedbackUploadRequest - | CommandExecRequest - | CommandExecWriteRequest - | CommandExecTerminateRequest - | CommandExecResizeRequest - | ConfigReadRequest - | ExternalAgentConfigDetectRequest - | ExternalAgentConfigImportRequest - | ConfigValueWriteRequest - | ConfigBatchWriteRequest - | ConfigRequirementsReadRequest - | AccountReadRequest - | FuzzyFileSearchRequest - ] -): - model_config = ConfigDict( - populate_by_name=True, - ) - root: Annotated[ - InitializeRequest - | ThreadStartRequest - | ThreadResumeRequest - | ThreadForkRequest - | ThreadArchiveRequest - | ThreadUnsubscribeRequest - | ThreadNameSetRequest - | ThreadMetadataUpdateRequest - | ThreadUnarchiveRequest - | ThreadCompactStartRequest - | ThreadRollbackRequest - | ThreadListRequest - | ThreadLoadedListRequest - | ThreadReadRequest - | SkillsListRequest - | PluginListRequest - | PluginReadRequest - | AppListRequest - | FsReadFileRequest - | FsWriteFileRequest - | FsCreateDirectoryRequest - | FsGetMetadataRequest - | FsReadDirectoryRequest - | FsRemoveRequest - | FsCopyRequest - | SkillsConfigWriteRequest - | PluginInstallRequest - | PluginUninstallRequest - | TurnStartRequest - | TurnSteerRequest - | TurnInterruptRequest - | ReviewStartRequest - | ModelListRequest - | ExperimentalFeatureListRequest - | McpServerOauthLoginRequest - | ConfigMcpServerReloadRequest - | McpServerStatusListRequest - | WindowsSandboxSetupStartRequest - | AccountLoginStartRequest - | AccountLoginCancelRequest - | AccountLogoutRequest - | AccountRateLimitsReadRequest - | FeedbackUploadRequest - | CommandExecRequest - | CommandExecWriteRequest - | CommandExecTerminateRequest - | CommandExecResizeRequest - | ConfigReadRequest - | ExternalAgentConfigDetectRequest - | ExternalAgentConfigImportRequest - | ConfigValueWriteRequest - | ConfigBatchWriteRequest - | ConfigRequirementsReadRequest - | AccountReadRequest - | FuzzyFileSearchRequest, - Field( - description="Request from the client to the server.", title="ClientRequest" - ), - ] - - -class Config(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - ) - analytics: AnalyticsConfig | None = None - approval_policy: AskForApproval | None = None - approvals_reviewer: Annotated[ - ApprovalsReviewer | None, - Field( - description="[UNSTABLE] Optional default for where approval requests are routed for review." - ), - ] = None - compact_prompt: str | None = None - developer_instructions: str | None = None - forced_chatgpt_workspace_id: str | None = None - forced_login_method: ForcedLoginMethod | None = None - instructions: str | None = None - model: str | None = None - model_auto_compact_token_limit: int | None = None - model_context_window: int | None = None - model_provider: str | None = None - model_reasoning_effort: ReasoningEffort | None = None - model_reasoning_summary: ReasoningSummary | None = None - model_verbosity: Verbosity | None = None - profile: str | None = None - profiles: dict[str, ProfileV2] | None = {} - review_model: str | None = None - sandbox_mode: SandboxMode | None = None - sandbox_workspace_write: SandboxWorkspaceWrite | None = None - service_tier: ServiceTier | None = None - tools: ToolsV2 | None = None - web_search: WebSearchMode | None = None - - -class ConfigReadResponse(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - config: Config - layers: list[ConfigLayer] | None = None - origins: dict[str, ConfigLayerMetadata] - - -class RawResponseItemCompletedNotification(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - item: ResponseItem - thread_id: Annotated[str, Field(alias="threadId")] - turn_id: Annotated[str, Field(alias="turnId")] - - class ThreadStartedServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6270,9 +6606,11 @@ class ServerNotification( | ServerRequestResolvedServerNotification | ItemMcpToolCallProgressServerNotification | McpServerOauthLoginCompletedServerNotification + | McpServerStartupStatusUpdatedServerNotification | AccountUpdatedServerNotification | AccountRateLimitsUpdatedServerNotification | AppListUpdatedServerNotification + | FsChangedServerNotification | ItemReasoningSummaryTextDeltaServerNotification | ItemReasoningSummaryPartAddedServerNotification | ItemReasoningTextDeltaServerNotification @@ -6284,6 +6622,7 @@ class ServerNotification( | FuzzyFileSearchSessionCompletedServerNotification | ThreadRealtimeStartedServerNotification | ThreadRealtimeItemAddedServerNotification + | ThreadRealtimeTranscriptUpdatedServerNotification | ThreadRealtimeOutputAudioDeltaServerNotification | ThreadRealtimeErrorServerNotification | ThreadRealtimeClosedServerNotification @@ -6324,9 +6663,11 @@ class ServerNotification( | ServerRequestResolvedServerNotification | ItemMcpToolCallProgressServerNotification | McpServerOauthLoginCompletedServerNotification + | McpServerStartupStatusUpdatedServerNotification | AccountUpdatedServerNotification | AccountRateLimitsUpdatedServerNotification | AppListUpdatedServerNotification + | FsChangedServerNotification | ItemReasoningSummaryTextDeltaServerNotification | ItemReasoningSummaryPartAddedServerNotification | ItemReasoningTextDeltaServerNotification @@ -6338,6 +6679,7 @@ class ServerNotification( | FuzzyFileSearchSessionCompletedServerNotification | ThreadRealtimeStartedServerNotification | ThreadRealtimeItemAddedServerNotification + | ThreadRealtimeTranscriptUpdatedServerNotification | ThreadRealtimeOutputAudioDeltaServerNotification | ThreadRealtimeErrorServerNotification | ThreadRealtimeClosedServerNotification From 16d4ea9ca8dd2f6ce586168467b568b2ac6973c1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 27 Mar 2026 14:26:54 -0700 Subject: [PATCH 5/6] codex-tools: extract responses API tool models (#16031) ## Why The previous extraction steps moved shared tool-schema parsing into `codex-tools`, but `codex-core` still owned the generic Responses API tool models and the last adapter layer that turned parsed tool definitions into `ResponsesApiTool` values. That left `core/src/tools/spec.rs` and `core/src/client_common.rs` holding a chunk of tool-shaping code that does not need session state, runtime plumbing, or any other `codex-core`-specific dependency. As a result, `codex-tools` owned the parsed tool definition, but `codex-core` still owned the generic wire model that those definitions are converted into. This change moves that boundary one step further. `codex-tools` now owns the reusable Responses/tool wire structs and the shared conversion helpers for dynamic tools, MCP tools, and deferred MCP aliases. `codex-core` continues to own `ToolSpec` orchestration and the remaining web-search-specific request shapes. ## What changed - added `tools/src/responses_api.rs` to own `ResponsesApiTool`, `FreeformTool`, `ToolSearchOutputTool`, namespace output types, and the shared `ToolDefinition -> ResponsesApiTool` adapter helpers - added `tools/src/responses_api_tests.rs` for deferred-loading behavior, adapter coverage, and namespace serialization coverage - rewired `core/src/tools/spec.rs` to use the extracted dynamic/MCP adapter helpers instead of defining those conversions locally - rewired `core/src/tools/handlers/tool_search.rs` to use the extracted deferred MCP adapter and namespace output types directly - slimmed `core/src/client_common.rs` so it now keeps `ToolSpec` and the web-search-specific wire types, while reusing the extracted tool models from `codex-tools` - moved the extracted seam tests out of `core` and updated `codex-rs/tools/README.md` plus `tools/src/lib.rs` to reflect the expanded `codex-tools` boundary ## Test plan - `cargo test -p codex-tools` - `cargo test -p codex-core --lib tools::spec::` - `cargo test -p codex-core --lib tools::handlers::tool_search::` - `just fix -p codex-tools -p codex-core` - `just argument-comment-lint` ## References - [#15923](https://github.com/openai/codex/pull/15923) `codex-tools: extract shared tool schema parsing` - [#15928](https://github.com/openai/codex/pull/15928) `codex-tools: extract MCP schema adapters` - [#15944](https://github.com/openai/codex/pull/15944) `codex-tools: extract dynamic tool adapters` - [#15953](https://github.com/openai/codex/pull/15953) `codex-tools: introduce named tool definitions` --- codex-rs/core/src/client_common.rs | 61 +----- codex-rs/core/src/client_common_tests.rs | 46 ----- .../core/src/tools/handlers/tool_search.rs | 13 +- .../src/tools/handlers/tool_search_tests.rs | 1 + codex-rs/core/src/tools/spec.rs | 50 +---- codex-rs/core/src/tools/spec_tests.rs | 64 +------ codex-rs/tools/README.md | 13 +- codex-rs/tools/src/lib.rs | 14 +- codex-rs/tools/src/responses_api.rs | 102 +++++++++++ codex-rs/tools/src/responses_api_tests.rs | 173 ++++++++++++++++++ 10 files changed, 320 insertions(+), 217 deletions(-) create mode 100644 codex-rs/tools/src/responses_api.rs create mode 100644 codex-rs/tools/src/responses_api_tests.rs diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 33a1f535c6..50a523a26e 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -157,14 +157,16 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { } pub(crate) mod tools { - use crate::tools::spec::JsonSchema; use codex_protocol::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; use codex_protocol::config_types::WebSearchUserLocationType; - use serde::Deserialize; + pub(crate) use codex_tools::FreeformTool; + pub(crate) use codex_tools::FreeformToolFormat; + use codex_tools::JsonSchema; + pub(crate) use codex_tools::ResponsesApiTool; + pub(crate) use codex_tools::ToolSearchOutputTool; use serde::Serialize; - use serde_json::Value; /// When serialized as JSON, this produces a valid "Tool" in the OpenAI /// Responses API. @@ -256,59 +258,6 @@ pub(crate) mod tools { } } } - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - pub struct FreeformTool { - pub(crate) name: String, - pub(crate) description: String, - pub(crate) format: FreeformToolFormat, - } - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - pub struct FreeformToolFormat { - pub(crate) r#type: String, - pub(crate) syntax: String, - pub(crate) definition: String, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub struct ResponsesApiTool { - pub(crate) name: String, - pub(crate) description: String, - /// TODO: Validation. When strict is set to true, the JSON schema, - /// `required` and `additional_properties` must be present. All fields in - /// `properties` must be present in `required`. - pub(crate) strict: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) defer_loading: Option, - pub(crate) parameters: JsonSchema, - #[serde(skip)] - pub(crate) output_schema: Option, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ToolSearchOutputTool { - #[allow(dead_code)] - #[serde(rename = "function")] - Function(ResponsesApiTool), - #[serde(rename = "namespace")] - Namespace(ResponsesApiNamespace), - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiNamespace { - pub(crate) name: String, - pub(crate) description: String, - pub(crate) tools: Vec, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ResponsesApiNamespaceTool { - #[serde(rename = "function")] - Function(ResponsesApiTool), - } } pub struct ResponseStream { diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 2f2305c7ab..c3add62370 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -197,49 +197,3 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { ] ); } - -#[test] -fn tool_search_output_namespace_serializes_with_deferred_child_tools() { - let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace { - name: "mcp__codex_apps__calendar".to_string(), - description: "Plan events".to_string(), - tools: vec![tools::ResponsesApiNamespaceTool::Function( - tools::ResponsesApiTool { - name: "create_event".to_string(), - description: "Create a calendar event.".to_string(), - strict: false, - defer_loading: Some(true), - parameters: crate::tools::spec::JsonSchema::Object { - properties: Default::default(), - required: None, - additional_properties: None, - }, - output_schema: None, - }, - )], - }); - - let value = serde_json::to_value(namespace).expect("serialize namespace"); - - assert_eq!( - value, - serde_json::json!({ - "type": "namespace", - "name": "mcp__codex_apps__calendar", - "description": "Plan events", - "tools": [ - { - "type": "function", - "name": "create_event", - "description": "Create a calendar event.", - "strict": false, - "defer_loading": true, - "parameters": { - "type": "object", - "properties": {} - } - } - ] - }) - ); -} diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 2005c7d2f1..487e57c355 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -1,6 +1,3 @@ -use crate::client_common::tools::ResponsesApiNamespace; -use crate::client_common::tools::ResponsesApiNamespaceTool; -use crate::client_common::tools::ToolSearchOutputTool; use crate::function_tool::FunctionCallError; use crate::mcp_connection_manager::ToolInfo; use crate::tools::context::ToolInvocation; @@ -8,17 +5,17 @@ use crate::tools::context::ToolPayload; use crate::tools::context::ToolSearchOutput; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use crate::tools::spec::mcp_tool_to_deferred_openai_tool; use async_trait::async_trait; use bm25::Document; use bm25::Language; use bm25::SearchEngineBuilder; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ToolSearchOutputTool; +use codex_tools::mcp_tool_to_deferred_responses_api_tool; use std::collections::BTreeMap; use std::collections::HashMap; -#[cfg(test)] -use crate::client_common::tools::ResponsesApiTool; - pub struct ToolSearchHandler { tools: HashMap, } @@ -129,7 +126,7 @@ fn serialize_tool_search_output_tools( let tools = tools .iter() .map(|tool| { - mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone()) + mcp_tool_to_deferred_responses_api_tool(tool.tool_name.clone(), &tool.tool) .map(ResponsesApiNamespaceTool::Function) }) .collect::, _>>()?; diff --git a/codex-rs/core/src/tools/handlers/tool_search_tests.rs b/codex-rs/core/src/tools/handlers/tool_search_tests.rs index 704653c214..9a90ad738c 100644 --- a/codex-rs/core/src/tools/handlers/tool_search_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_search_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_tools::ResponsesApiTool; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 053e1b9ea5..e938f82f8e 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,6 +1,3 @@ -use crate::client_common::tools::FreeformTool; -use crate::client_common::tools::FreeformToolFormat; -use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::config::AgentRoleConfig; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -46,9 +43,11 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tools::ToolDefinition; -use codex_tools::parse_dynamic_tool; -use codex_tools::parse_mcp_tool; +use codex_tools::FreeformTool; +use codex_tools::FreeformToolFormat; +use codex_tools::ResponsesApiTool; +use codex_tools::dynamic_tool_to_responses_api_tool; +use codex_tools::mcp_tool_to_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_template::Template; use serde::Deserialize; @@ -2382,41 +2381,6 @@ fn push_tool_spec( } } -pub(crate) fn mcp_tool_to_openai_tool( - fully_qualified_name: String, - tool: rmcp::model::Tool, -) -> Result { - Ok(tool_definition_to_openai_tool( - parse_mcp_tool(&tool)?.renamed(fully_qualified_name), - )) -} - -pub(crate) fn mcp_tool_to_deferred_openai_tool( - name: String, - tool: rmcp::model::Tool, -) -> Result { - Ok(tool_definition_to_openai_tool( - parse_mcp_tool(&tool)?.renamed(name).into_deferred(), - )) -} - -fn dynamic_tool_to_openai_tool( - tool: &DynamicToolSpec, -) -> Result { - Ok(tool_definition_to_openai_tool(parse_dynamic_tool(tool)?)) -} - -fn tool_definition_to_openai_tool(tool_definition: ToolDefinition) -> ResponsesApiTool { - ResponsesApiTool { - name: tool_definition.name, - description: tool_definition.description, - strict: false, - defer_loading: tool_definition.defer_loading.then_some(true), - parameters: tool_definition.input_schema, - output_schema: tool_definition.output_schema, - } -} - /// Builds the tool registry builder while collecting tool specs for later serialization. #[cfg(test)] pub(crate) fn build_specs( @@ -2911,7 +2875,7 @@ pub(crate) fn build_specs_with_discoverable_tools( entries.sort_by(|a, b| a.0.cmp(&b.0)); for (name, tool) in entries.into_iter() { - match mcp_tool_to_openai_tool(name.clone(), tool.clone()) { + match mcp_tool_to_responses_api_tool(name.clone(), &tool) { Ok(converted_tool) => { push_tool_spec( &mut builder, @@ -2930,7 +2894,7 @@ pub(crate) fn build_specs_with_discoverable_tools( if !dynamic_tools.is_empty() { for tool in dynamic_tools { - match dynamic_tool_to_openai_tool(tool) { + match dynamic_tool_to_responses_api_tool(tool) { Ok(converted_tool) => { push_tool_spec( &mut builder, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index cbdabf88f4..9a096b8bca 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -8,11 +8,11 @@ use crate::tools::ToolRouter; use crate::tools::registry::ConfiguredToolSpec; use crate::tools::router::ToolRouterParams; use codex_app_server_protocol::AppInfo; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_tools::AdditionalProperties; +use codex_tools::mcp_tool_to_deferred_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -64,28 +64,6 @@ fn search_capable_model_info() -> ModelInfo { model_info } -#[test] -fn search_tool_deferred_tools_always_set_defer_loading_true() { - let tool = mcp_tool( - "lookup_order", - "Look up an order", - serde_json::json!({ - "type": "object", - "properties": { - "order_id": {"type": "string"} - }, - "required": ["order_id"], - "additionalProperties": false, - }), - ); - - let openai_tool = - mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) - .expect("convert deferred tool"); - - assert_eq!(openai_tool.defer_loading, Some(true)); -} - #[test] fn deferred_responses_api_tool_serializes_with_defer_loading() { let tool = mcp_tool( @@ -102,7 +80,7 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); let serialized = serde_json::to_value(ToolSpec::Function( - mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) + mcp_tool_to_deferred_responses_api_tool("mcp__codex_apps__lookup_order".to_string(), &tool) .expect("convert deferred tool"), )) .expect("serialize deferred tool"); @@ -127,44 +105,6 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); } -#[test] -fn dynamic_tool_preserves_defer_loading() { - let tool = DynamicToolSpec { - name: "lookup_order".to_string(), - description: "Look up an order".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": { - "order_id": {"type": "string"} - }, - "required": ["order_id"], - "additionalProperties": false, - }), - defer_loading: true, - }; - - let openai_tool = dynamic_tool_to_openai_tool(&tool).expect("convert dynamic tool"); - - assert_eq!( - openai_tool, - ResponsesApiTool { - name: "lookup_order".to_string(), - description: "Look up an order".to_string(), - strict: false, - defer_loading: Some(true), - parameters: JsonSchema::Object { - properties: BTreeMap::from([( - "order_id".to_string(), - JsonSchema::String { description: None }, - )]), - required: Some(vec!["order_id".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - } - ); -} - fn tool_name(tool: &ToolSpec) -> &str { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index aeedbbdde3..ca73e8cf63 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -5,15 +5,26 @@ shared across multiple crates and does not need to stay coupled to `codex-core`. Today this crate is intentionally small. It currently owns the shared tool -schema primitives that no longer need to live in `core/src/tools/spec.rs`: +schema and Responses API tool primitives that no longer need to live in +`core/src/tools/spec.rs` or `core/src/client_common.rs`: - `JsonSchema` - `AdditionalProperties` - `ToolDefinition` +- `ResponsesApiTool` +- `FreeformTool` +- `FreeformToolFormat` +- `ToolSearchOutputTool` +- `ResponsesApiNamespace` +- `ResponsesApiNamespaceTool` - `parse_tool_input_schema()` - `parse_dynamic_tool()` - `parse_mcp_tool()` - `mcp_call_tool_result_output_schema()` +- `tool_definition_to_responses_api_tool()` +- `dynamic_tool_to_responses_api_tool()` +- `mcp_tool_to_responses_api_tool()` +- `mcp_tool_to_deferred_responses_api_tool()` That extraction is the first step in a longer migration. The goal is not to move all of `core/src/tools` into this crate in one shot. Instead, the plan is diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 0579e8e725..92dc860d6c 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -1,8 +1,10 @@ -//! Shared tool-schema parsing primitives that can live outside `codex-core`. +//! Shared tool definitions and Responses API tool primitives that can live +//! outside `codex-core`. mod dynamic_tool; mod json_schema; mod mcp_tool; +mod responses_api; mod tool_definition; pub use dynamic_tool::parse_dynamic_tool; @@ -11,4 +13,14 @@ pub use json_schema::JsonSchema; pub use json_schema::parse_tool_input_schema; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; +pub use responses_api::FreeformTool; +pub use responses_api::FreeformToolFormat; +pub use responses_api::ResponsesApiNamespace; +pub use responses_api::ResponsesApiNamespaceTool; +pub use responses_api::ResponsesApiTool; +pub use responses_api::ToolSearchOutputTool; +pub use responses_api::dynamic_tool_to_responses_api_tool; +pub use responses_api::mcp_tool_to_deferred_responses_api_tool; +pub use responses_api::mcp_tool_to_responses_api_tool; +pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_definition::ToolDefinition; diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs new file mode 100644 index 0000000000..6a8823582a --- /dev/null +++ b/codex-rs/tools/src/responses_api.rs @@ -0,0 +1,102 @@ +use crate::JsonSchema; +use crate::ToolDefinition; +use crate::parse_dynamic_tool; +use crate::parse_mcp_tool; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FreeformTool { + pub name: String, + pub description: String, + pub format: FreeformToolFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FreeformToolFormat { + pub r#type: String, + pub syntax: String, + pub definition: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiTool { + pub name: String, + pub description: String, + /// TODO: Validation. When strict is set to true, the JSON schema, + /// `required` and `additional_properties` must be present. All fields in + /// `properties` must be present in `required`. + pub strict: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub defer_loading: Option, + pub parameters: JsonSchema, + #[serde(skip)] + pub output_schema: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(tag = "type")] +pub enum ToolSearchOutputTool { + #[allow(dead_code)] + #[serde(rename = "function")] + Function(ResponsesApiTool), + #[serde(rename = "namespace")] + Namespace(ResponsesApiNamespace), +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiNamespace { + pub name: String, + pub description: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(tag = "type")] +pub enum ResponsesApiNamespaceTool { + #[serde(rename = "function")] + Function(ResponsesApiTool), +} + +pub fn dynamic_tool_to_responses_api_tool( + tool: &DynamicToolSpec, +) -> Result { + Ok(tool_definition_to_responses_api_tool(parse_dynamic_tool( + tool, + )?)) +} + +pub fn mcp_tool_to_responses_api_tool( + name: String, + tool: &rmcp::model::Tool, +) -> Result { + Ok(tool_definition_to_responses_api_tool( + parse_mcp_tool(tool)?.renamed(name), + )) +} + +pub fn mcp_tool_to_deferred_responses_api_tool( + name: String, + tool: &rmcp::model::Tool, +) -> Result { + Ok(tool_definition_to_responses_api_tool( + parse_mcp_tool(tool)?.renamed(name).into_deferred(), + )) +} + +pub fn tool_definition_to_responses_api_tool(tool_definition: ToolDefinition) -> ResponsesApiTool { + ResponsesApiTool { + name: tool_definition.name, + description: tool_definition.description, + strict: false, + defer_loading: tool_definition.defer_loading.then_some(true), + parameters: tool_definition.input_schema, + output_schema: tool_definition.output_schema, + } +} + +#[cfg(test)] +#[path = "responses_api_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/responses_api_tests.rs b/codex-rs/tools/src/responses_api_tests.rs new file mode 100644 index 0000000000..ee19f37285 --- /dev/null +++ b/codex-rs/tools/src/responses_api_tests.rs @@ -0,0 +1,173 @@ +use super::ResponsesApiNamespace; +use super::ResponsesApiNamespaceTool; +use super::ResponsesApiTool; +use super::ToolSearchOutputTool; +use super::dynamic_tool_to_responses_api_tool; +use super::mcp_tool_to_deferred_responses_api_tool; +use super::tool_definition_to_responses_api_tool; +use crate::JsonSchema; +use crate::ToolDefinition; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; + +#[test] +fn tool_definition_to_responses_api_tool_omits_false_defer_loading() { + assert_eq!( + tool_definition_to_responses_api_tool(ToolDefinition { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + input_schema: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(json!({"type": "object"})), + defer_loading: false, + }), + ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(json!({"type": "object"})), + } + ); +} + +#[test] +fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() { + let tool = DynamicToolSpec { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }), + defer_loading: true, + }; + + assert_eq!( + dynamic_tool_to_responses_api_tool(&tool).expect("convert dynamic tool"), + ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + } + ); +} + +#[test] +fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() { + let tool = rmcp::model::Tool { + name: "lookup_order".to_string().into(), + title: None, + description: Some("Look up an order".to_string().into()), + input_schema: std::sync::Arc::new(rmcp::model::object(json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }))), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + assert_eq!( + mcp_tool_to_deferred_responses_api_tool( + "mcp__codex_apps__lookup_order".to_string(), + &tool, + ) + .expect("convert deferred tool"), + ResponsesApiTool { + name: "mcp__codex_apps__lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + } + ); +} + +#[test] +fn tool_search_output_namespace_serializes_with_deferred_child_tools() { + let namespace = ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__calendar".to_string(), + description: "Plan events".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "create_event".to_string(), + description: "Create a calendar event.".to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + })], + }); + + let value = serde_json::to_value(namespace).expect("serialize namespace"); + + assert_eq!( + value, + json!({ + "type": "namespace", + "name": "mcp__codex_apps__calendar", + "description": "Plan events", + "tools": [ + { + "type": "function", + "name": "create_event", + "description": "Create a calendar event.", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": {} + } + } + ] + }) + ); +} From d06ae316d6c5432a141190237b002cc1b4b07f5f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 27 Mar 2026 14:47:30 -0700 Subject: [PATCH 6/6] codex-tools: extract tool spec models --- codex-rs/core/src/client_common.rs | 100 +------------- codex-rs/core/src/tools/spec_tests.rs | 17 +-- codex-rs/tools/README.md | 3 + codex-rs/tools/src/lib.rs | 4 + codex-rs/tools/src/tool_spec.rs | 105 +++++++++++++++ codex-rs/tools/src/tool_spec_tests.rs | 183 ++++++++++++++++++++++++++ 6 files changed, 303 insertions(+), 109 deletions(-) create mode 100644 codex-rs/tools/src/tool_spec.rs create mode 100644 codex-rs/tools/src/tool_spec_tests.rs diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 50a523a26e..908dec56c6 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,10 +1,10 @@ -use crate::client_common::tools::ToolSpec; use crate::config::types::Personality; use crate::error::Result; pub use codex_api::common::ResponseEvent; use codex_protocol::models::BaseInstructions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseItem; +use codex_tools::ToolSpec; use futures::Stream; use serde::Deserialize; use serde_json::Value; @@ -157,107 +157,11 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { } pub(crate) mod tools { - use codex_protocol::config_types::WebSearchContextSize; - use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; - use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; - use codex_protocol::config_types::WebSearchUserLocationType; pub(crate) use codex_tools::FreeformTool; pub(crate) use codex_tools::FreeformToolFormat; - use codex_tools::JsonSchema; pub(crate) use codex_tools::ResponsesApiTool; pub(crate) use codex_tools::ToolSearchOutputTool; - use serde::Serialize; - - /// When serialized as JSON, this produces a valid "Tool" in the OpenAI - /// Responses API. - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ToolSpec { - #[serde(rename = "function")] - Function(ResponsesApiTool), - #[serde(rename = "tool_search")] - ToolSearch { - execution: String, - description: String, - parameters: JsonSchema, - }, - #[serde(rename = "local_shell")] - LocalShell {}, - #[serde(rename = "image_generation")] - ImageGeneration { output_format: String }, - // TODO: Understand why we get an error on web_search although the API docs say it's supported. - // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C - // The `external_web_access` field determines whether the web search is over cached or live content. - // https://platform.openai.com/docs/guides/tools-web-search#live-internet-access - #[serde(rename = "web_search")] - WebSearch { - #[serde(skip_serializing_if = "Option::is_none")] - external_web_access: Option, - #[serde(skip_serializing_if = "Option::is_none")] - filters: Option, - #[serde(skip_serializing_if = "Option::is_none")] - user_location: Option, - #[serde(skip_serializing_if = "Option::is_none")] - search_context_size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - search_content_types: Option>, - }, - #[serde(rename = "custom")] - Freeform(FreeformTool), - } - - impl ToolSpec { - pub(crate) fn name(&self) -> &str { - match self { - ToolSpec::Function(tool) => tool.name.as_str(), - ToolSpec::ToolSearch { .. } => "tool_search", - ToolSpec::LocalShell {} => "local_shell", - ToolSpec::ImageGeneration { .. } => "image_generation", - ToolSpec::WebSearch { .. } => "web_search", - ToolSpec::Freeform(tool) => tool.name.as_str(), - } - } - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiWebSearchFilters { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) allowed_domains: Option>, - } - - impl From for ResponsesApiWebSearchFilters { - fn from(filters: ConfigWebSearchFilters) -> Self { - Self { - allowed_domains: filters.allowed_domains, - } - } - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiWebSearchUserLocation { - #[serde(rename = "type")] - pub(crate) r#type: WebSearchUserLocationType, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) country: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) region: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) city: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) timezone: Option, - } - - impl From for ResponsesApiWebSearchUserLocation { - fn from(user_location: ConfigWebSearchUserLocation) -> Self { - Self { - r#type: user_location.r#type, - country: user_location.country, - region: user_location.region, - city: user_location.city, - timezone: user_location.timezone, - } - } - } + pub(crate) use codex_tools::ToolSpec; } pub struct ResponseStream { diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 9a096b8bca..3e84c41268 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1,4 +1,3 @@ -use crate::client_common::tools::FreeformTool; use crate::config::test_config; use crate::models_manager::manager::ModelsManager; use crate::models_manager::model_info::with_config_overrides; @@ -12,6 +11,9 @@ use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_tools::AdditionalProperties; +use codex_tools::FreeformTool; +use codex_tools::ResponsesApiWebSearchFilters; +use codex_tools::ResponsesApiWebSearchUserLocation; use codex_tools::mcp_tool_to_deferred_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -106,14 +108,7 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { } fn tool_name(tool: &ToolSpec) -> &str { - match tool { - ToolSpec::Function(ResponsesApiTool { name, .. }) => name, - ToolSpec::ToolSearch { .. } => "tool_search", - ToolSpec::LocalShell {} => "local_shell", - ToolSpec::ImageGeneration { .. } => "image_generation", - ToolSpec::WebSearch { .. } => "web_search", - ToolSpec::Freeform(FreeformTool { name, .. }) => name, - } + tool.name() } // Avoid order-based assertions; compare via set containment instead. @@ -1077,10 +1072,10 @@ fn web_search_config_is_forwarded_to_tool_spec() { external_web_access: Some(true), filters: web_search_config .filters - .map(crate::client_common::tools::ResponsesApiWebSearchFilters::from), + .map(ResponsesApiWebSearchFilters::from), user_location: web_search_config .user_location - .map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from), + .map(ResponsesApiWebSearchUserLocation::from), search_context_size: web_search_config.search_context_size, search_content_types: None, } diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index ca73e8cf63..d8dabdcd72 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -11,10 +11,13 @@ schema and Responses API tool primitives that no longer need to live in - `JsonSchema` - `AdditionalProperties` - `ToolDefinition` +- `ToolSpec` - `ResponsesApiTool` - `FreeformTool` - `FreeformToolFormat` - `ToolSearchOutputTool` +- `ResponsesApiWebSearchFilters` +- `ResponsesApiWebSearchUserLocation` - `ResponsesApiNamespace` - `ResponsesApiNamespaceTool` - `parse_tool_input_schema()` diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 92dc860d6c..05b527ba94 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -6,6 +6,7 @@ mod json_schema; mod mcp_tool; mod responses_api; mod tool_definition; +mod tool_spec; pub use dynamic_tool::parse_dynamic_tool; pub use json_schema::AdditionalProperties; @@ -24,3 +25,6 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool; pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_definition::ToolDefinition; +pub use tool_spec::ResponsesApiWebSearchFilters; +pub use tool_spec::ResponsesApiWebSearchUserLocation; +pub use tool_spec::ToolSpec; diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs new file mode 100644 index 0000000000..fc63223e1b --- /dev/null +++ b/codex-rs/tools/src/tool_spec.rs @@ -0,0 +1,105 @@ +use crate::FreeformTool; +use crate::JsonSchema; +use crate::ResponsesApiTool; +use codex_protocol::config_types::WebSearchContextSize; +use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; +use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; +use codex_protocol::config_types::WebSearchUserLocationType; +use serde::Serialize; + +/// When serialized as JSON, this produces a valid "Tool" in the OpenAI +/// Responses API. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(tag = "type")] +pub enum ToolSpec { + #[serde(rename = "function")] + Function(ResponsesApiTool), + #[serde(rename = "tool_search")] + ToolSearch { + execution: String, + description: String, + parameters: JsonSchema, + }, + #[serde(rename = "local_shell")] + LocalShell {}, + #[serde(rename = "image_generation")] + ImageGeneration { output_format: String }, + // TODO: Understand why we get an error on web_search although the API docs + // say it's supported. + // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C + // The `external_web_access` field determines whether the web search is over + // cached or live content. + // https://platform.openai.com/docs/guides/tools-web-search#live-internet-access + #[serde(rename = "web_search")] + WebSearch { + #[serde(skip_serializing_if = "Option::is_none")] + external_web_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + search_context_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + search_content_types: Option>, + }, + #[serde(rename = "custom")] + Freeform(FreeformTool), +} + +impl ToolSpec { + pub fn name(&self) -> &str { + match self { + ToolSpec::Function(tool) => tool.name.as_str(), + ToolSpec::ToolSearch { .. } => "tool_search", + ToolSpec::LocalShell {} => "local_shell", + ToolSpec::ImageGeneration { .. } => "image_generation", + ToolSpec::WebSearch { .. } => "web_search", + ToolSpec::Freeform(tool) => tool.name.as_str(), + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiWebSearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_domains: Option>, +} + +impl From for ResponsesApiWebSearchFilters { + fn from(filters: ConfigWebSearchFilters) -> Self { + Self { + allowed_domains: filters.allowed_domains, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiWebSearchUserLocation { + #[serde(rename = "type")] + pub r#type: WebSearchUserLocationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timezone: Option, +} + +impl From for ResponsesApiWebSearchUserLocation { + fn from(user_location: ConfigWebSearchUserLocation) -> Self { + Self { + r#type: user_location.r#type, + country: user_location.country, + region: user_location.region, + city: user_location.city, + timezone: user_location.timezone, + } + } +} + +#[cfg(test)] +#[path = "tool_spec_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs new file mode 100644 index 0000000000..33d31c1401 --- /dev/null +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -0,0 +1,183 @@ +use super::ResponsesApiWebSearchFilters; +use super::ResponsesApiWebSearchUserLocation; +use super::ToolSpec; +use crate::AdditionalProperties; +use crate::FreeformTool; +use crate::FreeformToolFormat; +use crate::JsonSchema; +use crate::ResponsesApiTool; +use codex_protocol::config_types::WebSearchContextSize; +use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; +use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; +use codex_protocol::config_types::WebSearchUserLocationType; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; + +#[test] +fn tool_spec_name_covers_all_variants() { + assert_eq!( + ToolSpec::Function(ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + output_schema: None, + }) + .name(), + "lookup_order" + ); + assert_eq!( + ToolSpec::ToolSearch { + execution: "sync".to_string(), + description: "Search for tools".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + } + .name(), + "tool_search" + ); + assert_eq!(ToolSpec::LocalShell {}.name(), "local_shell"); + assert_eq!( + ToolSpec::ImageGeneration { + output_format: "png".to_string(), + } + .name(), + "image_generation" + ); + assert_eq!( + ToolSpec::WebSearch { + external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, + search_content_types: None, + } + .name(), + "web_search" + ); + assert_eq!( + ToolSpec::Freeform(FreeformTool { + name: "exec".to_string(), + description: "Run a command".to_string(), + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: \"exec\"".to_string(), + }, + }) + .name(), + "exec" + ); +} + +#[test] +fn web_search_config_converts_to_responses_api_types() { + assert_eq!( + ResponsesApiWebSearchFilters::from(ConfigWebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + }), + ResponsesApiWebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + } + ); + assert_eq!( + ResponsesApiWebSearchUserLocation::from(ConfigWebSearchUserLocation { + r#type: WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }), + ResponsesApiWebSearchUserLocation { + r#type: WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + } + ); +} + +#[test] +fn web_search_tool_spec_serializes_expected_wire_shape() { + assert_eq!( + serde_json::to_value(ToolSpec::WebSearch { + external_web_access: Some(true), + filters: Some(ResponsesApiWebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + }), + user_location: Some(ResponsesApiWebSearchUserLocation { + r#type: WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }), + search_context_size: Some(WebSearchContextSize::High), + search_content_types: Some(vec!["text".to_string(), "image".to_string()]), + }) + .expect("serialize web_search"), + json!({ + "type": "web_search", + "external_web_access": true, + "filters": { + "allowed_domains": ["example.com"], + }, + "user_location": { + "type": "approximate", + "country": "US", + "region": "California", + "city": "San Francisco", + "timezone": "America/Los_Angeles", + }, + "search_context_size": "high", + "search_content_types": ["text", "image"], + }) + ); +} + +#[test] +fn tool_search_tool_spec_serializes_expected_wire_shape() { + assert_eq!( + serde_json::to_value(ToolSpec::ToolSearch { + execution: "sync".to_string(), + description: "Search app tools".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "query".to_string(), + JsonSchema::String { + description: Some("Tool search query".to_string()), + }, + )]), + required: Some(vec!["query".to_string()]), + additional_properties: Some(AdditionalProperties::Boolean(false)), + }, + }) + .expect("serialize tool_search"), + json!({ + "type": "tool_search", + "execution": "sync", + "description": "Search app tools", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Tool search query", + } + }, + "required": ["query"], + "additionalProperties": false, + }, + }) + ); +}