diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5441a8a6d..17a367e66 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -8,6 +8,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode as CoreSandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; @@ -327,6 +328,7 @@ pub struct ProfileV2 { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, + pub web_search: Option, pub chatgpt_base_url: Option, #[serde(default, flatten)] pub additional: HashMap, @@ -355,6 +357,7 @@ pub struct Config { pub sandbox_workspace_write: Option, pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, + pub web_search: Option, pub tools: Option, pub profile: Option, #[serde(default)] diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 68b54d5b1..1c82eae00 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -691,11 +691,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() .parse_overrides() .map_err(anyhow::Error::msg)?; - // Honor `--search` via the new feature toggle. + // Honor `--search` via the canonical web_search mode. if interactive.web_search { cli_kv_overrides.push(( - "features.web_search_request".to_string(), - toml::Value::Boolean(true), + "web_search".to_string(), + toml::Value::String("live".to_string()), )); } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 580f3084e..37a0cce70 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -395,6 +395,14 @@ } ] }, + "web_search": { + "description": "Controls the web search tool mode: disabled, cached, or live.", + "allOf": [ + { + "$ref": "#/definitions/WebSearchMode" + } + ] + }, "windows_wsl_setup_acknowledged": { "description": "Tracks whether the Windows onboarding screen has been acknowledged.", "type": "boolean" @@ -634,6 +642,9 @@ }, "tools_web_search": { "type": "boolean" + }, + "web_search": { + "$ref": "#/definitions/WebSearchMode" } }, "additionalProperties": false @@ -1420,6 +1431,14 @@ "high" ] }, + "WebSearchMode": { + "type": "string", + "enum": [ + "disabled", + "cached", + "live" + ] + }, "WireApi": { "description": "Wire protocol that the provider speaks. Most third-party services only implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI itself (and a handful of others) additionally expose the more modern *Responses* API. The two protocols use different request/response shapes and *cannot* be auto-detected at runtime, therefore each provider entry must declare which one it expects.", "oneOf": [ diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8e7065cde..da0719b00 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -34,6 +34,7 @@ use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::FileChange; @@ -537,6 +538,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &per_turn_config.features, + web_search_mode: per_turn_config.web_search_mode, }); let base_instructions = if per_turn_config.features.enabled(Feature::Collab) { @@ -2409,9 +2411,11 @@ async fn spawn_review_thread( review_features .disable(crate::features::Feature::WebSearchRequest) .disable(crate::features::Feature::WebSearchCached); + let review_web_search_mode = WebSearchMode::Disabled; let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &review_model_info, features: &review_features, + web_search_mode: review_web_search_mode, }); let base_instructions = REVIEW_PROMPT.to_string(); @@ -2424,6 +2428,7 @@ async fn spawn_review_thread( let mut per_turn_config = (*config).clone(); per_turn_config.model = Some(model.clone()); per_turn_config.features = review_features.clone(); + per_turn_config.web_search_mode = review_web_search_mode; let otel_manager = parent_turn_context .client diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 486a04ec4..5e5d26c79 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -42,6 +42,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; @@ -336,7 +337,7 @@ pub struct Config { /// model info's default preference. pub include_apply_patch_tool: bool, - pub tools_web_search_request: bool, + pub web_search_mode: WebSearchMode, /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -894,6 +895,9 @@ pub struct ConfigToml { pub projects: Option>, + /// Controls the web search tool mode: disabled, cached, or live. + pub web_search: Option, + /// Nested tools section for feature toggles pub tools: Option, @@ -1178,6 +1182,26 @@ pub fn resolve_oss_provider( } } +/// Resolve the web search mode from the config, profile, and features. +fn resolve_web_search_mode( + config_toml: &ConfigToml, + config_profile: &ConfigProfile, + features: &Features, +) -> WebSearchMode { + // Enum gets precedence over features flags + if let Some(mode) = config_profile.web_search.or(config_toml.web_search) { + return mode; + } + if features.enabled(Feature::WebSearchCached) { + return WebSearchMode::Cached; + } + if features.enabled(Feature::WebSearchRequest) { + return WebSearchMode::Live; + } + // Fall back to default + WebSearchMode::default() +} + impl Config { #[cfg(test)] fn load_from_base_config_with_overrides( @@ -1242,6 +1266,7 @@ impl Config { }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); + let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features); #[cfg(target_os = "windows")] { // Base flag controls sandbox on/off; elevated only applies when base is enabled. @@ -1361,7 +1386,6 @@ impl Config { }; let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); - let tools_web_search_request = features.enabled(Feature::WebSearchRequest); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let forced_chatgpt_workspace_id = @@ -1502,7 +1526,7 @@ impl Config { forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, - tools_web_search_request, + web_search_mode, use_experimental_unified_exec_tool, ghost_snapshot, features, @@ -2177,6 +2201,50 @@ trust_level = "trusted" Ok(()) } + #[test] + fn web_search_mode_uses_default_if_unset() { + let cfg = ConfigToml::default(); + let profile = ConfigProfile::default(); + let features = Features::with_defaults(); + + assert_eq!( + resolve_web_search_mode(&cfg, &profile, &features), + WebSearchMode::default() + ); + } + + #[test] + fn web_search_mode_prefers_profile_over_legacy_flags() { + let cfg = ConfigToml::default(); + let profile = ConfigProfile { + web_search: Some(WebSearchMode::Live), + ..Default::default() + }; + let mut features = Features::with_defaults(); + features.enable(Feature::WebSearchCached); + + assert_eq!( + resolve_web_search_mode(&cfg, &profile, &features), + WebSearchMode::Live + ); + } + + #[test] + fn web_search_mode_disabled_overrides_legacy_request() { + let cfg = ConfigToml { + web_search: Some(WebSearchMode::Disabled), + ..Default::default() + }; + let profile = ConfigProfile::default(); + let mut features = Features::with_defaults(); + features.enable(Feature::WebSearchRequest); + + assert_eq!( + resolve_web_search_mode(&cfg, &profile, &features), + WebSearchMode::Disabled + ); + } + #[test] fn profile_legacy_toggles_override_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3513,7 +3581,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - tools_web_search_request: false, + web_search_mode: WebSearchMode::default(), use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3600,7 +3668,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - tools_web_search_request: false, + web_search_mode: WebSearchMode::default(), use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3702,7 +3770,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - tools_web_search_request: false, + web_search_mode: WebSearchMode::default(), use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3790,7 +3858,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - tools_web_search_request: false, + web_search_mode: WebSearchMode::default(), use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 54f9a08ad..d8630f15d 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -7,6 +7,7 @@ use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; /// Collection of common configuration options that a user can define as a unit @@ -31,6 +32,7 @@ pub struct ConfigProfile { pub experimental_use_freeform_apply_patch: Option, pub tools_web_search: Option, pub tools_view_image: Option, + pub web_search: Option, pub analytics: Option, /// Optional feature toggles scoped to this profile. #[serde(default)] diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index c57e550a7..13b201e84 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -4,6 +4,7 @@ // definitions that do not contain business logic. pub use codex_protocol::config_types::AltScreenMode; +pub use codex_protocol::config_types::WebSearchMode; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeMap; use std::collections::HashMap; diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 905558712..353189a6b 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -85,9 +86,7 @@ async fn start_review_conversation( let mut sub_agent_config = config.as_ref().clone(); // Carry over review-only feature restrictions so the delegate cannot // re-enable blocked tools (web search, view image). - sub_agent_config - .features - .disable(crate::features::Feature::WebSearchRequest); + sub_agent_config.web_search_mode = WebSearchMode::Disabled; // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index af577c1a5..28fc3a170 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS; use crate::tools::registry::ToolRegistryBuilder; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; @@ -23,8 +24,7 @@ use std::collections::HashMap; pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, pub apply_patch_tool_type: Option, - pub web_search_request: bool, - pub web_search_cached: bool, + pub web_search_mode: WebSearchMode, pub collab_tools: bool, pub experimental_supported_tools: Vec, } @@ -32,6 +32,7 @@ pub(crate) struct ToolsConfig { pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_info: &'a ModelInfo, pub(crate) features: &'a Features, + pub(crate) web_search_mode: WebSearchMode, } impl ToolsConfig { @@ -39,10 +40,9 @@ impl ToolsConfig { let ToolsConfigParams { model_info, features, + web_search_mode, } = params; let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); - let include_web_search_request = features.enabled(Feature::WebSearchRequest); - let include_web_search_cached = features.enabled(Feature::WebSearchCached); let include_collab_tools = features.enabled(Feature::Collab); let shell_type = if !features.enabled(Feature::ShellTool) { @@ -73,8 +73,7 @@ impl ToolsConfig { Self { shell_type, apply_patch_tool_type, - web_search_request: include_web_search_request, - web_search_cached: include_web_search_cached, + web_search_mode: *web_search_mode, collab_tools: include_collab_tools, experimental_supported_tools: model_info.experimental_supported_tools.clone(), } @@ -1225,15 +1224,18 @@ pub(crate) fn build_specs( builder.register_handler("test_sync_tool", test_sync_handler); } - // Prefer web_search_cached flag over web_search_request - if config.web_search_cached { - builder.push_spec(ToolSpec::WebSearch { - external_web_access: Some(false), - }); - } else if config.web_search_request { - builder.push_spec(ToolSpec::WebSearch { - external_web_access: Some(true), - }); + match config.web_search_mode { + WebSearchMode::Disabled => {} + WebSearchMode::Cached => { + builder.push_spec(ToolSpec::WebSearch { + external_web_access: Some(false), + }); + } + WebSearchMode::Live => { + builder.push_spec(ToolSpec::WebSearch { + external_web_access: Some(true), + }); + } } builder.push_spec_with_parallel_support(create_view_image_tool(), true); @@ -1374,10 +1376,10 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs(&config, None).build(); @@ -1439,6 +1441,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None).build(); assert_contains_tool_names( @@ -1447,12 +1450,18 @@ mod tests { ); } - fn assert_model_tools(model_slug: &str, features: &Features, expected_tools: &[&str]) { + fn assert_model_tools( + model_slug: &str, + features: &Features, + web_search_mode: WebSearchMode, + expected_tools: &[&str], + ) { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline(model_slug, &config); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features, + web_search_mode, }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build(); let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); @@ -1460,15 +1469,15 @@ mod tests { } #[test] - fn web_search_cached_sets_external_web_access_false() { + fn web_search_mode_cached_sets_external_web_access_false() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::WebSearchCached); + let features = Features::with_defaults(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1482,16 +1491,15 @@ mod tests { } #[test] - fn web_search_cached_takes_precedence_over_web_search_request() { + fn web_search_mode_live_sets_external_web_access_true() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::WebSearchCached); - features.enable(Feature::WebSearchRequest); + let features = Features::with_defaults(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1499,7 +1507,7 @@ mod tests { assert_eq!( tool.spec, ToolSpec::WebSearch { - external_web_access: Some(false), + external_web_access: Some(true), } ); } @@ -1509,6 +1517,7 @@ mod tests { assert_model_tools( "gpt-5-codex", &Features::with_defaults(), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1516,6 +1525,7 @@ mod tests { "read_mcp_resource", "update_plan", "apply_patch", + "web_search", "view_image", ], ); @@ -1526,6 +1536,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex", &Features::with_defaults(), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1533,6 +1544,7 @@ mod tests { "read_mcp_resource", "update_plan", "apply_patch", + "web_search", "view_image", ], ); @@ -1542,9 +1554,8 @@ mod tests { fn test_build_specs_gpt5_codex_unified_exec_web_search() { assert_model_tools( "gpt-5-codex", - Features::with_defaults() - .enable(Feature::UnifiedExec) - .enable(Feature::WebSearchRequest), + Features::with_defaults().enable(Feature::UnifiedExec), + WebSearchMode::Live, &[ "exec_command", "write_stdin", @@ -1563,9 +1574,8 @@ mod tests { fn test_build_specs_gpt51_codex_unified_exec_web_search() { assert_model_tools( "gpt-5.1-codex", - Features::with_defaults() - .enable(Feature::UnifiedExec) - .enable(Feature::WebSearchRequest), + Features::with_defaults().enable(Feature::UnifiedExec), + WebSearchMode::Live, &[ "exec_command", "write_stdin", @@ -1585,12 +1595,14 @@ mod tests { assert_model_tools( "codex-mini-latest", &Features::with_defaults(), + WebSearchMode::Cached, &[ "local_shell", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "web_search", "view_image", ], ); @@ -1601,6 +1613,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex-mini", &Features::with_defaults(), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1608,6 +1621,7 @@ mod tests { "read_mcp_resource", "update_plan", "apply_patch", + "web_search", "view_image", ], ); @@ -1618,12 +1632,14 @@ mod tests { assert_model_tools( "gpt-5", &Features::with_defaults(), + WebSearchMode::Cached, &[ "shell", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "web_search", "view_image", ], ); @@ -1634,6 +1650,7 @@ mod tests { assert_model_tools( "gpt-5.1", &Features::with_defaults(), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1641,6 +1658,7 @@ mod tests { "read_mcp_resource", "update_plan", "apply_patch", + "web_search", "view_image", ], ); @@ -1651,6 +1669,7 @@ mod tests { assert_model_tools( "exp-5.1", &Features::with_defaults(), + WebSearchMode::Cached, &[ "exec_command", "write_stdin", @@ -1659,6 +1678,7 @@ mod tests { "read_mcp_resource", "update_plan", "apply_patch", + "web_search", "view_image", ], ); @@ -1668,9 +1688,8 @@ mod tests { fn test_codex_mini_unified_exec_web_search() { assert_model_tools( "codex-mini-latest", - Features::with_defaults() - .enable(Feature::UnifiedExec) - .enable(Feature::WebSearchRequest), + Features::with_defaults().enable(Feature::UnifiedExec), + WebSearchMode::Live, &[ "exec_command", "write_stdin", @@ -1689,11 +1708,11 @@ mod tests { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline("o3", &config); let mut features = Features::with_defaults(); - features.enable(Feature::WebSearchRequest); features.enable(Feature::UnifiedExec); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build(); @@ -1715,6 +1734,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1733,6 +1753,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1760,10 +1781,10 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("o3", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::WebSearchRequest); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs( &tools_config, @@ -1858,6 +1879,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); // Intentionally construct a map with keys that would sort alphabetically. @@ -1931,10 +1953,10 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::WebSearchRequest); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -1988,10 +2010,10 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::WebSearchRequest); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -2041,11 +2063,11 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::WebSearchRequest); features.enable(Feature::ApplyPatchFreeform); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -2098,10 +2120,10 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::WebSearchRequest); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -2210,10 +2232,10 @@ Examples of valid command strings: let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::WebSearchRequest); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( &tools_config, diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 106bbd85c..4eaf3735a 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_protocol::config_types::WebSearchMode; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; use core_test_support::responses::start_mock_server; @@ -32,7 +33,10 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { let sse = sse_completed(model); let resp_mock = responses::mount_sse_once(&server, sse).await; - let mut builder = test_codex().with_model(model); + let mut builder = test_codex() + .with_model(model) + // Keep tool expectations stable when the default web_search mode changes. + .with_config(|config| config.web_search_mode = WebSearchMode::Cached); let test = builder .build(&server) .await @@ -58,6 +62,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "web_search".to_string(), "view_image".to_string() ], "codex-mini-latest should expose the local shell tool", @@ -73,6 +78,7 @@ async fn model_selects_expected_tools() { "read_mcp_resource".to_string(), "update_plan".to_string(), "apply_patch".to_string(), + "web_search".to_string(), "view_image".to_string() ], "gpt-5-codex should expose the apply_patch tool", @@ -88,6 +94,7 @@ async fn model_selects_expected_tools() { "read_mcp_resource".to_string(), "update_plan".to_string(), "apply_patch".to_string(), + "web_search".to_string(), "view_image".to_string() ], "gpt-5.1-codex should expose the apply_patch tool", @@ -102,6 +109,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "web_search".to_string(), "view_image".to_string() ], "gpt-5 should expose the apply_patch tool", @@ -117,6 +125,7 @@ async fn model_selects_expected_tools() { "read_mcp_resource".to_string(), "update_plan".to_string(), "apply_patch".to_string(), + "web_search".to_string(), "view_image".to_string() ], "gpt-5.1 should expose the apply_patch tool", @@ -132,6 +141,7 @@ async fn model_selects_expected_tools() { "read_mcp_resource".to_string(), "update_plan".to_string(), "apply_patch".to_string(), + "web_search".to_string(), "view_image".to_string() ], "exp-5.1 should expose the apply_patch tool", diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 3e60a9b56..303e958b3 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -11,6 +11,7 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -52,7 +53,13 @@ fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) { .as_array() .unwrap() .iter() - .map(|t| t["name"].as_str().unwrap().to_string()) + .map(|t| { + t.get("name") + .and_then(|value| value.as_str()) + .or_else(|| t.get("type").and_then(|value| value.as_str())) + .unwrap() + .to_string() + }) .collect::>(), expected_names ); @@ -80,6 +87,8 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.model = Some("gpt-5.1-codex-max".to_string()); + // Keep tool expectations stable when the default web_search mode changes. + config.web_search_mode = WebSearchMode::Cached; }) .build(&server) .await?; @@ -122,6 +131,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { "read_mcp_resource", "update_plan", "apply_patch", + "web_search", "view_image", ]; let body0 = req1.single_request().body_json(); diff --git a/codex-rs/core/tests/suite/web_search_cached.rs b/codex-rs/core/tests/suite/web_search_cached.rs index b6900a4c2..261efaf94 100644 --- a/codex-rs/core/tests/suite/web_search_cached.rs +++ b/codex-rs/core/tests/suite/web_search_cached.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use codex_core::features::Feature; +use codex_protocol::config_types::WebSearchMode; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; use core_test_support::responses::start_mock_server; @@ -24,7 +25,7 @@ fn find_web_search_tool(body: &Value) -> &Value { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn web_search_cached_sets_external_web_access_false_in_request_body() { +async fn web_search_mode_cached_sets_external_web_access_false_in_request_body() { skip_if_no_network!(); let server = start_mock_server().await; @@ -34,7 +35,7 @@ async fn web_search_cached_sets_external_web_access_false_in_request_body() { let mut builder = test_codex() .with_model("gpt-5-codex") .with_config(|config| { - config.features.enable(Feature::WebSearchCached); + config.web_search_mode = WebSearchMode::Cached; }); let test = builder .build(&server) @@ -50,12 +51,12 @@ async fn web_search_cached_sets_external_web_access_false_in_request_body() { assert_eq!( tool.get("external_web_access").and_then(Value::as_bool), Some(false), - "web_search_cached should force external_web_access=false" + "web_search cached mode should force external_web_access=false" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn web_search_cached_takes_precedence_over_web_search_request_in_request_body() { +async fn web_search_mode_takes_precedence_over_legacy_flags_in_request_body() { skip_if_no_network!(); let server = start_mock_server().await; @@ -66,7 +67,7 @@ async fn web_search_cached_takes_precedence_over_web_search_request_in_request_b .with_model("gpt-5-codex") .with_config(|config| { config.features.enable(Feature::WebSearchRequest); - config.features.enable(Feature::WebSearchCached); + config.web_search_mode = WebSearchMode::Cached; }); let test = builder .build(&server) @@ -82,6 +83,6 @@ async fn web_search_cached_takes_precedence_over_web_search_request_in_request_b assert_eq!( tool.get("external_web_access").and_then(Value::as_bool), Some(false), - "web_search_cached should win over web_search_request" + "web_search mode should win over legacy web_search_request" ); } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index b6e4a1e34..3979bc381 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -63,6 +63,18 @@ pub enum SandboxMode { DangerFullAccess, } +#[derive( + Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum WebSearchMode { + #[default] + Disabled, + Cached, + Live, +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 9f97ab3c8..8308f1c2a 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -94,7 +94,7 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, - /// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). + /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). #[arg(long = "search", default_value_t = false)] pub web_search: bool, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 519ceeb12..4bae970ee 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -123,11 +123,11 @@ pub async fn run_main( ) }; - // Map the legacy --search flag to the new feature toggle. + // Map the legacy --search flag to the canonical web_search mode. if cli.web_search { cli.config_overrides .raw_overrides - .push("features.web_search_request=true".to_string()); + .push("web_search=\"live\"".to_string()); } // When using `--oss`, let the bootstrapper pick the model (defaulting to diff --git a/codex-rs/tui2/src/cli.rs b/codex-rs/tui2/src/cli.rs index 69014d45d..77903c704 100644 --- a/codex-rs/tui2/src/cli.rs +++ b/codex-rs/tui2/src/cli.rs @@ -94,7 +94,7 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, - /// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). + /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). #[arg(long = "search", default_value_t = false)] pub web_search: bool, diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index f7ae3aac5..ddb38cf29 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -138,7 +138,7 @@ pub async fn run_main( if cli.web_search { cli.config_overrides .raw_overrides - .push("features.web_search_request=true".to_string()); + .push("web_search=\"live\"".to_string()); } // When using `--oss`, let the bootstrapper pick the model (defaulting to diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 582fc0bc9..c8e957e1f 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -3,7 +3,12 @@ import path from "node:path"; import readline from "node:readline"; import { fileURLToPath } from "node:url"; -import { SandboxMode, ModelReasoningEffort, ApprovalMode } from "./threadOptions"; +import { + SandboxMode, + ModelReasoningEffort, + ApprovalMode, + WebSearchMode, +} from "./threadOptions"; export type CodexExecArgs = { input: string; @@ -30,7 +35,9 @@ export type CodexExecArgs = { signal?: AbortSignal; // --config sandbox_workspace_write.network_access networkAccessEnabled?: boolean; - // --config features.web_search_request + // --config web_search + webSearchMode?: WebSearchMode; + // legacy --config features.web_search_request webSearchEnabled?: boolean; // --config approval_policy approvalPolicy?: ApprovalMode; @@ -88,8 +95,12 @@ export class CodexExec { ); } - if (args.webSearchEnabled !== undefined) { - commandArgs.push("--config", `features.web_search_request=${args.webSearchEnabled}`); + if (args.webSearchMode) { + commandArgs.push("--config", `web_search="${args.webSearchMode}"`); + } else if (args.webSearchEnabled === true) { + commandArgs.push("--config", `web_search="live"`); + } else if (args.webSearchEnabled === false) { + commandArgs.push("--config", `web_search="disabled"`); } if (args.approvalPolicy) { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index cfd0dc431..a74f391a7 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -35,5 +35,6 @@ export type { ApprovalMode, SandboxMode, ModelReasoningEffort, + WebSearchMode, } from "./threadOptions"; export type { TurnOptions } from "./turnOptions"; diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 6aed29c84..c36099348 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -88,6 +88,7 @@ export class Thread { modelReasoningEffort: options?.modelReasoningEffort, signal: turnOptions.signal, networkAccessEnabled: options?.networkAccessEnabled, + webSearchMode: options?.webSearchMode, webSearchEnabled: options?.webSearchEnabled, approvalPolicy: options?.approvalPolicy, additionalDirectories: options?.additionalDirectories, diff --git a/sdk/typescript/src/threadOptions.ts b/sdk/typescript/src/threadOptions.ts index 6fdf45447..7bde5e4d7 100644 --- a/sdk/typescript/src/threadOptions.ts +++ b/sdk/typescript/src/threadOptions.ts @@ -4,6 +4,8 @@ export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access" export type ModelReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh"; +export type WebSearchMode = "disabled" | "cached" | "live"; + export type ThreadOptions = { model?: string; sandboxMode?: SandboxMode; @@ -11,6 +13,7 @@ export type ThreadOptions = { skipGitRepoCheck?: boolean; modelReasoningEffort?: ModelReasoningEffort; networkAccessEnabled?: boolean; + webSearchMode?: WebSearchMode; webSearchEnabled?: boolean; approvalPolicy?: ApprovalMode; additionalDirectories?: string[]; diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index fcd9fea83..993b2304d 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -310,7 +310,69 @@ describe("Codex", () => { const commandArgs = spawnArgs[0]; expect(commandArgs).toBeDefined(); - expectPair(commandArgs, ["--config", "features.web_search_request=true"]); + expectPair(commandArgs, ["--config", 'web_search="live"']); + } finally { + restore(); + await close(); + } + }); + + it("passes webSearchMode to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Web search cached", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread({ + webSearchMode: "cached", + }); + await thread.run("test web search mode"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--config", 'web_search="cached"']); + } finally { + restore(); + await close(); + } + }); + + it("passes webSearchEnabled false to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Web search disabled", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread({ + webSearchEnabled: false, + }); + await thread.run("test web search disabled"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--config", 'web_search="disabled"']); } finally { restore(); await close();