diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 89a868ef5..2b67bda5c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3591,6 +3591,7 @@ dependencies = [ "codex-install-context", "codex-login", "codex-mcp", + "codex-model-provider", "codex-model-provider-info", "codex-models-manager", "codex-otel", diff --git a/codex-rs/model-provider/src/amazon_bedrock/auth.rs b/codex-rs/model-provider/src/amazon_bedrock/auth.rs index b6fcfdc11..96c233207 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/auth.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/auth.rs @@ -22,12 +22,14 @@ use super::mantle::region_from_config; const AWS_BEARER_TOKEN_BEDROCK_ENV_VAR: &str = "AWS_BEARER_TOKEN_BEDROCK"; const LEGACY_SESSION_ID_HEADER: &str = "session_id"; -enum BedrockAuthMethod { +pub(super) enum BedrockAuthMethod { EnvBearerToken { token: String, region: String }, AwsSdkAuth { context: AwsAuthContext }, } -async fn resolve_auth_method(aws: &ModelProviderAwsAuthInfo) -> Result { +pub(super) async fn resolve_auth_method( + aws: &ModelProviderAwsAuthInfo, +) -> Result { if let Some(token) = bearer_token_from_env() { let region = bearer_token_region_from_config(aws)?; return Ok(BedrockAuthMethod::EnvBearerToken { token, region }); @@ -55,13 +57,6 @@ pub(super) async fn resolve_provider_auth( } } -pub(super) async fn resolve_region(aws: &ModelProviderAwsAuthInfo) -> Result { - match resolve_auth_method(aws).await? { - BedrockAuthMethod::EnvBearerToken { region, .. } => Ok(region), - BedrockAuthMethod::AwsSdkAuth { context, .. } => Ok(context.region().to_string()), - } -} - fn bearer_token_from_env() -> Option { std::env::var(AWS_BEARER_TOKEN_BEDROCK_ENV_VAR) .ok() diff --git a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs index 47d88423b..7881845e4 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs @@ -3,6 +3,9 @@ use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; +use super::auth::BedrockAuthMethod; +use super::auth::resolve_auth_method; + const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle"; const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [ "us-east-2", @@ -45,6 +48,18 @@ pub(super) fn base_url(region: &str) -> Result { } } +pub(super) async fn runtime_base_url(aws: &ModelProviderAwsAuthInfo) -> Result { + let region = resolve_region(aws).await?; + base_url(®ion) +} + +async fn resolve_region(aws: &ModelProviderAwsAuthInfo) -> Result { + match resolve_auth_method(aws).await? { + BedrockAuthMethod::EnvBearerToken { region, .. } => Ok(region), + BedrockAuthMethod::AwsSdkAuth { context } => Ok(context.region().to_string()), + } +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index c8f772c62..adca7d7d9 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -22,9 +22,8 @@ use crate::provider::ProviderAccountResult; use crate::provider::ProviderAccountState; use crate::provider::ProviderCapabilities; use auth::resolve_provider_auth; -use auth::resolve_region; pub(crate) use catalog::static_model_catalog; -use mantle::base_url; +use mantle::runtime_base_url; /// Runtime provider for Amazon Bedrock's OpenAI-compatible Mantle endpoint. #[derive(Clone, Debug)] @@ -79,12 +78,15 @@ impl ModelProvider for AmazonBedrockModelProvider { } async fn api_provider(&self) -> Result { - let region = resolve_region(&self.aws).await?; let mut api_provider_info = self.info.clone(); - api_provider_info.base_url = Some(base_url(®ion)?); + api_provider_info.base_url = Some(runtime_base_url(&self.aws).await?); api_provider_info.to_api_provider(/*auth_mode*/ None) } + async fn runtime_base_url(&self) -> Result> { + Ok(Some(runtime_base_url(&self.aws).await?)) + } + async fn api_auth(&self) -> Result { resolve_provider_auth(&self.aws).await } @@ -112,7 +114,7 @@ mod tests { let region = "eu-central-1"; let mut api_provider_info = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None); - api_provider_info.base_url = Some(base_url(region).expect("supported region")); + api_provider_info.base_url = Some(mantle::base_url(region).expect("supported region")); let api_provider = api_provider_info .to_api_provider(/*auth_mode*/ None) .expect("api provider should build"); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 67240a6f5..0c5e8e0ff 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -106,6 +106,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { .to_api_provider(auth.as_ref().map(CodexAuth::auth_mode)) } + /// Returns the provider base URL that will be used at request time. + async fn runtime_base_url(&self) -> codex_protocol::error::Result> { + Ok(self.info().base_url.clone()) + } + /// Returns the auth provider used to attach request credentials. async fn api_auth(&self) -> codex_protocol::error::Result { let auth = self.auth().await; @@ -333,6 +338,22 @@ mod tests { assert_eq!(provider.capabilities(), ProviderCapabilities::default()); } + #[tokio::test] + async fn configured_provider_runtime_base_url_uses_configured_base_url() { + let provider = create_model_provider( + provider_for("https://example.test/v1".to_string()), + /*auth_manager*/ None, + ); + + assert_eq!( + provider + .runtime_base_url() + .await + .expect("runtime base URL should resolve"), + Some("https://example.test/v1".to_string()) + ); + } + #[test] fn create_model_provider_builds_command_auth_manager_without_base_manager() { let provider = create_model_provider( diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 300449a41..1ff81ebf6 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,6 +42,7 @@ codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 226872741..c188ff6b2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -128,6 +128,8 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; use codex_exec_server::EnvironmentManager; use codex_features::Feature; +use codex_model_provider::create_model_provider; +use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_otel::SessionTelemetry; @@ -516,6 +518,17 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option Option { + let provider = create_model_provider(provider.clone(), /*auth_manager*/ None); + match provider.runtime_base_url().await { + Ok(base_url) => base_url, + Err(err) => { + tracing::warn!(%err, "failed to resolve runtime model provider base URL for status"); + None + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ActiveTurnSteerRace { Missing, @@ -565,6 +578,10 @@ impl App { feedback: self.feedback.clone(), is_first_run: false, status_account_display: self.chat_widget.status_account_display().cloned(), + runtime_model_provider_base_url: self + .chat_widget + .runtime_model_provider_base_url() + .map(str::to_string), initial_plan_type: self.chat_widget.current_plan_type(), model: Some(self.chat_widget.current_model().to_string()), startup_tooltip_override: None, @@ -691,6 +708,8 @@ impl App { let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let runtime_model_provider_base_url = + resolve_runtime_model_provider_base_url(&config.model_provider).await; let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -717,6 +736,7 @@ impl App { feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), + runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), initial_plan_type, model: Some(model.clone()), startup_tooltip_override, @@ -751,6 +771,7 @@ impl App { feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), + runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), initial_plan_type, model: config.model.clone(), startup_tooltip_override: None, @@ -790,6 +811,7 @@ impl App { feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), + runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), initial_plan_type, model: config.model.clone(), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f843cc3d1..58ec89841 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -399,6 +399,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm feedback: codex_feedback::CodexFeedback::new(), is_first_run: false, status_account_display: None, + runtime_model_provider_base_url: None, initial_plan_type: None, model: Some(model), startup_tooltip_override: None, @@ -4671,6 +4672,10 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { feedback: app.feedback.clone(), is_first_run: false, status_account_display: app.chat_widget.status_account_display().cloned(), + runtime_model_provider_base_url: app + .chat_widget + .runtime_model_provider_base_url() + .map(str::to_string), initial_plan_type: app.chat_widget.current_plan_type(), model: Some(app.chat_widget.current_model().to_string()), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c9733c25b..9440f2d07 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -559,6 +559,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) status_account_display: Option, + pub(crate) runtime_model_provider_base_url: Option, pub(crate) initial_plan_type: Option, pub(crate) model: Option, pub(crate) startup_tooltip_override: Option, @@ -759,6 +760,7 @@ pub(crate) struct ChatWidget { session_header: SessionHeader, initial_user_message: Option, status_account_display: Option, + runtime_model_provider_base_url: Option, token_info: Option, rate_limit_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, @@ -4752,6 +4754,7 @@ impl ChatWidget { feedback, is_first_run, status_account_display, + runtime_model_provider_base_url, initial_plan_type, model, startup_tooltip_override, @@ -4836,6 +4839,7 @@ impl ChatWidget { session_header: SessionHeader::new(header_model), initial_user_message, status_account_display, + runtime_model_provider_base_url, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), @@ -6835,6 +6839,7 @@ impl ChatWidget { crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( &self.config, + self.runtime_model_provider_base_url.as_deref(), self.status_account_display.as_ref(), token_info, total_usage, @@ -9227,6 +9232,10 @@ impl ChatWidget { self.status_account_display.as_ref() } + pub(crate) fn runtime_model_provider_base_url(&self) -> Option<&str> { + self.runtime_model_provider_base_url.as_deref() + } + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn model_catalog(&self) -> Arc { self.model_catalog.clone() diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index a3a21611a..bb17359e7 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -198,6 +198,7 @@ pub(super) async fn make_chatwidget_manual( session_header: SessionHeader::new(resolved_model.clone()), initial_user_message: None, status_account_display: None, + runtime_model_provider_base_url: None, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 9f6f390ad..849580997 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1510,6 +1510,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, status_account_display: None, + runtime_model_provider_base_url: None, initial_plan_type: None, model: Some(resolved_model.clone()), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ad8ed0ea8..6f7a50e27 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -79,6 +79,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, status_account_display: None, + runtime_model_provider_base_url: None, initial_plan_type: None, model: Some(resolved_model.clone()), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 8afbd8ad7..bce32210d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -149,6 +149,7 @@ async fn helpers_are_available_and_do_not_panic() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, status_account_display: None, + runtime_model_provider_base_url: None, initial_plan_type: None, model: Some(resolved_model), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 53c1075c7..596c10aa7 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -167,6 +167,7 @@ pub(crate) fn new_status_output_with_rate_limits( ) -> CompositeHistoryCell { new_status_output_with_rate_limits_handle( config, + /*runtime_model_provider_base_url*/ None, account_display, token_info, total_usage, @@ -188,6 +189,7 @@ pub(crate) fn new_status_output_with_rate_limits( #[allow(clippy::too_many_arguments)] pub(crate) fn new_status_output_with_rate_limits_handle( config: &Config, + runtime_model_provider_base_url: Option<&str>, account_display: Option<&StatusAccountDisplay>, token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, @@ -206,6 +208,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle( let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let (card, handle) = StatusHistoryCell::new( config, + runtime_model_provider_base_url, account_display, token_info, total_usage, @@ -232,6 +235,7 @@ impl StatusHistoryCell { #[allow(clippy::too_many_arguments)] fn new( config: &Config, + runtime_model_provider_base_url: Option<&str>, account_display: Option<&StatusAccountDisplay>, token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, @@ -292,7 +296,7 @@ impl StatusHistoryCell { &sandbox, &approval, ); - let model_provider = format_model_provider(config); + let model_provider = format_model_provider(config, runtime_model_provider_base_url); let account = compose_account_display(account_display); let session_id = session_id.as_ref().map(std::string::ToString::to_string); let forked_from = forked_from.map(|id| id.to_string()); @@ -786,7 +790,7 @@ impl HistoryCell for StatusHistoryCell { } } -fn format_model_provider(config: &Config) -> Option { +fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> Option { let provider = &config.model_provider; let name = provider.name.trim(); let provider_name = if name.is_empty() { @@ -794,7 +798,7 @@ fn format_model_provider(config: &Config) -> Option { } else { name }; - let base_url = provider.base_url.as_deref().and_then(sanitize_base_url); + let base_url = runtime_base_url.and_then(sanitize_base_url); let is_default_openai = provider.is_openai() && base_url.is_none(); if is_default_openai { return None; diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 9dc923db8..b511957d8 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -24,6 +24,8 @@ use codex_app_server_protocol::PermissionProfileFileSystemPermissions; use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; +use codex_model_provider_info::ModelProviderAwsAuthInfo; +use codex_model_provider_info::ModelProviderInfo; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; @@ -515,6 +517,56 @@ async fn status_snapshot_shows_active_user_defined_profile() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_model_provider_uses_bedrock_runtime_base_url() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model_provider_id = "amazon-bedrock".to_string(); + config.model_provider = + ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo { + profile: None, + region: Some("eu-west-1".to_string()), + })); + config.model_provider.base_url = + Some("https://bedrock-mantle.us-east-1.api.aws/openai/v1".to_string()); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let runtime_base_url = "https://bedrock-mantle.eu-west-1.api.aws/openai/v1"; + + let (composite, _handle) = new_status_output_with_rate_limits_handle( + &config, + Some(runtime_base_url), + test_status_account_display().as_ref(), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ &[], + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + "".to_string(), + /*refreshing_rate_limits*/ false, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)).join("\n"); + + assert!( + rendered.contains(&format!("Amazon Bedrock - {runtime_base_url}")), + "expected /status to render runtime Bedrock URL, got: {rendered}" + ); + assert!( + !rendered.contains("bedrock-mantle.us-east-1"), + "expected /status to ignore configured Bedrock base URL, got: {rendered}" + ); +} + #[tokio::test] async fn status_snapshot_shows_auto_review_permissions() { let temp_home = TempDir::new().expect("temp home"); @@ -1113,6 +1165,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { let token_info = token_info_for(&model_slug, &config, &usage); let (composite, _) = new_status_output_with_rate_limits_handle( &config, + /*runtime_model_provider_base_url*/ None, account_display.as_ref(), Some(&token_info), &usage,