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/client_common.rs b/codex-rs/core/src/client_common.rs index 33a1f535c6..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,158 +157,11 @@ 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; - use serde::Serialize; - use serde_json::Value; - - /// 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, - } - } - } - - #[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(crate) use codex_tools::FreeformTool; + pub(crate) use codex_tools::FreeformToolFormat; + pub(crate) use codex_tools::ResponsesApiTool; + pub(crate) use codex_tools::ToolSearchOutputTool; + pub(crate) use codex_tools::ToolSpec; } 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/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/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/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/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..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; @@ -8,11 +7,14 @@ 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::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; use std::path::PathBuf; @@ -64,28 +66,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 +82,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,53 +107,8 @@ 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, - 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. @@ -1137,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/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/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. 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/tools/README.md b/codex-rs/tools/README.md index aeedbbdde3..d8dabdcd72 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -5,15 +5,29 @@ 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` +- `ToolSpec` +- `ResponsesApiTool` +- `FreeformTool` +- `FreeformToolFormat` +- `ToolSearchOutputTool` +- `ResponsesApiWebSearchFilters` +- `ResponsesApiWebSearchUserLocation` +- `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..05b527ba94 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -1,9 +1,12 @@ -//! 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; +mod tool_spec; pub use dynamic_tool::parse_dynamic_tool; pub use json_schema::AdditionalProperties; @@ -11,4 +14,17 @@ 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; +pub use tool_spec::ResponsesApiWebSearchFilters; +pub use tool_spec::ResponsesApiWebSearchUserLocation; +pub use tool_spec::ToolSpec; 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": {} + } + } + ] + }) + ); +} 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, + }, + }) + ); +} 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/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/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/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/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) 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