From 3044612e13b510433f403b161c98245189a690f5 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Tue, 4 Aug 2026 17:45:38 +0000 Subject: [PATCH] Enable cached web search for Amazon Bedrock (#36938) ## Why Amazon Bedrock supports hosted text web search, but it rejects the `search_content_types` field used for multimodal search and does not support external live or indexed web access. ## What changed - Advertise hosted web search for Amazon Bedrock while marking external web access as unsupported. - Resolve unsupported live and indexed modes to cached search, or disable the tool when cached search is prohibited by managed requirements. - Normalize built-in and configured Bedrock model catalogs to text-only web search, and retain the runtime provider in session configuration so turn setup can apply its capabilities. ## Testing - Cover cached fallback, managed-mode restrictions, text-only tool payloads, provider capabilities, and catalog normalization. GitOrigin-RevId: 310473849257401654388a4ebb42920e03aa3228 --- .../v2/model_provider_capabilities_read.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 90 +++++++++- codex-rs/core/src/config/mod.rs | 18 +- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 9 +- codex-rs/core/src/session/tests.rs | 36 ++-- codex-rs/core/src/session/turn_context.rs | 16 +- codex-rs/core/src/tools/spec_plan_tests.rs | 26 ++- codex-rs/core/tests/suite/responses_lite.rs | 5 +- codex-rs/core/tests/suite/web_search.rs | 154 ++++++++++++++++++ .../src/amazon_bedrock/catalog.rs | 35 +++- .../model-provider/src/amazon_bedrock/mod.rs | 14 +- codex-rs/model-provider/src/provider.rs | 2 + 13 files changed, 365 insertions(+), 50 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs index df842cf856..e05b7e2e3a 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs @@ -57,7 +57,7 @@ async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { let expected = ModelProviderCapabilitiesReadResponse { namespace_tools: true, image_generation: false, - web_search: false, + web_search: true, }; assert_eq!(received, expected); Ok(()) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c7e8ddfcca..4a49a2871c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -71,6 +71,7 @@ use codex_core_plugins::PluginsManager; use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_features::FeaturesToml; +use codex_model_provider::ProviderCapabilities; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; @@ -5797,7 +5798,11 @@ fn web_search_mode_disabled_overrides_legacy_request() { #[test] fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Indexed); - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &PermissionProfile::Disabled, + ProviderCapabilities::default(), + ); assert_eq!(mode, WebSearchMode::Indexed); } @@ -5806,7 +5811,11 @@ fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() { fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); let permission_profile = PermissionProfile::read_only(); - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &permission_profile, + ProviderCapabilities::default(), + ); assert_eq!(mode, WebSearchMode::Cached); } @@ -5814,15 +5823,75 @@ fn web_search_mode_for_turn_uses_preference_for_read_only() { #[test] fn web_search_mode_for_turn_prefers_live_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &PermissionProfile::Disabled, + ProviderCapabilities::default(), + ); assert_eq!(mode, WebSearchMode::Live); } +#[test] +fn web_search_mode_for_turn_falls_back_when_provider_disallows_external_web_access() { + for preferred in [WebSearchMode::Live, WebSearchMode::Indexed] { + let web_search_mode = Constrained::allow_any(preferred); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &PermissionProfile::Disabled, + ProviderCapabilities { + external_web_access: false, + ..ProviderCapabilities::default() + }, + ); + + assert_eq!(mode, WebSearchMode::Cached); + } +} + +#[test] +fn web_search_mode_for_turn_disables_when_external_access_and_cached_are_disallowed() +-> anyhow::Result<()> { + let allowed = [ + WebSearchMode::Disabled, + WebSearchMode::Live, + WebSearchMode::Indexed, + ]; + for preferred in [WebSearchMode::Live, WebSearchMode::Indexed] { + let web_search_mode = Constrained::new(preferred, move |candidate| { + if allowed.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &PermissionProfile::Disabled, + ProviderCapabilities { + external_web_access: false, + ..ProviderCapabilities::default() + }, + ); + + assert_eq!(mode, WebSearchMode::Disabled); + } + Ok(()) +} + #[test] fn web_search_mode_for_turn_respects_disabled_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled); - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &PermissionProfile::Disabled, + ProviderCapabilities::default(), + ); assert_eq!(mode, WebSearchMode::Disabled); } @@ -5842,7 +5911,11 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu }) } })?; - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &PermissionProfile::Disabled, + ProviderCapabilities::default(), + ); assert_eq!(mode, WebSearchMode::Cached); Ok(()) @@ -5867,7 +5940,11 @@ fn web_search_mode_for_turn_does_not_implicitly_select_indexed() -> anyhow::Resu }) } })?; - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &PermissionProfile::Disabled, + ProviderCapabilities::default(), + ); assert_eq!(mode, WebSearchMode::Cached); Ok(()) @@ -10297,6 +10374,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> resolve_web_search_mode_for_turn( &config.web_search_mode, &config.permissions.effective_permission_profile(), + ProviderCapabilities::default(), ), WebSearchMode::Cached, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0c7ef1d40b..75163a9c8e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -81,6 +81,7 @@ use codex_mcp::McpProtocolMode; use codex_mcp::McpServerRegistration; use codex_mcp::ResolvedMcpCatalog; use codex_memories_read::memory_root; +use codex_model_provider::ProviderCapabilities; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; @@ -2988,8 +2989,19 @@ pub fn resolve_bootstrap_http_client_factory( pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, + provider_capabilities: ProviderCapabilities, ) -> WebSearchMode { let preferred = web_search_mode.value(); + let is_allowed = |mode: WebSearchMode| { + let provider_supports_mode = match mode { + WebSearchMode::Live | WebSearchMode::Indexed => { + provider_capabilities.external_web_access + } + WebSearchMode::Cached | WebSearchMode::Disabled => true, + }; + + provider_supports_mode && web_search_mode.can_set(&mode).is_ok() + }; if matches!(permission_profile, PermissionProfile::Disabled) && !matches!(preferred, WebSearchMode::Disabled | WebSearchMode::Indexed) @@ -2999,12 +3011,12 @@ pub(crate) fn resolve_web_search_mode_for_turn( WebSearchMode::Cached, WebSearchMode::Disabled, ] { - if web_search_mode.can_set(&mode).is_ok() { + if is_allowed(mode) { return mode; } } } else { - if web_search_mode.can_set(&preferred).is_ok() { + if is_allowed(preferred) { return preferred; } for mode in [ @@ -3012,7 +3024,7 @@ pub(crate) fn resolve_web_search_mode_for_turn( WebSearchMode::Live, WebSearchMode::Disabled, ] { - if web_search_mode.can_set(&mode).is_ok() { + if is_allowed(mode) { return mode; } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f515fa2787..4b3aadf7ae 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -190,6 +190,7 @@ use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerSource; use codex_config::types::McpServerConfig; +use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; @@ -676,7 +677,10 @@ impl Session { let service_tier = get_service_tier(config.service_tier.clone(), fast_mode_enabled, &model_info); let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider( + config.model_provider.clone(), + Some(Arc::clone(&auth_manager)), + ), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, service_tier, @@ -1630,7 +1634,7 @@ impl Session { pub(crate) async fn provider(&self) -> ModelProviderInfo { let state = self.state.lock().await; - state.session_configuration.provider.clone() + state.session_configuration.provider.info().clone() } pub(crate) async fn refresh_runtime_config(&self, next_config: Config) { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index e6f5627920..29bd0af114 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -13,6 +13,7 @@ use codex_extension_api::ExtensionDataInit; use codex_http_client::ClientRouteClass; use codex_http_client::RouteAwareClientPool; use codex_login::auth::AgentIdentityAuthPolicy; +use codex_model_provider::SharedModelProvider; use codex_protocol::SessionId; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; @@ -65,8 +66,8 @@ pub(crate) struct Session { #[derive(Clone)] pub(crate) struct SessionConfiguration { - /// Provider identifier ("openai", "openrouter", ...). - pub(super) provider: ModelProviderInfo, + /// Runtime provider and its provider-specific execution policy. + pub(super) provider: SharedModelProvider, pub(super) collaboration_mode: CollaborationMode, pub(super) model_reasoning_summary: Option, @@ -840,7 +841,7 @@ impl Session { let terminal_type = user_agent(); let session_model = session_configuration.collaboration_mode.model().to_string(); let auth_env_telemetry = collect_auth_env_telemetry( - &session_configuration.provider, + session_configuration.provider.info(), auth_manager.codex_api_key_env_enabled(), ); let mut session_telemetry = SessionTelemetry::new( @@ -1147,7 +1148,7 @@ impl Session { AgentIdentityAuthPolicy::JwtOnly }, thread_id, - session_configuration.provider.clone(), + session_configuration.provider.info().clone(), session_configuration.session_source.clone(), session_configuration.originator.clone(), config.model_verbosity, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 040138acc0..b8a91c4b34 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -39,6 +39,7 @@ use codex_http_client::OutboundProxyPolicy; use codex_http_client::RouteAwareClientPool; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuthPolicy; +use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::bundled_models_response; use codex_models_manager::model_info; @@ -4092,7 +4093,7 @@ async fn set_rate_limits_retains_previous_credits() { }, }; let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider(config.model_provider.clone(), /*auth_manager*/ None), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -4201,7 +4202,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { }, }; let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider(config.model_provider.clone(), /*auth_manager*/ None), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -4747,7 +4748,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati }; SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider(config.model_provider.clone(), /*auth_manager*/ None), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -5486,7 +5487,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { }, }; let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider( + config.model_provider.clone(), + Some(Arc::clone(&auth_manager)), + ), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -5623,7 +5627,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { }; let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider( + config.model_provider.clone(), + Some(Arc::clone(&auth_manager)), + ), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -5760,7 +5767,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { Some(auth_manager.clone()), AgentIdentityAuthPolicy::JwtOnly, thread_id, - session_configuration.provider.clone(), + session_configuration.provider.info().clone(), session_configuration.session_source.clone(), session_configuration.originator.clone(), config.model_verbosity, @@ -5895,7 +5902,10 @@ async fn make_session_with_config_and_rx( }; let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider( + config.model_provider.clone(), + Some(Arc::clone(&auth_manager)), + ), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -6005,7 +6015,10 @@ async fn make_session_with_history_source_and_agent_control_and_rx( }; let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider( + config.model_provider.clone(), + Some(Arc::clone(&auth_manager)), + ), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -7818,7 +7831,10 @@ where }; let default_environments = vec![local(config.cwd.clone())]; let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), + provider: create_model_provider( + config.model_provider.clone(), + Some(Arc::clone(&auth_manager)), + ), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -7954,7 +7970,7 @@ where Some(Arc::clone(&auth_manager)), AgentIdentityAuthPolicy::JwtOnly, thread_id, - session_configuration.provider.clone(), + session_configuration.provider.info().clone(), session_configuration.session_source.clone(), session_configuration.originator.clone(), config.model_verbosity, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 30ae7e2d81..968b9a9139 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -6,7 +6,6 @@ use codex_core_plugins::TrustedPluginRoots; use codex_core_skills::HostSkillsSnapshot; use codex_file_system::FileSystemSandboxContext; use codex_model_provider::SharedModelProvider; -use codex_model_provider::create_model_provider; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::AdditionalPermissionProfile; @@ -454,8 +453,11 @@ impl Session { session_configuration .apply_permission_profile_to_permissions(&mut per_turn_config.permissions); let permission_profile = session_configuration.permission_profile(); - let resolved_web_search_mode = - resolve_web_search_mode_for_turn(&per_turn_config.web_search_mode, &permission_profile); + let resolved_web_search_mode = resolve_web_search_mode_for_turn( + &per_turn_config.web_search_mode, + &permission_profile, + session_configuration.provider.capabilities(), + ); if let Err(err) = per_turn_config .web_search_mode .set(resolved_web_search_mode) @@ -488,7 +490,7 @@ impl Session { session_id: SessionId, auth_manager: Option>, session_telemetry: &SessionTelemetry, - provider: ModelProviderInfo, + provider: SharedModelProvider, session_configuration: &SessionConfiguration, multi_agent_version: MultiAgentVersion, user_shell: &shell::Shell, @@ -513,8 +515,6 @@ impl Session { model_info.slug.as_str(), ); let session_source = session_configuration.session_source.clone(); - let auth_manager_for_context = auth_manager.clone(); - let provider_for_context = create_model_provider(provider, auth_manager); let session_telemetry_for_context = session_telemetry; let available_models = models_manager.try_list_models().unwrap_or_default(); let unified_exec_shell_mode = UnifiedExecShellMode::for_session( @@ -555,10 +555,10 @@ impl Session { realtime_active: false, code_mode_available: true, config: per_turn_config, - auth_manager: auth_manager_for_context, + auth_manager, model_info, session_telemetry: session_telemetry_for_context, - provider: provider_for_context, + provider, reasoning_effort, reasoning_summary, session_source, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 4bd8244ae7..499feae3f5 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -2289,10 +2289,30 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( .await; standalone_web_search.assert_visible_lacks(&["web_search"]); - let unsupported_provider = probe(|turn| { - set_web_search_mode(turn, WebSearchMode::Live); + let bedrock_cached_web_search = probe(|turn| { use_bedrock_provider(turn); + turn.model_info.web_search_tool_type = WebSearchToolType::Text; }) .await; - unsupported_provider.assert_visible_lacks(&["web_search"]); + assert_eq!( + bedrock_cached_web_search.visible_spec("web_search"), + &ToolSpec::WebSearch { + external_web_access: Some(false), + indexed_web_access: None, + filters: None, + user_location: None, + search_context_size: None, + search_content_types: None, + } + ); + + let bedrock_with_standalone_web_search = probe(|turn| { + set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); + set_web_search_mode(turn, WebSearchMode::Cached); + use_bedrock_provider(turn); + turn.model_info.web_search_tool_type = WebSearchToolType::Text; + }) + .await; + bedrock_with_standalone_web_search.assert_visible_contains(&["web_search"]); + bedrock_with_standalone_web_search.assert_visible_lacks(&["web"]); } diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 3ba46439c6..b6a565a926 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -343,8 +343,7 @@ async fn responses_lite_does_not_expose_disabled_standalone_web_search_for_opted } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_lite_does_not_expose_standalone_web_search_for_opted_in_bedrock_provider() --> Result<()> { +async fn responses_lite_does_not_expose_standalone_web_search_for_bedrock_provider() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -373,7 +372,7 @@ async fn responses_lite_does_not_expose_standalone_web_search_for_opted_in_bedro 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; + config.model_provider.supports_standalone_web_search = false; }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 1bc1adadfd..8b0f9cf208 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -1,8 +1,15 @@ #![allow(clippy::unwrap_used)] +use codex_config::test_support::CloudConfigBundleFixture; use codex_features::Feature; +use codex_login::CodexAuth; +use codex_login::auth::BedrockApiKeyAuth; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; +use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::WebSearchToolType; use core_test_support::responses; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -59,6 +66,153 @@ async fn web_search_mode_cached_sets_external_web_access_false() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn amazon_bedrock_web_search_uses_text_only_hosted_tools() { + skip_if_no_network!(); + + enum ModelCatalog { + BuiltIn, + Configured, + } + + for (case, configured_web_search_mode, model_catalog) in [ + ("cached by default", None, ModelCatalog::BuiltIn), + ( + "unsupported explicit live search falls back to cached", + Some(WebSearchMode::Live), + ModelCatalog::BuiltIn, + ), + ( + "unsupported explicit indexed search falls back to cached", + Some(WebSearchMode::Indexed), + ModelCatalog::BuiltIn, + ), + ("configured model catalog", None, ModelCatalog::Configured), + ] { + let server = start_mock_server().await; + let resp_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 mut builder = test_codex().with_auth(auth); + builder = match model_catalog { + ModelCatalog::BuiltIn => builder.with_model(AMAZON_BEDROCK_GPT_5_4_MODEL_ID), + ModelCatalog::Configured => builder.with_model_info_override("gpt-5.4", |model_info| { + model_info.web_search_tool_type = WebSearchToolType::TextAndImage; + }), + }; + builder = builder.with_config(move |config| { + let base_url = config.model_provider.base_url.clone(); + config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string(); + config.model_provider = + ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None); + config.model_provider.base_url = base_url; + if let Some(mode) = configured_web_search_mode { + config + .web_search_mode + .set(mode) + .expect("test web search mode should satisfy constraints"); + } + }); + let test = builder + .build_with_auto_env(&server) + .await + .expect("create test Bedrock conversation"); + + test.submit_turn_with_permission_profile( + "hello Bedrock web search", + PermissionProfile::Disabled, + ) + .await + .expect("submit Bedrock turn"); + + let body = resp_mock.single_request().body_json(); + assert_eq!( + find_web_search_tool(&body), + &json!({ + "type": "web_search", + "external_web_access": false, + }), + "unexpected Bedrock search tool for {case}" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn amazon_bedrock_web_search_is_disabled_when_managed_requirements_prohibit_cached_search() { + skip_if_no_network!(); + + for (allowed_mode, requested_mode) in [ + ("live", WebSearchMode::Live), + ("indexed", WebSearchMode::Indexed), + ] { + let server = start_mock_server().await; + let resp_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 mut builder = test_codex() + .with_auth(auth) + .with_model(AMAZON_BEDROCK_GPT_5_4_MODEL_ID) + .with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(format!( + r#"allowed_web_search_modes = ["{allowed_mode}"]"# + )), + ) + .with_config(move |config| { + assert_eq!(config.web_search_mode.value(), requested_mode); + let base_url = config.model_provider.base_url.clone(); + config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string(); + config.model_provider = + ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None); + config.model_provider.base_url = base_url; + }); + let test = builder + .build_with_auto_env(&server) + .await + .expect("create managed Bedrock conversation"); + + test.submit_turn_with_permission_profile( + "hello managed Bedrock web search", + PermissionProfile::Disabled, + ) + .await + .expect("submit managed Bedrock turn"); + + let body = resp_mock.single_request().body_json(); + let web_search_tool = body + .get("tools") + .and_then(Value::as_array) + .and_then(|tools| { + tools + .iter() + .find(|tool| tool.get("type").and_then(Value::as_str) == Some("web_search")) + }); + assert_eq!( + web_search_tool, None, + "Bedrock search should be disabled when managed requirements only allow {allowed_mode}" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn web_search_mode_takes_precedence_over_legacy_flags() { skip_if_no_network!(); diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index e1ddfd8dd7..e07a4d713b 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -9,6 +9,7 @@ use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::openai_models::WebSearchToolType; const GPT_5_BEDROCK_CONTEXT_WINDOW: i64 = 272_000; const GPT_5_6_SOL_OPENAI_MODEL_ID: &str = "gpt-5.6-sol"; @@ -18,7 +19,7 @@ const GPT_5_5_OPENAI_MODEL_ID: &str = "gpt-5.5"; const GPT_5_4_OPENAI_MODEL_ID: &str = "gpt-5.4"; pub(crate) fn static_model_catalog() -> ModelsResponse { - with_default_only_service_tier(ModelsResponse { + normalize_bedrock_catalog(ModelsResponse { models: vec![ gpt_5_6_bedrock_model( GPT_5_6_SOL_OPENAI_MODEL_ID, @@ -54,12 +55,14 @@ pub(crate) fn static_model_catalog() -> ModelsResponse { }) } -pub(crate) fn with_default_only_service_tier(mut catalog: ModelsResponse) -> ModelsResponse { +pub(crate) fn normalize_bedrock_catalog(mut catalog: ModelsResponse) -> ModelsResponse { for model in &mut catalog.models { // Amazon Bedrock currently only supports the implicit "default" tier for GPT models. model.additional_speed_tiers.clear(); model.service_tiers.clear(); model.default_service_tier = None; + // Bedrock rejects the `search_content_types` field used by multimodal search. + model.web_search_tool_type = WebSearchToolType::Text; } catalog } @@ -148,15 +151,39 @@ mod tests { for model in catalog.models { assert_eq!( - (model.context_window, model.max_context_window), + ( + model.context_window, + model.max_context_window, + model.web_search_tool_type, + ), ( Some(GPT_5_BEDROCK_CONTEXT_WINDOW), - Some(GPT_5_BEDROCK_CONTEXT_WINDOW) + Some(GPT_5_BEDROCK_CONTEXT_WINDOW), + WebSearchToolType::Text, ) ); } } + #[test] + fn configured_bedrock_catalogs_normalize_unsupported_model_capabilities() { + let model = bundled_openai_model(GPT_5_4_OPENAI_MODEL_ID); + let mut expected = model.clone(); + expected.additional_speed_tiers.clear(); + expected.service_tiers.clear(); + expected.default_service_tier = None; + expected.web_search_tool_type = WebSearchToolType::Text; + + assert_eq!( + normalize_bedrock_catalog(ModelsResponse { + models: vec![model], + }), + ModelsResponse { + models: vec![expected], + } + ); + } + #[test] fn gpt_5_bedrock_models_do_not_include_availability_nux_or_upgrade() { let catalog = static_model_catalog(); diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index d807da1e95..51bfafdce7 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -30,8 +30,8 @@ use crate::provider::ProviderAccountResult; use crate::provider::ProviderAccountState; use crate::provider::ProviderCapabilities; use auth::resolve_provider_auth as resolve_bedrock_provider_auth; +use catalog::normalize_bedrock_catalog; pub(crate) use catalog::static_model_catalog; -use catalog::with_default_only_service_tier; use mantle::bedrock_mantle_runtime_base_url; pub use mantle::is_supported_amazon_bedrock_region; @@ -124,7 +124,8 @@ impl ModelProvider for AmazonBedrockModelProvider { ProviderCapabilities { namespace_tools: true, image_generation: false, - web_search: false, + web_search: true, + external_web_access: false, } } @@ -184,7 +185,7 @@ impl ModelProvider for AmazonBedrockModelProvider { ) -> SharedModelsManager { Arc::new(StaticModelsManager::new( /*auth_manager*/ None, - config_model_catalog.map_or_else(static_model_catalog, with_default_only_service_tier), + config_model_catalog.map_or_else(static_model_catalog, normalize_bedrock_catalog), )) } @@ -194,7 +195,7 @@ impl ModelProvider for AmazonBedrockModelProvider { ) -> SharedModelsManager { Arc::new(StaticModelsManager::new( /*auth_manager*/ None, - config_model_catalog.map_or_else(static_model_catalog, with_default_only_service_tier), + config_model_catalog.map_or_else(static_model_catalog, normalize_bedrock_catalog), )) } } @@ -354,7 +355,7 @@ mod tests { } #[test] - fn capabilities_disable_unsupported_hosted_tools() { + fn capabilities_enable_web_search_but_disable_image_generation() { let provider = AmazonBedrockModelProvider::new( ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), /*auth_manager*/ None, @@ -365,7 +366,8 @@ mod tests { ProviderCapabilities { namespace_tools: true, image_generation: false, - web_search: false, + web_search: true, + external_web_access: false, } ); } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index a731a16103..95a873bea6 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -35,6 +35,7 @@ pub struct ProviderCapabilities { pub namespace_tools: bool, pub image_generation: bool, pub web_search: bool, + pub external_web_access: bool, } impl Default for ProviderCapabilities { @@ -43,6 +44,7 @@ impl Default for ProviderCapabilities { namespace_tools: true, image_generation: true, web_search: true, + external_web_access: true, } } }