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
This commit is contained in:
Celia Chen
2026-08-04 17:55:28 +00:00
committed by copyberry
parent 1e59dc5bda
commit 3ca9f375aa
13 changed files with 365 additions and 50 deletions
+84 -6
View File
@@ -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(())
@@ -10332,6 +10409,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,
);
+15 -3
View File
@@ -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;
@@ -2990,8 +2991,19 @@ pub fn resolve_bootstrap_http_client_factory(
pub(crate) fn resolve_web_search_mode_for_turn(
web_search_mode: &Constrained<WebSearchMode>,
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)
@@ -3001,12 +3013,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 [
@@ -3014,7 +3026,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;
}
}
+6 -2
View File
@@ -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;
@@ -677,7 +678,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,
@@ -1631,7 +1635,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) {
+5 -4
View File
@@ -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;
@@ -66,8 +67,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<ReasoningSummaryConfig>,
@@ -841,7 +842,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(
@@ -1146,7 +1147,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,
+26 -10
View File
@@ -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(),
@@ -7822,7 +7835,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(),
@@ -7958,7 +7974,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,
+8 -8
View File
@@ -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;
@@ -451,8 +450,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)
@@ -484,7 +486,7 @@ impl Session {
session_id: SessionId,
auth_manager: Option<Arc<AuthManager>>,
session_telemetry: &SessionTelemetry,
provider: ModelProviderInfo,
provider: SharedModelProvider,
session_configuration: &SessionConfiguration,
multi_agent_version: MultiAgentVersion,
user_shell: &shell::Shell,
@@ -509,8 +511,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(
@@ -551,10 +551,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,
+23 -3
View File
@@ -2342,10 +2342,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"]);
}