fix: show correct Bedrock runtime endpoint in /status (#20275)

## Why

`/status` was showing the configured `ModelProviderInfo.base_url` for
Amazon Bedrock, which can be stale or misleading because the actual
Bedrock Mantle endpoint is derived at runtime from the resolved AWS
region. This made sessions report the wrong provider endpoint even
though requests used the correct runtime URL.

## What changed

- Added `ModelProvider::runtime_base_url()` so provider implementations
can expose the request-time base URL through the shared runtime provider
abstraction.
- Moved Bedrock region-to-Mantle URL resolution into
`amazon_bedrock::mantle::runtime_base_url()`, keeping region resolution
private to the Mantle module.
- Overrode `runtime_base_url()` for Amazon Bedrock so it returns the
resolved Mantle endpoint instead of the configured default.
- Resolved and cached the runtime provider base URL during TUI startup,
then used that cached value when rendering `/status`.
- Added status coverage that verifies Bedrock displays the runtime URL
and ignores the configured Bedrock `base_url` when they differ.

## Verification
model provider is resolved correctly in local build:
<img width="696" height="245" alt="Screenshot 2026-04-29 at 5 01 36 PM"
src="https://github.com/user-attachments/assets/a13c10a5-3720-41ab-8ace-3c4bc573f971"
/>
This commit is contained in:
Celia Chen
2026-04-30 12:02:34 -07:00
committed by GitHub
Unverified
parent 93d53f655b
commit 31f8813e3e
15 changed files with 149 additions and 17 deletions
+1
View File
@@ -3591,6 +3591,7 @@ dependencies = [
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-model-provider",
"codex-model-provider-info",
"codex-models-manager",
"codex-otel",
@@ -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<BedrockAuthMethod> {
pub(super) async fn resolve_auth_method(
aws: &ModelProviderAwsAuthInfo,
) -> Result<BedrockAuthMethod> {
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<String> {
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<String> {
std::env::var(AWS_BEARER_TOKEN_BEDROCK_ENV_VAR)
.ok()
@@ -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<String> {
}
}
pub(super) async fn runtime_base_url(aws: &ModelProviderAwsAuthInfo) -> Result<String> {
let region = resolve_region(aws).await?;
base_url(&region)
}
async fn resolve_region(aws: &ModelProviderAwsAuthInfo) -> Result<String> {
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;
@@ -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<Provider> {
let region = resolve_region(&self.aws).await?;
let mut api_provider_info = self.info.clone();
api_provider_info.base_url = Some(base_url(&region)?);
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<Option<String>> {
Ok(Some(runtime_base_url(&self.aws).await?))
}
async fn api_auth(&self) -> Result<SharedAuthProvider> {
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");
+21
View File
@@ -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<Option<String>> {
Ok(self.info().base_url.clone())
}
/// Returns the auth provider used to attach request credentials.
async fn api_auth(&self) -> codex_protocol::error::Result<SharedAuthProvider> {
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(
+1
View File
@@ -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 }
+22
View File
@@ -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<App
.then_some(turn_error)
}
async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option<String> {
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,
+5
View File
@@ -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,
+9
View File
@@ -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<StatusAccountDisplay>,
pub(crate) runtime_model_provider_base_url: Option<String>,
pub(crate) initial_plan_type: Option<PlanType>,
pub(crate) model: Option<String>,
pub(crate) startup_tooltip_override: Option<String>,
@@ -759,6 +760,7 @@ pub(crate) struct ChatWidget {
session_header: SessionHeader,
initial_user_message: Option<UserMessage>,
status_account_display: Option<StatusAccountDisplay>,
runtime_model_provider_base_url: Option<String>,
token_info: Option<TokenUsageInfo>,
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
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<ModelCatalog> {
self.model_catalog.clone()
@@ -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(),
@@ -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,
@@ -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,
@@ -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,
+7 -3
View File
@@ -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<String> {
fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> Option<String> {
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<String> {
} 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;
+53
View File
@@ -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,
"<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,