diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 438c7c5eeee..326d81e0e91 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -31,6 +31,7 @@ use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -64,6 +65,8 @@ use crate::model_provider_info::WireApi; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; +pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible"; + #[derive(Debug)] struct ModelClientState { config: Arc, @@ -319,7 +322,7 @@ impl ModelClientSession { store_override: None, conversation_id: Some(conversation_id), session_source: Some(self.state.session_source.clone()), - extra_headers: beta_feature_headers(&self.state.config), + extra_headers: build_responses_headers(&self.state.config), compression, } } @@ -635,6 +638,21 @@ fn beta_feature_headers(config: &Config) -> ApiHeaderMap { headers } +fn build_responses_headers(config: &Config) -> ApiHeaderMap { + let mut headers = beta_feature_headers(config); + headers.insert( + WEB_SEARCH_ELIGIBLE_HEADER, + HeaderValue::from_static( + if matches!(config.web_search_mode, Some(WebSearchMode::Disabled)) { + "false" + } else { + "true" + }, + ), + ); + headers +} + fn map_response_stream(api_stream: S, otel_manager: OtelManager) -> ResponseStream where S: futures::Stream> diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 158443ee49f..e54e997d168 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2416,7 +2416,7 @@ async fn spawn_review_thread( let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &review_model_info, features: &review_features, - web_search_mode: review_web_search_mode, + web_search_mode: Some(review_web_search_mode), }); let base_instructions = REVIEW_PROMPT.to_string(); @@ -2429,7 +2429,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; + per_turn_config.web_search_mode = Some(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 5e5d26c791a..034783da15b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -337,7 +337,8 @@ pub struct Config { /// model info's default preference. pub include_apply_patch_tool: bool, - pub web_search_mode: WebSearchMode, + /// Explicit or feature-derived web search mode. + pub web_search_mode: Option, /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1182,24 +1183,22 @@ pub fn resolve_oss_provider( } } -/// Resolve the web search mode from the config, profile, and features. +/// Resolve the web search mode from explicit config and feature flags. fn resolve_web_search_mode( config_toml: &ConfigToml, config_profile: &ConfigProfile, features: &Features, -) -> WebSearchMode { - // Enum gets precedence over features flags +) -> Option { if let Some(mode) = config_profile.web_search.or(config_toml.web_search) { - return mode; + return Some(mode); } if features.enabled(Feature::WebSearchCached) { - return WebSearchMode::Cached; + return Some(WebSearchMode::Cached); } if features.enabled(Feature::WebSearchRequest) { - return WebSearchMode::Live; + return Some(WebSearchMode::Live); } - // Fall back to default - WebSearchMode::default() + None } impl Config { @@ -2202,15 +2201,12 @@ trust_level = "trusted" } #[test] - fn web_search_mode_uses_default_if_unset() { + fn web_search_mode_uses_none_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() - ); + assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None); } #[test] @@ -2225,7 +2221,7 @@ trust_level = "trusted" assert_eq!( resolve_web_search_mode(&cfg, &profile, &features), - WebSearchMode::Live + Some(WebSearchMode::Live) ); } @@ -2241,7 +2237,7 @@ trust_level = "trusted" assert_eq!( resolve_web_search_mode(&cfg, &profile, &features), - WebSearchMode::Disabled + Some(WebSearchMode::Disabled) ); } @@ -3581,7 +3577,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: WebSearchMode::default(), + web_search_mode: None, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3668,7 +3664,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: WebSearchMode::default(), + web_search_mode: None, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3770,7 +3766,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: WebSearchMode::default(), + web_search_mode: None, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3858,7 +3854,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: WebSearchMode::default(), + web_search_mode: None, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4c6183454d9..498c45748fd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -111,6 +111,7 @@ mod user_shell_command; pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; +pub use client::WEB_SEARCH_ELIGIBLE_HEADER; pub use command_safety::is_dangerous_command; pub use command_safety::is_safe_command; pub use exec_policy::ExecPolicyError; diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 353189a6be9..9157e922ecf 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -86,7 +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.web_search_mode = WebSearchMode::Disabled; + sub_agent_config.web_search_mode = Some(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 28fc3a17017..345f6b6ebef 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -24,7 +24,7 @@ use std::collections::HashMap; pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, pub apply_patch_tool_type: Option, - pub web_search_mode: WebSearchMode, + pub web_search_mode: Option, pub collab_tools: bool, pub experimental_supported_tools: Vec, } @@ -32,7 +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, + pub(crate) web_search_mode: Option, } impl ToolsConfig { @@ -1225,17 +1225,17 @@ pub(crate) fn build_specs( } match config.web_search_mode { - WebSearchMode::Disabled => {} - WebSearchMode::Cached => { + Some(WebSearchMode::Cached) => { builder.push_spec(ToolSpec::WebSearch { external_web_access: Some(false), }); } - WebSearchMode::Live => { + Some(WebSearchMode::Live) => { builder.push_spec(ToolSpec::WebSearch { external_web_access: Some(true), }); } + Some(WebSearchMode::Disabled) | None => {} } builder.push_spec_with_parallel_support(create_view_image_tool(), true); @@ -1379,7 +1379,7 @@ mod tests { let config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Live, + web_search_mode: Some(WebSearchMode::Live), }); let (tools, _) = build_specs(&config, None).build(); @@ -1441,7 +1441,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs(&tools_config, None).build(); assert_contains_tool_names( @@ -1453,7 +1453,7 @@ mod tests { fn assert_model_tools( model_slug: &str, features: &Features, - web_search_mode: WebSearchMode, + web_search_mode: Option, expected_tools: &[&str], ) { let config = test_config(); @@ -1477,7 +1477,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1499,7 +1499,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Live, + web_search_mode: Some(WebSearchMode::Live), }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1517,7 +1517,7 @@ mod tests { assert_model_tools( "gpt-5-codex", &Features::with_defaults(), - WebSearchMode::Cached, + Some(WebSearchMode::Cached), &[ "shell_command", "list_mcp_resources", @@ -1536,7 +1536,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex", &Features::with_defaults(), - WebSearchMode::Cached, + Some(WebSearchMode::Cached), &[ "shell_command", "list_mcp_resources", @@ -1555,7 +1555,7 @@ mod tests { assert_model_tools( "gpt-5-codex", Features::with_defaults().enable(Feature::UnifiedExec), - WebSearchMode::Live, + Some(WebSearchMode::Live), &[ "exec_command", "write_stdin", @@ -1575,7 +1575,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex", Features::with_defaults().enable(Feature::UnifiedExec), - WebSearchMode::Live, + Some(WebSearchMode::Live), &[ "exec_command", "write_stdin", @@ -1595,7 +1595,7 @@ mod tests { assert_model_tools( "codex-mini-latest", &Features::with_defaults(), - WebSearchMode::Cached, + Some(WebSearchMode::Cached), &[ "local_shell", "list_mcp_resources", @@ -1613,7 +1613,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex-mini", &Features::with_defaults(), - WebSearchMode::Cached, + Some(WebSearchMode::Cached), &[ "shell_command", "list_mcp_resources", @@ -1632,7 +1632,7 @@ mod tests { assert_model_tools( "gpt-5", &Features::with_defaults(), - WebSearchMode::Cached, + Some(WebSearchMode::Cached), &[ "shell", "list_mcp_resources", @@ -1650,7 +1650,7 @@ mod tests { assert_model_tools( "gpt-5.1", &Features::with_defaults(), - WebSearchMode::Cached, + Some(WebSearchMode::Cached), &[ "shell_command", "list_mcp_resources", @@ -1669,7 +1669,7 @@ mod tests { assert_model_tools( "exp-5.1", &Features::with_defaults(), - WebSearchMode::Cached, + Some(WebSearchMode::Cached), &[ "exec_command", "write_stdin", @@ -1689,7 +1689,7 @@ mod tests { assert_model_tools( "codex-mini-latest", Features::with_defaults().enable(Feature::UnifiedExec), - WebSearchMode::Live, + Some(WebSearchMode::Live), &[ "exec_command", "write_stdin", @@ -1712,7 +1712,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Live, + web_search_mode: Some(WebSearchMode::Live), }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build(); @@ -1734,7 +1734,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1753,7 +1753,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs(&tools_config, None).build(); @@ -1784,7 +1784,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Live, + web_search_mode: Some(WebSearchMode::Live), }); let (tools, _) = build_specs( &tools_config, @@ -1879,7 +1879,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); // Intentionally construct a map with keys that would sort alphabetically. @@ -1956,7 +1956,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs( @@ -2013,7 +2013,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs( @@ -2067,7 +2067,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs( @@ -2123,7 +2123,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs( @@ -2235,7 +2235,7 @@ Examples of valid command strings: let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: WebSearchMode::Cached, + web_search_mode: Some(WebSearchMode::Cached), }); let (tools, _) = build_specs( &tools_config, diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 8be6e363405..8eba3eabe71 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -9,15 +9,18 @@ use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; +use codex_core::WEB_SEARCH_ELIGIBLE_HEADER; use codex_core::WireApi; use codex_core::models_manager::manager::ModelsManager; use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use core_test_support::load_default_config_for_test; use core_test_support::responses; +use core_test_support::test_codex::test_codex; use futures::StreamExt; use tempfile::TempDir; use wiremock::matchers::header; @@ -213,6 +216,66 @@ async fn responses_stream_includes_subagent_header_on_other() { ); } +#[tokio::test] +async fn responses_stream_includes_web_search_eligible_header_true_by_default() { + core_test_support::skip_if_no_network!(); + + let server = responses::start_mock_server().await; + let response_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + + let request_recorder = responses::mount_sse_once_match( + &server, + header(WEB_SEARCH_ELIGIBLE_HEADER, "true"), + response_body, + ) + .await; + + let test = test_codex().build(&server).await.expect("build test codex"); + test.submit_turn("hello").await.expect("submit test prompt"); + + let request = request_recorder.single_request(); + assert_eq!( + request.header(WEB_SEARCH_ELIGIBLE_HEADER).as_deref(), + Some("true") + ); +} + +#[tokio::test] +async fn responses_stream_includes_web_search_eligible_header_false_when_disabled() { + core_test_support::skip_if_no_network!(); + + let server = responses::start_mock_server().await; + let response_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + + let request_recorder = responses::mount_sse_once_match( + &server, + header(WEB_SEARCH_ELIGIBLE_HEADER, "false"), + response_body, + ) + .await; + + let test = test_codex() + .with_config(|config| { + config.web_search_mode = Some(WebSearchMode::Disabled); + }) + .build(&server) + .await + .expect("build test codex"); + test.submit_turn("hello").await.expect("submit test prompt"); + + let request = request_recorder.single_request(); + assert_eq!( + request.header(WEB_SEARCH_ELIGIBLE_HEADER).as_deref(), + Some("false") + ); +} + #[tokio::test] async fn responses_respects_model_info_overrides_from_config() { core_test_support::skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 18d3caf3bfc..6f659e1b649 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -14,6 +14,7 @@ use codex_core::ThreadManager; use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; +use codex_core::default_client::originator; use codex_core::error::CodexErr; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::EventMsg; @@ -406,7 +407,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { let request_originator = request.header("originator").expect("originator header"); assert_eq!(request_session_id, session_id.to_string()); - assert_eq!(request_originator, "codex_cli_rs"); + assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Test API Key"); } @@ -522,7 +523,7 @@ async fn chatgpt_auth_sends_correct_request() { let session_id = request.header("session_id").expect("session_id header"); assert_eq!(session_id, thread_id.to_string()); - assert_eq!(request_originator, "codex_cli_rs"); + assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Access Token"); assert_eq!(request_chatgpt_account_id, "account_id"); assert!(request_body["stream"].as_bool().unwrap()); diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 4eaf3735a37..8a4d0a77176 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -36,7 +36,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { 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); + .with_config(|config| config.web_search_mode = Some(WebSearchMode::Cached)); let test = builder .build(&server) .await diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b3ed78f69db..6ea5619bfe5 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -88,7 +88,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { 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; + config.web_search_mode = Some(WebSearchMode::Cached); }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/web_search_cached.rs b/codex-rs/core/tests/suite/web_search_cached.rs index 261efaf942b..1a69d8b7370 100644 --- a/codex-rs/core/tests/suite/web_search_cached.rs +++ b/codex-rs/core/tests/suite/web_search_cached.rs @@ -35,7 +35,7 @@ async fn web_search_mode_cached_sets_external_web_access_false_in_request_body() let mut builder = test_codex() .with_model("gpt-5-codex") .with_config(|config| { - config.web_search_mode = WebSearchMode::Cached; + config.web_search_mode = Some(WebSearchMode::Cached); }); let test = builder .build(&server) @@ -67,7 +67,7 @@ async fn web_search_mode_takes_precedence_over_legacy_flags_in_request_body() { .with_model("gpt-5-codex") .with_config(|config| { config.features.enable(Feature::WebSearchRequest); - config.web_search_mode = WebSearchMode::Cached; + config.web_search_mode = Some(WebSearchMode::Cached); }); let test = builder .build(&server)