Allow custom providers to opt into standalone web search (#34846)

## What changed

- Add the `supports_standalone_web_search` model-provider setting, defaulting to `false`, and preserve it in remote thread configuration.
- Enable the standalone `web.run` tool for opted-in custom Responses providers when web search is enabled and the runtime provider supports it.
- Send standalone search requests through the custom provider's endpoint and authentication.

## Testing

- Cover opt-in, default-off, disabled-search, and unsupported-provider behavior.
- Verify custom-provider search request routing and authorization through the app server.

GitOrigin-RevId: 7c5f96b0ce924ad2b9715c45bfc635e89fc39cff
This commit is contained in:
sayan-oai
2026-07-23 15:40:17 -07:00
committed by shijie-openai
parent bbfc3f0152
commit 499e62300c
20 changed files with 280 additions and 21 deletions
@@ -688,6 +688,7 @@ mod thread_processor_behavior_tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: false,
};
let config_manager = ConfigManager::new(
temp_dir.path().to_path_buf(),
@@ -43,6 +43,23 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn standalone_web_search_round_trips_output() -> Result<()> {
assert_standalone_web_search_round_trips_output(WebSearchProvider::ChatGpt).await
}
#[tokio::test]
async fn standalone_web_search_round_trips_output_for_custom_provider() -> Result<()> {
assert_standalone_web_search_round_trips_output(WebSearchProvider::CustomResponses).await
}
#[derive(Clone, Copy)]
enum WebSearchProvider {
ChatGpt,
CustomResponses,
}
async fn assert_standalone_web_search_round_trips_output(
provider: WebSearchProvider,
) -> Result<()> {
let call_id = "web-run-1";
let expected_model_id = "model-id-from-search-context";
let search_context = json!({
@@ -57,7 +74,11 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
json!({ "openai/search_context": search_context }).to_string(),
)]);
let server = responses::start_mock_server().await;
mount_search_response(&server).await;
let search_path = match provider {
WebSearchProvider::ChatGpt => "/api/codex/alpha/search",
WebSearchProvider::CustomResponses => "/v1/alpha/search",
};
mount_search_response(&server, search_path).await;
let response_mock = responses::mount_sse_sequence(
&server,
@@ -84,24 +105,47 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
.await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.with_model_provider("openai-custom")
.with_provider_name("OpenAI")
.with_provider_base_url(&format!("{}/api/codex", server.uri()))
let config = MockResponsesConfig::new(&server.uri())
.with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri()))
.enable_feature(Feature::StandaloneWebSearch)
.with_provider_config("supports_websockets = false")
.with_provider_config("requires_openai_auth = true")
.write(codex_home.path())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
.with_provider_config("supports_websockets = false");
let config = match provider {
WebSearchProvider::ChatGpt => config
.with_model_provider("openai-custom")
.with_provider_name("OpenAI")
.with_provider_base_url(&format!("{}/api/codex", server.uri()))
.with_provider_config("requires_openai_auth = true"),
WebSearchProvider::CustomResponses => config
.with_model_provider("custom-responses")
.with_provider_name("Custom Responses")
.with_provider_base_url(&format!("{}/v1", server.uri()))
.with_provider_config("env_key = \"CUSTOM_RESPONSES_API_KEY\"")
.with_provider_config("supports_standalone_web_search = true")
.with_provider_config("requires_openai_auth = false"),
};
config.write(codex_home.path())?;
if matches!(provider, WebSearchProvider::ChatGpt) {
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
}
let env_overrides = match provider {
WebSearchProvider::ChatGpt => {
vec![("OPENAI_API_KEY", None), ("CUSTOM_RESPONSES_API_KEY", None)]
}
WebSearchProvider::CustomResponses => vec![
("OPENAI_API_KEY", None),
("CUSTOM_RESPONSES_API_KEY", Some("test-api-key")),
],
};
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.with_env_overrides(&env_overrides)
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
@@ -159,7 +203,28 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
"standalone web search should replace hosted web search"
);
let search_request = search_request(&server).await?;
let search_request = search_request(&server, search_path).await?;
let expected_authorization = match provider {
WebSearchProvider::ChatGpt => "Bearer access-chatgpt",
WebSearchProvider::CustomResponses => "Bearer test-api-key",
};
assert_eq!(
search_request
.headers
.get("authorization")
.context("standalone search should include provider authorization")?
.to_str()
.context("standalone search authorization should be valid ASCII")?,
expected_authorization
);
if matches!(provider, WebSearchProvider::CustomResponses) {
assert!(
search_request
.headers
.get("x-openai-actor-authorization")
.is_none()
);
}
assert_eq!(
search_request
.headers
@@ -268,7 +333,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
drop(mcp);
let mut reloaded_mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.with_env_overrides(&env_overrides)
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let read_req = reloaded_mcp
@@ -310,9 +375,9 @@ async fn wait_for_web_search_completed(
}
}
async fn mount_search_response(server: &MockServer) {
async fn mount_search_response(server: &MockServer, search_path: &str) {
Mock::given(method("POST"))
.and(path("/api/codex/alpha/search"))
.and(path(search_path))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encrypted_output": "ciphertext",
"output": "Search result",
@@ -340,13 +405,13 @@ fn has_hosted_web_search(body: &Value) -> bool {
})
}
async fn search_request(server: &MockServer) -> Result<wiremock::Request> {
async fn search_request(server: &MockServer, search_path: &str) -> Result<wiremock::Request> {
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
requests
.into_iter()
.find(|request| request.url.path() == "/api/codex/alpha/search")
.find(|request| request.url.path() == search_path)
.context("expected standalone search request")
}
+2
View File
@@ -284,6 +284,7 @@ mod tests {
wire_api = "responses"
requires_openai_auth = false
supports_websockets = true
supports_standalone_web_search = true
[features]
plugins = false
@@ -312,6 +313,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: true,
}
}
}
@@ -48,6 +48,7 @@ message ModelProvider {
optional uint64 websocket_connect_timeout_ms = 15;
bool requires_openai_auth = 16;
bool supports_websockets = 17;
bool supports_standalone_web_search = 18;
}
message StringMap {
@@ -75,6 +75,8 @@ pub struct ModelProvider {
pub requires_openai_auth: bool,
#[prost(bool, tag = "17")]
pub supports_websockets: bool,
#[prost(bool, tag = "18")]
pub supports_standalone_web_search: bool,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct StringMap {
@@ -190,6 +190,7 @@ fn model_provider_from_proto(
websocket_connect_timeout_ms: provider.websocket_connect_timeout_ms,
requires_openai_auth: provider.requires_openai_auth,
supports_websockets: provider.supports_websockets,
supports_standalone_web_search: provider.supports_standalone_web_search,
};
Ok((id, info))
}
@@ -217,6 +218,7 @@ fn model_provider_to_proto(
websocket_connect_timeout_ms,
requires_openai_auth,
supports_websockets,
supports_standalone_web_search,
} = provider;
proto::ModelProvider {
@@ -237,6 +239,7 @@ fn model_provider_to_proto(
websocket_connect_timeout_ms,
requires_openai_auth,
supports_websockets,
supports_standalone_web_search,
}
}
@@ -421,6 +424,21 @@ mod tests {
fn model_provider_proto_roundtrips_through_domain_type() {
let expected = expected_provider();
let proto = model_provider_to_proto("local", expected.clone());
assert!(proto.supports_standalone_web_search);
let (id, actual) = model_provider_from_proto(proto).expect("model provider from proto");
assert_eq!(id, "local");
assert_eq!(actual, expected);
}
#[test]
fn model_provider_proto_defaults_standalone_web_search_to_false() {
let expected = ModelProviderInfo {
supports_standalone_web_search: false,
..expected_provider()
};
let proto = model_provider_to_proto("local", expected.clone());
assert!(!proto.supports_standalone_web_search);
let (id, actual) = model_provider_from_proto(proto).expect("model provider from proto");
assert_eq!(id, "local");
@@ -473,6 +491,7 @@ mod tests {
websocket_connect_timeout_ms: Some(10_000),
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: true,
}],
features: HashMap::from([
("plugins".to_string(), false),
@@ -536,6 +555,7 @@ mod tests {
websocket_connect_timeout_ms: Some(10_000),
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: true,
aws: None,
}
}
+5
View File
@@ -1736,6 +1736,11 @@
"minimum": 0.0,
"type": "integer"
},
"supports_standalone_web_search": {
"default": false,
"description": "Whether this provider supports the standalone web-search endpoint.",
"type": "boolean"
},
"supports_websockets": {
"default": false,
"description": "Whether this provider supports the Responses API WebSocket transport.",
+1
View File
@@ -284,6 +284,7 @@ fn should_use_remote_compact_task_for_azure_provider() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
assert!(should_use_remote_compact_task(&provider));
+1
View File
@@ -606,6 +606,7 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool {
namespace_tools_enabled(turn_context)
&& turn_context.provider.capabilities().web_search
&& (turn_context.model_info.use_responses_lite
|| turn_context
.config
+3
View File
@@ -88,6 +88,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let codex_home = TempDir::new().expect("failed to create TempDir");
@@ -223,6 +224,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let codex_home = TempDir::new().expect("failed to create TempDir");
@@ -339,6 +341,7 @@ async fn responses_respects_model_info_overrides_from_config() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let codex_home = TempDir::new().expect("failed to create TempDir");
+4
View File
@@ -1450,6 +1450,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
send_request_with_provider(provider).await;
@@ -3186,6 +3187,7 @@ async fn azure_responses_request_includes_store_and_prefixed_item_ids() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let codex_home = TempDir::new().unwrap();
@@ -3843,6 +3845,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
// Init session
@@ -3932,6 +3935,7 @@ async fn env_var_overrides_loaded_auth() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
// Init session
@@ -2285,6 +2285,7 @@ fn websocket_provider_with_connect_timeout(
websocket_connect_timeout_ms,
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: false,
}
}
+135
View File
@@ -9,6 +9,7 @@ use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_image_generation_extension::install as install_image_generation_extension;
use codex_login::CodexAuth;
use codex_login::auth::BedrockApiKeyAuth;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::InputModality;
@@ -290,6 +291,140 @@ async fn responses_lite_exposes_standalone_tools_for_actor_authorized_provider()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_exposes_standalone_web_search_for_opted_in_custom_provider() -> Result<()> {
assert_responses_lite_custom_provider_web_search(
WebSearchMode::Live,
/*supports_standalone_web_search*/ true,
/*expect_web_run*/ true,
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_does_not_expose_standalone_web_search_for_custom_provider_by_default()
-> Result<()> {
assert_responses_lite_custom_provider_web_search(
WebSearchMode::Live,
/*supports_standalone_web_search*/ false,
/*expect_web_run*/ false,
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_does_not_expose_disabled_standalone_web_search_for_opted_in_provider()
-> Result<()> {
assert_responses_lite_custom_provider_web_search(
WebSearchMode::Disabled,
/*supports_standalone_web_search*/ true,
/*expect_web_run*/ false,
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_does_not_expose_standalone_web_search_for_opted_in_bedrock_provider()
-> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]),
)
.await;
let auth = CodexAuth::BedrockApiKey(BedrockApiKeyAuth {
api_key: "dummy".to_string(),
region: "us-east-1".to_string(),
});
let extensions = responses_extensions(&auth);
let mut builder = test_codex()
.with_auth(auth)
.with_extensions(extensions)
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
})
.with_config(|config| {
configure_responses_tools(config);
config.model_provider.name = "Amazon Bedrock".to_string();
config.model_provider.requires_openai_auth = false;
config.model_provider.http_headers = None;
config.model_provider.supports_standalone_web_search = true;
});
let test = builder.build(&server).await?;
test.submit_turn("Use standalone web search").await?;
let request = response_mock.single_request();
assert_eq!(
request.header(RESPONSES_LITE_HEADER).as_deref(),
Some("true")
);
let body = request.body_json();
assert!(body.get("tools").is_none());
let tools = additional_tools(&body)?;
assert!(!has_namespaced_tool(tools, "web", "run"));
assert!(!has_hosted_tool(tools, "web_search"));
Ok(())
}
async fn assert_responses_lite_custom_provider_web_search(
web_search_mode: WebSearchMode,
supports_standalone_web_search: bool,
expect_web_run: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]),
)
.await;
let auth = CodexAuth::from_api_key("dummy");
let extensions = responses_extensions(&auth);
let mut builder = test_codex()
.with_auth(auth)
.with_extensions(extensions)
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
})
.with_config(move |config| {
configure_responses_tools(config);
assert!(config.web_search_mode.set(web_search_mode).is_ok());
config.model_provider.name = "custom-responses".to_string();
config.model_provider.requires_openai_auth = false;
config.model_provider.http_headers = None;
config.model_provider.supports_standalone_web_search = supports_standalone_web_search;
});
let test = builder.build(&server).await?;
test.submit_turn("Use standalone web search").await?;
let request = response_mock.single_request();
assert_eq!(
request.header(RESPONSES_LITE_HEADER).as_deref(),
Some("true")
);
let body = request.body_json();
assert!(body.get("tools").is_none());
let tools = additional_tools(&body)?;
assert_eq!(has_namespaced_tool(tools, "web", "run"), expect_web_run);
assert!(!has_hosted_tool(tools, "web_search"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_compact_request_uses_lite_transport_contract() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -81,6 +81,7 @@ async fn continue_after_stream_error() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let TestCodex { codex, .. } = test_codex()
@@ -64,6 +64,7 @@ async fn retries_on_early_close() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let TestCodex { codex, .. } = test_codex()
+2 -1
View File
@@ -43,7 +43,8 @@ impl From<&Config> for WebSearchExtensionConfig {
Self {
// Core selects this executor per turn using the feature flag or model metadata.
available: (config.model_provider.is_openai()
|| config.model_provider.uses_openai_actor_authorization())
|| config.model_provider.uses_openai_actor_authorization()
|| config.model_provider.supports_standalone_web_search)
&& web_search_mode != WebSearchMode::Disabled,
provider: config.model_provider.clone(),
settings: search_settings(config, web_search_mode),
+1
View File
@@ -76,6 +76,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let telemetry =
+6
View File
@@ -138,6 +138,9 @@ pub struct ModelProviderInfo {
/// Whether this provider supports the Responses API WebSocket transport.
#[serde(default)]
pub supports_websockets: bool,
/// Whether this provider supports the standalone web-search endpoint.
#[serde(default)]
pub supports_standalone_web_search: bool,
}
/// AWS SigV4 auth configuration for a model provider.
@@ -360,6 +363,7 @@ impl ModelProviderInfo {
websocket_connect_timeout_ms: None,
requires_openai_auth: true,
supports_websockets: true,
supports_standalone_web_search: true,
}
}
@@ -393,6 +397,7 @@ impl ModelProviderInfo {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
}
}
@@ -540,6 +545,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
}
}
@@ -29,6 +29,7 @@ base_url = "http://localhost:11434/v1"
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -63,6 +64,7 @@ query_params = { api-version = "2025-04-01-preview" }
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -77,6 +79,7 @@ base_url = "https://example.com"
env_key = "API_KEY"
http_headers = { "X-Example-Header" = "example-value" }
env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
supports_standalone_web_search = true
"#;
let expected_provider = ModelProviderInfo {
name: "Example".into(),
@@ -100,6 +103,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: true,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -177,6 +181,7 @@ fn test_supports_remote_compaction_for_azure_name() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
assert!(provider.supports_remote_compaction());
@@ -202,6 +207,7 @@ fn test_supports_remote_compaction_for_non_openai_non_azure_provider() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
};
assert!(!provider.supports_remote_compaction());
@@ -310,6 +316,7 @@ fn test_create_amazon_bedrock_provider() {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
}
);
}
+1
View File
@@ -441,6 +441,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
}
}