From 54ded1a3c0cfddc38b5f1ff494b3bce1727d927b Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 6 Jan 2026 14:53:59 -0800 Subject: [PATCH] add web_search_cached flag (#8795) Add `web_search_cached` feature to config. Enables `web_search` tool with access only to cached/indexed results (see [docs](https://platform.openai.com/docs/guides/tools-web-search#live-internet-access)). This takes precedence over the existing `web_search_request`, which continues to enable `web_search` over live results as it did before. `web_search_cached` is disabled for review mode, as `web_search_request` is. --- codex-rs/core/src/client_common.rs | 9 +- codex-rs/core/src/codex.rs | 1 + codex-rs/core/src/features.rs | 11 ++- codex-rs/core/src/tools/spec.rs | 67 ++++++++++++-- codex-rs/core/tests/suite/mod.rs | 1 + .../core/tests/suite/web_search_cached.rs | 87 +++++++++++++++++++ 6 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 codex-rs/core/tests/suite/web_search_cached.rs diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 913bb2232..6972ff7ce 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -195,8 +195,13 @@ pub(crate) mod tools { LocalShell {}, // 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 {}, + WebSearch { + #[serde(skip_serializing_if = "Option::is_none")] + external_web_access: Option, + }, #[serde(rename = "custom")] Freeform(FreeformTool), } @@ -206,7 +211,7 @@ pub(crate) mod tools { match self { ToolSpec::Function(tool) => tool.name.as_str(), ToolSpec::LocalShell {} => "local_shell", - ToolSpec::WebSearch {} => "web_search", + ToolSpec::WebSearch { .. } => "web_search", ToolSpec::Freeform(tool) => tool.name.as_str(), } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 23feace81..aeffec749 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2212,6 +2212,7 @@ async fn spawn_review_thread( let mut review_features = sess.features.clone(); review_features .disable(crate::features::Feature::WebSearchRequest) + .disable(crate::features::Feature::WebSearchCached) .disable(crate::features::Feature::ViewImageTool); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &review_model_family, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 3b22bfc3f..99b0522a1 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -72,8 +72,11 @@ pub enum Feature { UnifiedExec, /// Include the freeform apply_patch tool. ApplyPatchFreeform, - /// Allow the model to request web searches. + /// Allow the model to request web searches that fetch live content. WebSearchRequest, + /// Allow the model to request web searches that fetch cached content. + /// Takes precedence over `WebSearchRequest`. + WebSearchCached, /// Gate the execpolicy enforcement for shell/unified exec. ExecPolicy, /// Enable Windows sandbox (restricted token) on Windows. @@ -330,6 +333,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: false, }, + FeatureSpec { + id: Feature::WebSearchCached, + key: "web_search_cached", + stage: Stage::Experimental, + default_enabled: false, + }, // Beta program. Rendered in the `/experimental` menu for users. FeatureSpec { id: Feature::UnifiedExec, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 0ac91755c..75756831b 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -21,6 +21,7 @@ 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 include_view_image_tool: bool, pub experimental_supported_tools: Vec, } @@ -38,6 +39,7 @@ impl ToolsConfig { } = 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_view_image_tool = features.enabled(Feature::ViewImageTool); let shell_type = if !features.enabled(Feature::ShellTool) { @@ -69,6 +71,7 @@ impl ToolsConfig { shell_type, apply_patch_tool_type, web_search_request: include_web_search_request, + web_search_cached: include_web_search_cached, include_view_image_tool, experimental_supported_tools: model_family.experimental_supported_tools.clone(), } @@ -1093,8 +1096,15 @@ pub(crate) fn build_specs( builder.register_handler("test_sync_tool", test_sync_handler); } - if config.web_search_request { - builder.push_spec(ToolSpec::WebSearch {}); + // 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), + }); } if config.include_view_image_tool { @@ -1137,7 +1147,7 @@ mod tests { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, ToolSpec::LocalShell {} => "local_shell", - ToolSpec::WebSearch {} => "web_search", + ToolSpec::WebSearch { .. } => "web_search", ToolSpec::Freeform(FreeformTool { name, .. }) => name, } } @@ -1215,7 +1225,7 @@ mod tests { ToolSpec::Function(ResponsesApiTool { parameters, .. }) => { strip_descriptions_schema(parameters); } - ToolSpec::Freeform(_) | ToolSpec::LocalShell {} | ToolSpec::WebSearch {} => {} + ToolSpec::Freeform(_) | ToolSpec::LocalShell {} | ToolSpec::WebSearch { .. } => {} } } @@ -1259,7 +1269,9 @@ mod tests { create_read_mcp_resource_tool(), PLAN_TOOL.clone(), create_apply_patch_freeform_tool(), - ToolSpec::WebSearch {}, + ToolSpec::WebSearch { + external_web_access: Some(true), + }, create_view_image_tool(), ] { expected.insert(tool_name(&spec).to_string(), spec); @@ -1292,6 +1304,51 @@ mod tests { assert_eq!(&tool_names, &expected_tools,); } + #[test] + fn web_search_cached_sets_external_web_access_false() { + let config = test_config(); + let model_family = ModelsManager::construct_model_family_offline("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::WebSearchCached); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &features, + }); + let (tools, _) = build_specs(&tools_config, None).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(false), + } + ); + } + + #[test] + fn web_search_cached_takes_precedence_over_web_search_request() { + let config = test_config(); + let model_family = ModelsManager::construct_model_family_offline("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::WebSearchCached); + features.enable(Feature::WebSearchRequest); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &features, + }); + let (tools, _) = build_specs(&tools_config, None).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(false), + } + ); + } + #[test] fn test_build_specs_gpt5_codex_default() { assert_model_tools( diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 63784bd40..e5b809ca3 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -65,3 +65,4 @@ mod unified_exec; mod user_notification; mod user_shell_cmd; mod view_image; +mod web_search_cached; diff --git a/codex-rs/core/tests/suite/web_search_cached.rs b/codex-rs/core/tests/suite/web_search_cached.rs new file mode 100644 index 000000000..fa8e303d8 --- /dev/null +++ b/codex-rs/core/tests/suite/web_search_cached.rs @@ -0,0 +1,87 @@ +#![allow(clippy::unwrap_used)] + +use codex_core::features::Feature; +use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use serde_json::Value; + +fn sse_completed(id: &str) -> String { + load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) +} + +#[allow(clippy::expect_used)] +fn find_web_search_tool(body: &Value) -> &Value { + body["tools"] + .as_array() + .expect("request body should include tools array") + .iter() + .find(|tool| tool.get("type").and_then(Value::as_str) == Some("web_search")) + .expect("tools should include a web_search tool") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn web_search_cached_sets_external_web_access_false_in_request_body() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let sse = sse_completed("resp-1"); + let resp_mock = responses::mount_sse_once(&server, sse).await; + + let mut builder = test_codex() + .with_model("gpt-5-codex") + .with_config(|config| { + config.features.enable(Feature::WebSearchCached); + }); + let test = builder + .build(&server) + .await + .expect("create test Codex conversation"); + + test.submit_turn("hello cached web search") + .await + .expect("submit turn"); + + let body = resp_mock.single_request().body_json(); + let tool = find_web_search_tool(&body); + assert_eq!( + tool.get("external_web_access").and_then(Value::as_bool), + Some(false), + "web_search_cached 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() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let sse = sse_completed("resp-1"); + let resp_mock = responses::mount_sse_once(&server, sse).await; + + let mut builder = test_codex() + .with_model("gpt-5-codex") + .with_config(|config| { + config.features.enable(Feature::WebSearchRequest); + config.features.enable(Feature::WebSearchCached); + }); + let test = builder + .build(&server) + .await + .expect("create test Codex conversation"); + + test.submit_turn("hello cached+live flags") + .await + .expect("submit turn"); + + let body = resp_mock.single_request().body_json(); + let tool = find_web_search_tool(&body); + assert_eq!( + tool.get("external_web_access").and_then(Value::as_bool), + Some(false), + "web_search_cached should win over web_search_request" + ); +}