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.
This commit is contained in:
sayan-oai 2026-01-06 14:53:59 -08:00 committed by GitHub
parent 11d4f3f45e
commit 54ded1a3c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 168 additions and 8 deletions

View file

@ -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<bool>,
},
#[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(),
}
}

View file

@ -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,

View file

@ -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,

View file

@ -21,6 +21,7 @@ pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_request: bool,
pub web_search_cached: bool,
pub include_view_image_tool: bool,
pub experimental_supported_tools: Vec<String>,
}
@ -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(

View file

@ -65,3 +65,4 @@ mod unified_exec;
mod user_notification;
mod user_shell_cmd;
mod view_image;
mod web_search_cached;

View file

@ -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"
);
}