diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 0c2a2e585..62a8f55d1 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -36,6 +36,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3d9133700..cc471ae89 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -84,6 +84,7 @@ async fn models_client_hits_models_endpoint() { default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 50facf53e..d775c9a37 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -176,6 +176,8 @@ pub(crate) mod tools { WebSearch { #[serde(skip_serializing_if = "Option::is_none")] external_web_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] + search_content_types: Option>, }, #[serde(rename = "custom")] Freeform(FreeformTool), diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 643d42571..3664a5266 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::TruncationMode; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::openai_models::default_input_modalities; use crate::config::Config; @@ -78,6 +79,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo { support_verbosity: false, default_verbosity: None, apply_patch_tool_type: None, + web_search_tool_type: WebSearchToolType::Text, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index dcdb48500..00867b20d 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -25,6 +25,7 @@ use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use serde::Deserialize; @@ -36,6 +37,7 @@ use std::collections::HashMap; const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); +const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"]; #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ShellCommandBackendConfig { Classic, @@ -49,6 +51,7 @@ pub(crate) struct ToolsConfig { pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, + pub web_search_tool_type: WebSearchToolType, pub image_gen_tool: bool, pub agent_roles: BTreeMap, pub search_tool: bool, @@ -140,6 +143,7 @@ impl ToolsConfig { allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, + web_search_tool_type: model_info.web_search_tool_type, image_gen_tool: include_image_gen_tool, agent_roles: BTreeMap::new(), search_tool: include_search_tool, @@ -1877,18 +1881,27 @@ pub(crate) fn build_specs( builder.register_handler("test_sync_tool", test_sync_handler); } - match config.web_search_mode { - Some(WebSearchMode::Cached) => { - builder.push_spec(ToolSpec::WebSearch { - external_web_access: Some(false), - }); - } - Some(WebSearchMode::Live) => { - builder.push_spec(ToolSpec::WebSearch { - external_web_access: Some(true), - }); - } - Some(WebSearchMode::Disabled) | None => {} + let external_web_access = match config.web_search_mode { + Some(WebSearchMode::Cached) => Some(false), + Some(WebSearchMode::Live) => Some(true), + Some(WebSearchMode::Disabled) | None => None, + }; + + if let Some(external_web_access) = external_web_access { + let search_content_types = match config.web_search_tool_type { + WebSearchToolType::Text => None, + WebSearchToolType::TextAndImage => Some( + WEB_SEARCH_CONTENT_TYPES + .into_iter() + .map(str::to_string) + .collect(), + ), + }; + + builder.push_spec(ToolSpec::WebSearch { + external_web_access: Some(external_web_access), + search_content_types, + }); } if config.image_gen_tool { @@ -2172,6 +2185,7 @@ mod tests { create_apply_patch_freeform_tool(), ToolSpec::WebSearch { external_web_access: Some(true), + search_content_types: None, }, create_view_image_tool(), ] { @@ -2486,6 +2500,7 @@ mod tests { tool.spec, ToolSpec::WebSearch { external_web_access: Some(false), + search_content_types: None, } ); } @@ -2510,6 +2525,38 @@ mod tests { tool.spec, ToolSpec::WebSearch { external_web_access: Some(true), + search_content_types: None, + } + ); + } + + #[test] + fn web_search_tool_type_text_and_image_sets_search_content_types() { + let config = test_config(); + let mut model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + model_info.web_search_tool_type = WebSearchToolType::TextAndImage; + let features = Features::with_defaults(); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(true), + search_content_types: Some( + WEB_SEARCH_CONTENT_TYPES + .into_iter() + .map(str::to_string) + .collect() + ), } ); } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index a93d0f751..937de8d53 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -65,6 +65,7 @@ fn test_model_info( default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, @@ -684,6 +685,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 54b062924..e8f9cbf7f 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -342,6 +342,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 548c09ba4..754c46ebf 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -647,6 +647,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, @@ -761,6 +762,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index ac2bc7156..4610bec09 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -301,6 +301,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, @@ -542,6 +543,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, @@ -1007,6 +1009,7 @@ fn test_remote_model_with_policy( default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy, supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index a8d414e99..4139a582a 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -407,6 +407,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 3e652577b..3a3c89ac7 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1003,6 +1003,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an default_verbosity: None, availability_nux: None, apply_patch_tool_type: None, + web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 5968ae699..3d668c447 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -181,6 +181,16 @@ pub enum ApplyPatchToolType { Function, } +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema, Default, +)] +#[serde(rename_all = "snake_case")] +pub enum WebSearchToolType { + #[default] + Text, + TextAndImage, +} + /// Server-provided truncation policy metadata for a model. #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -243,6 +253,8 @@ pub struct ModelInfo { pub support_verbosity: bool, pub default_verbosity: Option, pub apply_patch_tool_type: Option, + #[serde(default)] + pub web_search_tool_type: WebSearchToolType, pub truncation_policy: TruncationPolicyConfig, pub supports_parallel_tool_calls: bool, #[serde(default)] @@ -515,6 +527,7 @@ mod tests { support_verbosity: false, default_verbosity: None, apply_patch_tool_type: None, + web_search_tool_type: WebSearchToolType::Text, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, @@ -718,6 +731,7 @@ mod tests { assert_eq!(model.availability_nux, None); assert!(!model.supports_image_detail_original); + assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); } #[test]