Files
codex/codex-rs/model-provider/src/provider.rs
T
jif 2681f8cba2 Update default models for memory extraction and consolidation (#58091)
Use `gpt-6-luna` for memory extraction and `gpt-6.1-terra` for memory
consolidation when the provider does not require a backend-specific model ID.

GitOrigin-RevId: 51419103b090d66af21e092983658da1285f6660
2026-10-29 14:30:02 +00:00

1707 lines
65 KiB
Rust

use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use codex_api::ApiError;
use codex_api::Provider;
use codex_api::SharedAuthProvider;
use codex_api::TransportError;
use codex_features::RuntimeAuthMode;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::GatewayAuthManager;
use codex_login::WorkspaceRoutingRequest;
use codex_login::default_client::ClientRedirectPolicy;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::cache::ModelsCache;
use codex_models_manager::manager::OpenAiModelsManager;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::manager::StaticModelsManager;
use codex_protocol::account::ProviderAccount;
use codex_protocol::auth::RefreshTokenFailedError;
use codex_protocol::error::CodexErr;
use codex_protocol::openai_models::ModelsResponse;
use codex_runtime_agent_instance::RuntimeAuthManager;
use crate::ProviderCapabilities;
#[cfg(test)]
use crate::RemoteCompactionSupport;
use crate::ResolvedResponsesProvider;
use crate::amazon_bedrock::AmazonBedrockModelProvider;
use crate::auth::ProviderAuthScope;
use crate::auth::ResolvedProviderAuth;
use crate::auth::auth_manager_for_provider;
use crate::auth::resolve_provider_auth;
use crate::auth::resolve_provider_auth_for_scope;
use crate::auth::uses_fedramp_api_key;
use crate::combined_auth::compose_auth;
use crate::gcp::GcpModelProvider;
use crate::models_endpoint::OpenAiModelsEndpoint;
use crate::workspace_routing::WorkspaceRoutingContext;
/// Current app-visible account state for a model provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountState {
pub account: Option<ProviderAccount>,
pub requires_openai_auth: bool,
}
/// Outcome of a provider-owned attempt to recover from an authentication failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderUnauthorizedRecovery {
/// The provider has no provider-specific authentication recovery configured.
NotConfigured,
/// The provider recovered its authentication state and the request can be retried.
Recovered,
}
/// User-facing lifecycle messages for provider-owned authentication recovery.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderAuthRecoveryMessages {
pub started: &'static str,
pub succeeded: &'static str,
}
/// Error returned when a provider cannot construct its app-visible account state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAccountError {
MissingChatgptAccountDetails,
UnsupportedBedrockApiKeyAuth,
}
impl fmt::Display for ProviderAccountError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingChatgptAccountDetails => {
write!(f, "plan type is required for chatgpt authentication")
}
Self::UnsupportedBedrockApiKeyAuth => {
write!(
f,
"Bedrock API key auth is only supported by the Amazon Bedrock model provider"
)
}
}
}
}
impl std::error::Error for ProviderAccountError {}
pub type ProviderAccountResult = std::result::Result<ProviderAccountState, ProviderAccountError>;
/// Default model used for automatic approval review when a provider does not
/// require a backend-specific model ID.
pub const DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL: &str = "codex-auto-review";
const API_KEY_APPROVAL_REVIEW_PREFERRED_MODEL: &str = "gpt-5.6-luna";
const FEDRAMP_API_BASE_URL: &str = "https://gov.api.openai.com/v1";
/// Default model used for memory extraction when a provider does not require a
/// backend-specific model ID.
pub const DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL: &str = "gpt-6-luna";
/// Default model used for memory consolidation when a provider does not require
/// a backend-specific model ID.
pub const DEFAULT_MEMORY_CONSOLIDATION_PREFERRED_MODEL: &str = "gpt-6.1-terra";
/// Runtime provider abstraction used by model execution.
///
/// Implementations own provider-specific behavior for a model backend. The
/// `ModelProviderInfo` returned by `info` is the serialized/configured provider
/// metadata used by the default OpenAI-compatible implementation.
///
/// OpenAI-compatible catalog caches use header auth's account ID instead of auth
/// headers when available. Missing IDs and explicit provider API key, bearer, or
/// command auth retain credential-sensitive keys. Provider settings remain in the key.
/// Hosts using header auth must isolate model managers and cache storage by user and
/// catalog-varying request context, such as country: an account ID may identify a
/// workspace shared by multiple users. Request authentication, credential refresh,
/// and permission checks are unchanged.
pub trait ModelProvider: fmt::Debug + Send + Sync {
/// Returns the configured provider metadata.
fn info(&self) -> &ModelProviderInfo;
/// Returns whether the resolved Responses provider may receive internal tool metadata.
fn include_internal_metadata(&self, provider: &Provider) -> bool {
self.info().include_internal_metadata
|| url::Url::parse(&provider.base_url).ok().is_some_and(|url| {
url.scheme() == "https"
&& url.host_str().is_some_and(|host| {
host == "api.openai.com" || codex_http_client::is_allowed_chatgpt_host(host)
})
})
}
/// Returns the provider-owned capability upper bounds.
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities::default()
}
/// Returns the preferred model used for automatic approval review.
///
/// Providers that require backend-specific model IDs should override this.
fn approval_review_preferred_model(&self) -> &'static str {
DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
}
/// Returns the preferred model used for memory extraction.
///
/// Providers that require backend-specific model IDs should override this.
fn memory_extraction_preferred_model(&self) -> &'static str {
DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL
}
/// Returns the preferred model used for memory consolidation.
///
/// Providers that require backend-specific model IDs should override this.
fn memory_consolidation_preferred_model(&self) -> &'static str {
DEFAULT_MEMORY_CONSOLIDATION_PREFERRED_MODEL
}
/// Returns whether requests made through this provider should include attestation.
fn supports_attestation(&self) -> bool {
false
}
/// Returns the provider-scoped auth manager, when this provider uses one.
///
/// TODO(celia-oai): Make auth manager access internal to this crate so callers
/// resolve provider-specific auth only through `ModelProvider`. We first need
/// to think through whether Codex should have a unified provider-specific auth
/// manager throughout the codebase; that is a larger refactor than this change.
fn auth_manager(&self) -> Option<Arc<AuthManager>>;
/// Runtime authentication selected for this provider's inference destination.
fn runtime_auth(&self) -> Option<&(Arc<RuntimeAuthManager>, RuntimeAuthMode)> {
None
}
/// Returns the gateway credential manager shared with inference and model discovery.
/// Hosts use this handle for explicit login; configured setup failures remain errors.
fn gateway_auth_manager(&self) -> std::io::Result<Option<Arc<GatewayAuthManager>>> {
Ok(None)
}
/// Returns whether this transport failure can be recovered by provider-scoped authentication.
///
/// The default preserves existing unauthorized-response handling. Providers with other
/// authentication failure shapes may recognize additional response or request-signing errors.
fn is_recoverable_auth_error(&self, error: &TransportError) -> bool {
matches!(
error,
TransportError::Http { status, .. } if *status == http::StatusCode::UNAUTHORIZED
)
}
/// Returns lifecycle messages when provider-owned authentication recovery is active.
fn auth_recovery_messages(&self) -> Option<ProviderAuthRecoveryMessages> {
None
}
/// Attempts provider-owned authentication recovery before using the auth manager.
fn recover_from_unauthorized(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ProviderUnauthorizedRecovery>> {
Box::pin(async { Ok(ProviderUnauthorizedRecovery::NotConfigured) })
}
/// Returns the current provider-scoped auth value, if one is configured.
fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>>;
/// Returns the current app-visible account state for this provider.
fn account_state(&self) -> ProviderAccountResult;
/// Maps an API client error into the provider's user-facing error representation.
fn map_api_error(&self, error: ApiError) -> CodexErr {
codex_api::map_api_error(error)
}
/// Returns provider configuration adapted for the API client.
fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Provider>> {
Box::pin(async move {
let auth = self.auth().await;
self.api_provider_for_auth(auth.as_ref())
})
}
/// Returns the endpoint and authentication for one API client.
/// Providers with credential-dependent routing should resolve both from the same auth read.
fn api_provider_and_auth(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<(Provider, SharedAuthProvider)>>
{
Box::pin(async move { Ok((self.api_provider().await?, self.api_auth().await?)) })
}
/// Resolves routing for Responses HTTP, compaction, and WebSocket handshakes.
fn responses_api_provider<'a>(
&'a self,
routing_context: &'a WorkspaceRoutingContext,
) -> ModelProviderFuture<'a, codex_protocol::error::Result<ResolvedResponsesProvider>> {
Box::pin(async move {
let provider = self.api_provider().await?;
let auth = self.auth().await;
self.route_responses_api_provider(provider, auth.as_ref(), routing_context)
.await
})
}
/// Applies workspace routing using the credentials already selected for this request.
#[expect(
clippy::await_holding_invalid_type,
reason = "serialize discovery and the session's first successful routing transition"
)]
fn route_responses_api_provider<'a>(
&'a self,
mut provider: Provider,
auth: Option<&'a CodexAuth>,
routing_context: &'a WorkspaceRoutingContext,
) -> ModelProviderFuture<'a, codex_protocol::error::Result<ResolvedResponsesProvider>> {
Box::pin(async move {
let mut redirect_policy = ClientRedirectPolicy::Default;
if provider_uses_first_party_auth_path(self.info())
&& self.info().supports_codex_backend_routes()
&& let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth())
&& let Some(auth_manager) = self.auth_manager()
{
let mut previously_routed = routing_context.previously_routed.lock().await;
if let Some(routing) = auth_manager
.workspace_routing(
auth,
WorkspaceRoutingRequest {
provider_base_url: provider.base_url.clone(),
chatgpt_base_url: routing_context.chatgpt_base_url.clone(),
previously_routed: *previously_routed,
session: routing_context.session.clone(),
},
)
.await
.map_err(|error| match error.downcast::<RefreshTokenFailedError>() {
Ok(rejection) => CodexErr::RefreshTokenFailed(rejection),
Err(error) => error.into(),
})?
{
crate::workspace_routing::apply_workspace_routing(&mut provider, routing)?;
redirect_policy = ClientRedirectPolicy::Reject;
*previously_routed = true;
}
}
Ok(ResolvedResponsesProvider {
provider,
redirect_policy,
})
})
}
/// Adapt routing from the same frozen credential selected for the request.
fn api_provider_for_auth(
&self,
auth: Option<&CodexAuth>,
) -> codex_protocol::error::Result<Provider> {
resolve_api_provider(self.info(), auth)
}
/// Returns the provider base URL that will be used at request time.
fn runtime_base_url(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<Option<String>>> {
Box::pin(async { Ok(self.info().base_url.clone()) })
}
/// Returns the auth provider used to attach request credentials.
fn api_auth(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<SharedAuthProvider>> {
Box::pin(async move {
let auth = self.auth().await;
resolve_provider_auth(auth.as_ref(), self.info())
})
}
/// Adds provider-owned credentials, such as gateway OAuth, to already selected primary auth.
fn compose_auth(
&self,
resolved: ResolvedProviderAuth,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ResolvedProviderAuth>> {
Box::pin(async move { Ok(resolved) })
}
/// Returns request credentials, optionally scoped to a Codex session task.
fn api_auth_for_scope(
&self,
scope: ProviderAuthScope,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ResolvedProviderAuth>> {
Box::pin(async move {
if !provider_uses_first_party_auth_path(self.info()) {
return self.api_auth().await.map(ResolvedProviderAuth::new);
}
let auth = self.auth().await;
resolve_provider_auth_for_scope(self.auth_manager(), auth.as_ref(), self.info(), scope)
.await
})
}
/// Resolve auth together with metadata needed to route and admit this request.
fn request_auth(
&self,
scope: Option<ProviderAuthScope>,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ResolvedProviderAuth>> {
match scope {
Some(scope) => self.api_auth_for_scope(scope),
None => Box::pin(async { self.api_auth().await.map(ResolvedProviderAuth::new) }),
}
}
/// Creates the model manager implementation appropriate for this provider.
fn models_manager(
&self,
codex_home: PathBuf,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager;
/// Creates a model manager with caching disabled.
///
/// Providers that fetch model catalogs should override this method. The default uses an
/// authoritative in-memory catalog so hosted callers cannot accidentally write to disk.
fn models_manager_without_cache(
&self,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager {
let origin = if config_model_catalog.is_some() {
codex_models_manager::ModelOrigin::ExplicitConfig
} else {
codex_models_manager::ModelOrigin::Bundled
};
let model_catalog = config_model_catalog
.or_else(|| codex_models_manager::bundled_models_response().ok())
.unwrap_or_default();
Arc::new(StaticModelsManager::new(
self.auth_manager(),
model_catalog,
origin,
))
}
/// Creates a model manager that can use a caller-provided cache for remote catalogs.
///
/// Providers with remote catalogs should override this method. The default preserves the
/// authoritative catalog returned by [`ModelProvider::models_manager_without_cache`] and does
/// not consult `cache`. Implementations should likewise ignore the cache when
/// `config_model_catalog` supplies an authoritative static catalog.
fn models_manager_with_cache(
&self,
config_model_catalog: Option<ModelsResponse>,
cache: Arc<dyn ModelsCache>,
) -> SharedModelsManager {
drop(cache);
self.models_manager_without_cache(config_model_catalog)
}
}
pub type ModelProviderFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Shared runtime model provider handle.
pub type SharedModelProvider = Arc<dyn ModelProvider>;
/// Resolve credential-dependent defaults without changing configured endpoints.
pub fn resolve_api_provider(
info: &ModelProviderInfo,
auth: Option<&CodexAuth>,
) -> codex_protocol::error::Result<Provider> {
let mut provider = info.to_api_provider(auth.map(CodexAuth::auth_mode))?;
if info.base_url.is_none() && uses_fedramp_api_key(auth, info)? {
provider.base_url = FEDRAMP_API_BASE_URL.to_string();
}
Ok(provider)
}
pub fn provider_uses_first_party_auth_path(provider: &ModelProviderInfo) -> bool {
provider.requires_openai_auth
&& provider.env_key.is_none()
&& provider.experimental_bearer_token.is_none()
&& provider.auth.is_none()
&& provider.aws.is_none()
}
/// Creates the default model provider for configured provider metadata.
/// Harnesses with runtime identity use [`crate::ProviderAuthContext::model_provider`].
pub fn create_model_provider(
provider_info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
) -> SharedModelProvider {
create_model_provider_with_runtime(provider_info, auth_manager, /*runtime_auth*/ None)
}
pub(crate) fn create_model_provider_with_runtime(
provider_info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
runtime_auth: Option<(Arc<RuntimeAuthManager>, RuntimeAuthMode)>,
) -> SharedModelProvider {
if provider_info.is_amazon_bedrock() {
return Arc::new(AmazonBedrockModelProvider::new(provider_info, auth_manager));
}
if provider_info.is_gcp() {
return Arc::new(GcpModelProvider::new(provider_info, auth_manager));
}
Arc::new(ConfiguredModelProvider::new(
provider_info,
auth_manager,
runtime_auth,
))
}
/// Runtime model provider that orchestrates primary and gateway credentials.
#[derive(Clone, Debug)]
struct ConfiguredModelProvider {
info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
// Construct eagerly; report setup failures when auth is requested because the factory is infallible.
gateway_auth_manager: Option<Result<Arc<GatewayAuthManager>, String>>,
runtime_auth: Option<(Arc<RuntimeAuthManager>, RuntimeAuthMode)>,
catalog_runtime_auth: Option<(Arc<RuntimeAuthManager>, RuntimeAuthMode)>,
}
enum ModelsCacheConfig {
Disk { codex_home: PathBuf },
Disabled,
Custom(Arc<dyn ModelsCache>),
}
impl ConfiguredModelProvider {
fn new(
info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
runtime_auth: Option<(Arc<RuntimeAuthManager>, RuntimeAuthMode)>,
) -> Self {
let gateway_auth_manager = info.gateway_oauth.as_ref().map(|config| {
info.validate()?;
let manager = auth_manager
.as_ref()
.ok_or_else(|| "gateway_oauth requires auth runtime configuration".to_string())?;
crate::shared_state::process_shared_state()
.gateway_auth(config, &manager.runtime_config())
.map_err(|_| "failed to create provider OAuth HTTP client".to_string())
});
let auth_manager = auth_manager_for_provider(auth_manager, &info);
let runtime_for_destination = |destination: Option<&str>| {
runtime_auth.clone().filter(|_| {
destination.is_none_or(|url| {
codex_http_client::classify_openai_api_url(url)
!= codex_http_client::OpenAiDestination::Other
})
})
};
let inference_runtime_auth = runtime_for_destination(info.base_url.as_deref());
// Catalog overrides are absolute URLs and can have a different origin from inference.
let catalog_runtime_auth = runtime_for_destination(
info.model_catalog_url
.as_deref()
.map(String::as_str)
.or(info.base_url.as_deref()),
);
Self {
info,
auth_manager,
gateway_auth_manager,
runtime_auth: inference_runtime_auth,
catalog_runtime_auth,
}
}
async fn resolve_auth(
&self,
scope: Option<ProviderAuthScope>,
) -> codex_protocol::error::Result<ResolvedProviderAuth> {
let resolved = if let Some((runtime, mode)) = &self.runtime_auth {
crate::validate_runtime_destination(self.info.base_url.as_deref())?;
crate::validate_runtime_provider(&self.info, self.auth_manager.as_ref(), runtime)?;
let headers = self.info.build_header_map()?;
let selected = crate::runtime_auth::select_runtime_auth(runtime, &headers)
.await
.map_err(|error| self.map_api_error(error.into()))?;
crate::validate_runtime_selectors(&headers, selected.runtime().actor().info())?;
ResolvedProviderAuth {
auth: selected.clone().into_auth_provider(*mode),
agent_identity_telemetry: None,
runtime: Some(selected),
}
} else {
let auth = self.auth().await;
match scope {
Some(scope) if provider_uses_first_party_auth_path(&self.info) => {
resolve_provider_auth_for_scope(
self.auth_manager.clone(),
auth.as_ref(),
&self.info,
scope,
)
.await?
}
_ => ResolvedProviderAuth::new(resolve_provider_auth(auth.as_ref(), &self.info)?),
}
};
self.compose_auth(resolved).await
}
fn create_models_manager(
&self,
config_model_catalog: Option<ModelsResponse>,
cache: ModelsCacheConfig,
) -> SharedModelsManager {
if let Some(model_catalog) = config_model_catalog {
return Arc::new(StaticModelsManager::new(
self.auth_manager.clone(),
model_catalog,
codex_models_manager::ModelOrigin::ExplicitConfig,
));
}
let endpoint = Arc::new(
OpenAiModelsEndpoint::new(
self.info.clone(),
self.auth_manager.clone(),
self.gateway_auth_manager.clone(),
)
.with_runtime_auth(self.catalog_runtime_auth.clone()),
);
let auth_manager = self.auth_manager.clone();
let manager = match cache {
ModelsCacheConfig::Disk { codex_home } => {
OpenAiModelsManager::new(codex_home, endpoint, auth_manager)
}
ModelsCacheConfig::Disabled => {
OpenAiModelsManager::new_without_cache(endpoint, auth_manager)
}
ModelsCacheConfig::Custom(cache) => {
OpenAiModelsManager::new_with_cache(cache, endpoint, auth_manager)
}
};
match &self.info.model_catalog_url {
Some(_) => Arc::new(manager.with_provider_catalog()),
None => Arc::new(manager),
}
}
}
impl ModelProvider for ConfiguredModelProvider {
fn info(&self) -> &ModelProviderInfo {
&self.info
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities::from_config(&self.info)
}
fn approval_review_preferred_model(&self) -> &'static str {
if self
.auth_manager
.as_ref()
.and_then(|auth_manager| auth_manager.auth_cached())
.is_some_and(|auth| auth.is_api_key_auth())
{
API_KEY_APPROVAL_REVIEW_PREFERRED_MODEL
} else {
DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
}
}
fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
fn gateway_auth_manager(&self) -> std::io::Result<Option<Arc<GatewayAuthManager>>> {
self.gateway_auth_manager
.clone()
.transpose()
.map_err(std::io::Error::other)
}
fn supports_attestation(&self) -> bool {
self.auth_manager
.as_ref()
.and_then(|auth_manager| auth_manager.auth_cached())
.is_some_and(|auth| auth.is_chatgpt_auth())
}
fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>> {
Box::pin(async move {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
None => None,
}
})
}
fn runtime_auth(&self) -> Option<&(Arc<RuntimeAuthManager>, RuntimeAuthMode)> {
self.runtime_auth.as_ref()
}
fn request_auth(
&self,
scope: Option<ProviderAuthScope>,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ResolvedProviderAuth>> {
Box::pin(self.resolve_auth(scope))
}
fn api_auth(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<SharedAuthProvider>> {
Box::pin(async move {
Ok(self.resolve_auth(/*scope*/ None).await?.auth)
})
}
fn api_provider_and_auth(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<(Provider, SharedAuthProvider)>>
{
Box::pin(async move {
let auth = self.auth().await;
let provider = self.api_provider_for_auth(auth.as_ref())?;
let primary = resolve_provider_auth(auth.as_ref(), &self.info)?;
let auth = self
.compose_auth(ResolvedProviderAuth::new(primary))
.await?
.auth;
Ok((provider, auth))
})
}
fn compose_auth(
&self,
resolved: ResolvedProviderAuth,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ResolvedProviderAuth>> {
Box::pin(compose_auth(
&self.info,
self.gateway_auth_manager.as_ref(),
resolved,
))
}
fn api_auth_for_scope(
&self,
scope: ProviderAuthScope,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ResolvedProviderAuth>> {
Box::pin(self.resolve_auth(Some(scope)))
}
fn account_state(&self) -> ProviderAccountResult {
let account = if self.info.requires_openai_auth {
self.auth_manager
.as_ref()
.and_then(|auth_manager| {
let auth = auth_manager.auth_cached()?;
if auth_manager.refresh_failure_for_auth(&auth).is_some() {
return None;
}
if matches!(auth, CodexAuth::Headers(_)) {
return None;
}
Some(auth)
})
.map(|auth| match &auth {
CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey),
CodexAuth::BedrockApiKey(_) | CodexAuth::BedrockAccessKeys(_) => {
Err(ProviderAccountError::UnsupportedBedrockApiKeyAuth)
}
CodexAuth::Chatgpt(_)
| CodexAuth::ChatgptAuthTokens(_)
| CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => {
let email = auth.get_account_email();
let plan_type = auth.account_plan_type();
plan_type
.map(|plan_type| ProviderAccount::Chatgpt { email, plan_type })
.ok_or(ProviderAccountError::MissingChatgptAccountDetails)
}
})
.transpose()?
} else {
None
};
Ok(ProviderAccountState {
account,
requires_openai_auth: self.info.requires_openai_auth,
})
}
fn models_manager(
&self,
codex_home: PathBuf,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager {
self.create_models_manager(config_model_catalog, ModelsCacheConfig::Disk { codex_home })
}
fn models_manager_without_cache(
&self,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager {
self.create_models_manager(config_model_catalog, ModelsCacheConfig::Disabled)
}
fn models_manager_with_cache(
&self,
config_model_catalog: Option<ModelsResponse>,
cache: Arc<dyn ModelsCache>,
) -> SharedModelsManager {
self.create_models_manager(config_model_catalog, ModelsCacheConfig::Custom(cache))
}
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::num::NonZeroU64;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::task::Context;
use std::task::Waker;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::auth::AgentIdentityAuthPolicy;
use codex_login::auth::BedrockApiKeyAuth;
use codex_model_provider_info::AwsAuthRefreshConfig;
use codex_model_provider_info::AwsCredentialExportConfig;
use codex_model_provider_info::ModelProviderAwsAuthInfo;
use codex_model_provider_info::WireApi;
use codex_model_provider_info::create_oss_provider_with_base_url;
use codex_models_manager::ModelsManagerConfig;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::SessionSource;
use codex_utils_redacted_string::RedactedString;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
use crate::auth::AgentIdentitySessionFallback;
use crate::shared_state::process_shared_state;
fn provider_info_with_command_auth() -> ModelProviderInfo {
ModelProviderInfo {
auth: Some(ModelProviderAuthInfo {
command: "print-token".to_string(),
args: Vec::new(),
timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"),
refresh_interval_ms: 300_000,
cwd: std::env::current_dir()
.expect("current dir should be available")
.try_into()
.expect("current dir should be absolute"),
}),
requires_openai_auth: false,
..ModelProviderInfo::create_openai_provider(/*base_url*/ None)
}
}
fn test_codex_home() -> std::path::PathBuf {
std::env::temp_dir().join(format!("codex-model-provider-test-{}", std::process::id()))
}
fn provider_for(base_url: String) -> ModelProviderInfo {
ModelProviderInfo {
name: "mock".into(),
base_url: Some(base_url),
model_catalog_url: None,
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
gateway_oauth: None,
aws: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(5_000),
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
credential_file: None,
include_internal_metadata: false,
}
}
fn remote_model(slug: &str) -> ModelInfo {
serde_json::from_value(json!({
"slug": slug,
"display_name": slug,
"description": null,
"default_reasoning_level": "medium",
"supported_reasoning_levels": [],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 0,
"upgrade": null,
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {"mode": "bytes", "limit": 10_000},
"supports_image_detail_original": false,
"context_window": 272_000,
"max_context_window": 272_000,
"experimental_supported_tools": [],
}))
.expect("valid model")
}
fn bedrock_api_key_auth() -> CodexAuth {
CodexAuth::BedrockApiKey(BedrockApiKeyAuth {
api_key: "bedrock-api-key-test".to_string(),
region: "us-east-1".to_string(),
})
}
#[tokio::test]
async fn scoped_auth_ignores_scope_for_non_openai_provider() {
let provider = create_model_provider(
create_oss_provider_with_base_url("http://localhost:11434/v1", WireApi::Responses),
/*auth_manager*/ None,
);
let auth = provider
.api_auth_for_scope(ProviderAuthScope {
agent_identity_policy: AgentIdentityAuthPolicy::JwtOnly,
session_source: SessionSource::Cli,
agent_identity_session_fallback: AgentIdentitySessionFallback::default(),
})
.await
.expect("auth should resolve");
assert!(auth.auth.to_auth_headers().unwrap().is_empty());
}
#[test]
fn openai_provider_enables_remote_compaction() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
/*auth_manager*/ None,
);
assert_eq!(
provider.capabilities(),
ProviderCapabilities {
remote_compaction: RemoteCompactionSupport::V2,
..ProviderCapabilities::default()
}
);
}
#[test]
fn configured_provider_remote_compaction_matches_provider_support() {
let cases = [
(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
RemoteCompactionSupport::V2,
),
(
ModelProviderInfo {
name: "Azure".to_string(),
base_url: Some("https://example.com/openai".to_string()),
..ModelProviderInfo::default()
},
RemoteCompactionSupport::V2,
),
(
ModelProviderInfo {
name: "Custom".to_string(),
base_url: Some("https://example.openai.azure.com/openai/v1".to_string()),
..ModelProviderInfo::default()
},
RemoteCompactionSupport::V2,
),
(
provider_for("https://example.test/v1".to_string()),
RemoteCompactionSupport::Unsupported,
),
];
for (provider_info, expected) in cases {
let provider = create_model_provider(provider_info, /*auth_manager*/ None);
assert_eq!(provider.capabilities().remote_compaction, expected);
}
}
#[test]
fn configured_provider_uses_default_approval_review_preferred_model() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
/*auth_manager*/ None,
);
assert_eq!(
provider.approval_review_preferred_model(),
DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
);
}
#[test]
fn configured_provider_uses_luna_for_approval_review_with_api_key_auth() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"openai-api-key",
))),
);
assert_eq!(provider.approval_review_preferred_model(), "gpt-5.6-luna");
}
#[test]
fn configured_provider_uses_default_approval_review_model_with_chatgpt_auth() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(AuthManager::from_auth_for_testing(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
)),
);
assert_eq!(
provider.approval_review_preferred_model(),
DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL
);
}
struct RotatingAuth(AtomicUsize);
impl codex_login::ExternalAuth for RotatingAuth {
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async move {
let key = if self.0.fetch_add(1, Ordering::SeqCst).is_multiple_of(2) {
"sk-proj-fedramp-synthetic-test-key"
} else {
"commercial-key"
};
Ok(CodexAuth::from_api_key(key))
})
}
fn refresh(
&self,
_context: codex_login::ExternalAuthRefreshContext,
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
self.resolve()
}
}
#[tokio::test]
async fn configured_provider_resolves_default_and_explicit_endpoints() {
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())
);
assert_eq!(
provider.api_provider_and_auth().await.unwrap().0.base_url,
"https://example.test/v1"
);
let home = tempfile::tempdir().unwrap();
let auth = AuthManager::from_auth_for_testing_with_home(
CodexAuth::from_api_key("initial-key"),
home.path().to_path_buf(),
);
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(auth.clone()),
);
for (api_key, expected_base_url) in [
("legacy-key", "https://api.openai.com/v1"),
(
"sk-proj-fedramp-synthetic-test-key",
"https://gov.api.openai.com/v1",
),
("commercial-key", "https://api.openai.com/v1"),
] {
codex_login::login_with_api_key(
home.path(),
api_key,
codex_login::AuthCredentialsStoreMode::File,
codex_login::AuthKeyringBackendKind::default(),
)
.unwrap();
auth.reload().await;
let (api_provider, api_auth) = provider.api_provider_and_auth().await.unwrap();
assert_eq!(api_provider.base_url, expected_base_url);
let headers = api_auth.to_auth_headers().unwrap();
assert_eq!(
headers[http::header::AUTHORIZATION].to_str().unwrap(),
format!("Bearer {api_key}")
);
}
assert_eq!(provider.info().base_url, None);
let rotating_auth = Arc::new(RotatingAuth(AtomicUsize::new(0)));
auth.set_external_auth(rotating_auth.clone()).await.unwrap();
for (api_key, expected_base_url, expected_fedramp_header) in [
("commercial-key", "https://api.openai.com/v1", None),
(
"sk-proj-fedramp-synthetic-test-key",
"https://gov.api.openai.com/v1",
Some("true"),
),
("commercial-key", "https://api.openai.com/v1", None),
] {
let reads_before = rotating_auth.0.load(Ordering::SeqCst);
let (api_provider, api_auth) = provider.api_provider_and_auth().await.unwrap();
assert_eq!(rotating_auth.0.load(Ordering::SeqCst), reads_before + 1);
let headers = api_auth.to_auth_headers().unwrap();
let expected_authorization = format!("Bearer {api_key}");
assert_eq!(
(
api_provider.base_url.as_str(),
headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
headers
.get("X-OpenAI-Fedramp")
.and_then(|value| value.to_str().ok()),
),
(
expected_base_url,
Some(expected_authorization.as_str()),
expected_fedramp_header,
)
);
}
}
#[test]
fn create_model_provider_builds_command_auth_manager_without_base_manager() {
let provider = create_model_provider(
provider_info_with_command_auth(),
/*auth_manager*/ None,
);
let auth_manager = provider
.auth_manager()
.expect("command auth provider should have an auth manager");
assert!(auth_manager.has_external_auth());
}
#[test]
fn create_model_provider_does_not_use_openai_auth_manager_for_amazon_bedrock_provider() {
let provider = create_model_provider(
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
profile: Some("codex-bedrock".to_string()),
region: None,
credential_export: None,
auth_refresh: None,
})),
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"openai-api-key",
))),
);
assert!(provider.auth_manager().is_none());
}
#[tokio::test]
async fn shared_bedrock_auth_refresh_is_reused_only_for_matching_configuration() {
const TEST_NAME: &str = "provider::tests::shared_bedrock_auth_refresh_is_reused_only_for_matching_configuration";
const HELPER_ARG: &str = "CODEX_BEDROCK_SHARED_AUTH_REFRESH_COMMAND";
const SUBPROCESS_ARG: &str = "CODEX_BEDROCK_SHARED_AUTH_REFRESH_SUBPROCESS";
let arguments = std::env::args().collect::<Vec<_>>();
if let Some(index) = arguments.iter().position(|argument| argument == HELPER_ARG) {
let counter = &arguments[index + 2];
std::thread::sleep(std::time::Duration::from_millis(50));
let mut counter = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(counter)
.expect("refresh invocation counter should open");
std::io::Write::write_all(&mut counter, b"1").expect("write invocation counter");
return;
}
let counter = std::env::temp_dir().join(format!("bedrock-refresh-{}", std::process::id()));
if !arguments.iter().any(|argument| argument == SUBPROCESS_ARG) {
std::fs::create_dir(&counter).expect("AWS command directory should be created");
let executable = std::env::current_exe().expect("test executable should be available");
let aws = counter.join(format!("aws{}", std::env::consts::EXE_SUFFIX));
std::fs::hard_link(&executable, &aws)
.or_else(|_| codex_utils_cargo_bin::copy_executable(&executable, &aws))
.expect("test executable should be installed as aws");
let existing_path = std::env::var_os("PATH").unwrap_or_default();
let path = std::env::join_paths(
std::iter::once(counter.clone()).chain(std::env::split_paths(&existing_path)),
)
.expect("test executable PATH should be valid");
let output = tokio::process::Command::new(executable)
.args(["--exact", TEST_NAME, "--skip", SUBPROCESS_ARG])
.env("PATH", path)
.env_remove("AWS_ACCESS_KEY_ID")
.env_remove("AWS_SECRET_ACCESS_KEY")
.output()
.await
.expect("isolated AWS refresh test should run");
std::fs::remove_dir_all(&counter).expect("AWS command directory should be removed");
assert!(output.status.success(), "{output:?}");
return;
}
let _ = std::fs::remove_file(&counter);
let aws = ModelProviderAwsAuthInfo {
profile: Some("codex-bedrock".to_string()),
region: Some("us-west-2".to_string()),
credential_export: None,
auth_refresh: Some(AwsAuthRefreshConfig {
command: "aws".to_string(),
args: Vec::from(
[
"--exact",
TEST_NAME,
"--skip",
HELPER_ARG,
"--skip",
counter.to_str().expect("counter path should be UTF-8"),
]
.map(RedactedString::from),
),
timeout_ms: NonZeroU64::new(10_000).expect("timeout should be non-zero"),
}),
};
let provider_info = ModelProviderInfo::create_amazon_bedrock_provider(Some(aws.clone()));
let first = create_model_provider(
provider_info.clone(),
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"openai-api-key",
))),
);
let second = create_model_provider(provider_info.clone(), /*auth_manager*/ None);
let shared_state = process_shared_state();
let shared_recovery = shared_state
.aws_auth_recovery(&aws)
.expect("test provider should have refresh configured");
assert_eq!(Arc::strong_count(&shared_recovery), 3);
assert!(first.auth_manager().is_none() && second.auth_manager().is_none());
for provider in [&first, &second] {
for (status, body, recoverable) in [
(http::StatusCode::UNAUTHORIZED, "ExpiredToken", true),
(http::StatusCode::FORBIDDEN, "InvalidClientTokenId", true),
(http::StatusCode::FORBIDDEN, "AccessDeniedException", false),
] {
let error = TransportError::Http {
retry_after: None,
status,
url: None,
headers: None,
body: Some(body.to_string()),
};
assert_eq!(provider.is_recoverable_auth_error(&error), recoverable);
}
}
let mut invalid_aws = aws.clone();
invalid_aws.auth_refresh.as_mut().expect("refresh").command = "not-aws".into();
let invalid_provider = create_model_provider(
ModelProviderInfo::create_amazon_bedrock_provider(Some(invalid_aws)),
/*auth_manager*/ None,
);
let error = invalid_provider
.recover_from_unauthorized()
.await
.expect_err("non-aws command should be rejected");
assert_eq!(error.retry_delay(/*retry_count*/ 1), None);
assert_eq!(error.to_string(), "AWS auth refresh command must be `aws`");
let (first_result, second_result) = tokio::join!(
first.recover_from_unauthorized(),
second.recover_from_unauthorized()
);
assert_eq!(
[first_result, second_result].map(|result| result.expect("provider should recover")),
[ProviderUnauthorizedRecovery::Recovered; 2]
);
let read_counter = || std::fs::read_to_string(&counter).expect("read counter");
assert_eq!(read_counter(), "1");
assert_eq!(
first
.recover_from_unauthorized()
.await
.expect("later generation should recover"),
ProviderUnauthorizedRecovery::Recovered
);
assert_eq!(read_counter(), "11");
let fixture = tempfile::tempdir().expect("export fixture should be created");
let export_counter = fixture.path().join("exports");
let export_gate = fixture.path().join("release");
#[cfg(unix)]
let (command, mut args) = (
"sh".to_string(),
vec![
"-c".to_string(),
r#"printf '1\n' >> "$1"
while [ ! -e "$2" ]; do :; done
printf '%s\n' '{"AccessKeyId":"exported","SecretAccessKey":"secret"}'
"#
.to_string(),
"export-credentials".to_string(),
],
);
#[cfg(windows)]
let (command, mut args) = {
let script = fixture.path().join("export.cmd");
std::fs::write(
&script,
concat!(
"@echo off\r\n>> \"%~1\" echo 1\r\n",
":wait\r\nif not exist \"%~2\" goto wait\r\n",
"echo {\"AccessKeyId\":\"exported\",\"SecretAccessKey\":\"secret\"}\r\n",
),
)
.expect("export script should be written");
(
"cmd.exe".to_string(),
vec![
"/D".to_string(),
"/Q".to_string(),
"/C".to_string(),
script.to_string_lossy().into_owned(),
],
)
};
args.extend(
[&export_counter, &export_gate].map(|path| path.to_string_lossy().into_owned()),
);
let provider_info =
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
profile: None,
credential_export: Some(AwsCredentialExportConfig {
command,
args: args.into_iter().map(RedactedString::from).collect(),
timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"),
}),
..aws.clone()
}));
let first_export = create_model_provider(provider_info.clone(), /*auth_manager*/ None);
let second_export = create_model_provider(provider_info, /*auth_manager*/ None);
let first_refresh = first_export.recover_from_unauthorized();
let second_refresh = second_export.recover_from_unauthorized();
tokio::pin!(first_refresh, second_refresh);
tokio::time::timeout(std::time::Duration::from_secs(10), async {
tokio::select! {
result = &mut first_refresh => panic!("export should wait for its gate: {result:?}"),
() = async {
while !export_counter.exists() {
tokio::task::yield_now().await;
}
} => {}
}
}).await.expect("export should start after login");
assert_eq!(read_counter(), "111");
{
// Queue another recovery after login finishes, while export still holds the lock.
let mut context = Context::from_waker(Waker::noop());
assert!(second_refresh.as_mut().poll(&mut context).is_pending());
}
std::fs::write(&export_gate, []).expect("export gate should open");
let (first_result, second_result) = tokio::join!(first_refresh, second_refresh);
assert_eq!(
[first_result, second_result].map(|result| result.expect("provider should recover")),
[ProviderUnauthorizedRecovery::Recovered; 2]
);
assert_eq!(read_counter(), "111");
assert_eq!(
std::fs::read_to_string(export_counter)
.expect("read export counter")
.lines()
.collect::<Vec<_>>(),
vec!["1"]
);
std::fs::remove_file(&counter).expect("refresh invocation counter should be removed");
let different_profile = ModelProviderAwsAuthInfo {
profile: Some("another-bedrock-profile".to_string()),
..aws.clone()
};
let other_recovery = shared_state
.aws_auth_recovery(&different_profile)
.expect("test provider should have refresh configured");
assert!(!Arc::ptr_eq(&shared_recovery, &other_recovery));
let released_recovery = Arc::downgrade(&shared_recovery);
drop((first, second, shared_recovery));
assert!(released_recovery.upgrade().is_none());
assert!(shared_state.aws_auth_recovery(&aws).is_some());
}
#[tokio::test]
async fn create_model_provider_uses_managed_auth_for_amazon_bedrock_provider() {
let auth = bedrock_api_key_auth();
let provider = create_model_provider(
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
Some(AuthManager::from_auth_for_testing(auth.clone())),
);
assert_eq!(provider.auth().await, Some(auth));
}
#[test]
fn openai_provider_returns_unauthenticated_openai_account_state() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
/*auth_manager*/ None,
);
assert_eq!(
provider.account_state(),
Ok(ProviderAccountState {
account: None,
requires_openai_auth: true,
})
);
}
#[test]
fn openai_provider_returns_api_key_account_state() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"openai-api-key",
))),
);
assert_eq!(
provider.account_state(),
Ok(ProviderAccountState {
account: Some(ProviderAccount::ApiKey),
requires_openai_auth: true,
})
);
}
#[test]
fn openai_provider_returns_chatgpt_account_state_without_email() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(AuthManager::from_auth_for_testing(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
)),
);
assert_eq!(
provider.account_state(),
Ok(ProviderAccountState {
account: Some(ProviderAccount::Chatgpt {
email: None,
plan_type: PlanType::Unknown,
}),
requires_openai_auth: true,
})
);
}
#[test]
fn openai_provider_rejects_bedrock_api_key_account_state() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(AuthManager::from_auth_for_testing(bedrock_api_key_auth())),
);
assert_eq!(
provider.account_state(),
Err(ProviderAccountError::UnsupportedBedrockApiKeyAuth)
);
}
#[test]
fn custom_non_openai_provider_returns_no_account_state() {
let provider = create_model_provider(
ModelProviderInfo {
name: "Custom".to_string(),
base_url: Some("http://localhost:1234/v1".to_string()),
wire_api: WireApi::Responses,
requires_openai_auth: false,
..Default::default()
},
/*auth_manager*/ None,
);
assert_eq!(
provider.account_state(),
Ok(ProviderAccountState {
account: None,
requires_openai_auth: false,
})
);
}
#[test]
fn amazon_bedrock_provider_returns_bedrock_account_state() {
let provider = create_model_provider(
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
/*auth_manager*/ None,
);
assert_eq!(
provider.account_state(),
Ok(ProviderAccountState {
account: Some(ProviderAccount::AmazonBedrock {
uses_codex_managed_credentials: false,
}),
requires_openai_auth: false,
})
);
}
#[tokio::test]
async fn amazon_bedrock_provider_creates_static_models_manager() {
let provider = create_model_provider(
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
/*auth_manager*/ None,
);
let manager =
provider.models_manager(test_codex_home(), /*config_model_catalog*/ None);
let uncached_manager =
provider.models_manager_without_cache(/*config_model_catalog*/ None);
let catalog = manager
.raw_model_catalog(
RefreshStrategy::Online,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.await;
let uncached_catalog = uncached_manager
.raw_model_catalog(
RefreshStrategy::Online,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.await;
assert_eq!(uncached_catalog, catalog);
for slug in [
"openai.gpt-6.1-sol",
"openai.gpt-6.1-terra",
"openai.gpt-6-sol",
"openai.gpt-6-luna",
"openai.gpt-5.6-sol",
"openai.gpt-6-astra",
] {
let model_info = manager
.get_model_info(
slug,
&ModelsManagerConfig {
model_context_window: Some(1_000_000),
..Default::default()
},
)
.await;
let mut expected_model_info = manager
.get_model_info(slug, &ModelsManagerConfig::default())
.await;
expected_model_info.context_window = Some(872_000);
assert_eq!(model_info, expected_model_info);
}
let models = catalog
.models
.iter()
.map(|model| (model.slug.as_str(), model.display_name.as_str()))
.collect::<Vec<_>>();
assert_eq!(
models,
vec![
("openai.gpt-6.1-sol", "GPT-6.1 Sol"),
("openai.gpt-6-astra", "GPT-6-Astra"),
("openai.gpt-6.1-terra", "GPT-6.1 Terra"),
("openai.gpt-6-sol", "GPT-6 Sol"),
("openai.gpt-6-luna", "GPT-6 Luna"),
("openai.gpt-5.6-sol", "GPT-5.6 Sol"),
("openai.gpt-5.6-terra", "GPT-5.6 Terra"),
("openai.gpt-5.6-luna", "GPT-5.6 Luna"),
("openai.gpt-5.5", "GPT-5.5"),
]
);
let available_models = manager
.list_models(
RefreshStrategy::Online,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.await;
assert_eq!(
available_models
.iter()
.map(|preset| preset.model.as_str())
.collect::<Vec<_>>(),
vec![
"openai.gpt-6.1-sol",
"openai.gpt-6-astra",
"openai.gpt-6.1-terra",
"openai.gpt-6-sol",
"openai.gpt-6-luna",
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
"openai.gpt-5.6-luna",
"openai.gpt-5.5",
]
);
let default_model = available_models
.iter()
.find(|preset| preset.is_default)
.expect("Bedrock catalog should have a default model");
assert_eq!(default_model.model, "openai.gpt-6.1-sol");
}
#[tokio::test]
async fn configured_bedrock_catalog_preserves_service_tiers() {
let mut configured_model = codex_models_manager::bundled_models_response()
.expect("bundled models should parse")
.models
.into_iter()
.find(|model| model.slug == "gpt-5.5")
.expect("bundled models should include GPT-5.5");
configured_model.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier {
id: "custom-tier".to_string(),
name: "Custom tier".to_string(),
description: "User-defined tier description.".to_string(),
}];
configured_model.default_service_tier = Some("custom-tier".to_string());
let configured_catalog = ModelsResponse {
models: vec![configured_model],
};
let mut expected = configured_catalog.clone();
expected.models[0].web_search_tool_type =
codex_protocol::openai_models::WebSearchToolType::Text;
let mut gov_provider = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
gov_provider.base_url =
Some("https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1".to_string());
for provider_info in [
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
ModelProviderInfo::create_amazon_bedrock_runtime_provider(/*aws*/ None),
gov_provider,
] {
let provider = create_model_provider(provider_info, /*auth_manager*/ None);
for manager in [
provider.models_manager(test_codex_home(), Some(configured_catalog.clone())),
provider.models_manager_without_cache(Some(configured_catalog.clone())),
] {
let catalog = manager
.raw_model_catalog(
RefreshStrategy::Online,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.await;
assert_eq!(catalog, expected);
}
}
}
#[tokio::test]
async fn configured_provider_models_manager_uses_provider_bearer_token() {
let server = MockServer::start().await;
let remote_models = vec![remote_model("provider-model")];
Mock::given(method("GET"))
.and(path("/models"))
.and(header_regex("Authorization", "Bearer provider-token"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_json(ModelsResponse {
models: remote_models.clone(),
}),
)
.expect(2)
.mount(&server)
.await;
let mut provider_info = provider_for(server.uri());
provider_info.experimental_bearer_token = Some("provider-token".into());
provider_info.model_catalog_url = Some(format!("{}/models", server.uri()).into());
provider_info.http_headers = Some(std::collections::HashMap::from([(
codex_login::default_client::RESIDENCY_HEADER_NAME.to_string(),
"us".into(),
)]));
for auth in [
None,
Some(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
] {
// Disabled discovery must ignore the catalog cached by the enabled run.
for enabled in [true, false] {
let provider = create_model_provider(
provider_info.clone(),
auth.clone().map(AuthManager::from_auth_for_testing),
);
let manager =
provider.models_manager(test_codex_home(), /*config_model_catalog*/ None);
manager.set_api_key_model_discovery_enabled(enabled);
let refresh_strategy = if enabled {
RefreshStrategy::Online
} else {
RefreshStrategy::Offline
};
let catalog = manager
.raw_model_catalog(
refresh_strategy,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.await;
assert_eq!(
catalog
.models
.iter()
.any(|model| model.slug == "provider-model"),
enabled
);
}
}
}
}
pub(crate) mod test_support {
use super::*;
/// Use an injected runtime with a local transport fixture. Production callers use the
/// destination-aware `ProviderAuthContext::model_provider` factory.
pub fn runtime_model_provider(
info: ModelProviderInfo,
credentials: Arc<AuthManager>,
runtime: (Arc<RuntimeAuthManager>, RuntimeAuthMode),
) -> SharedModelProvider {
let mut provider =
ConfiguredModelProvider::new(info, Some(credentials), Some(runtime.clone()));
provider.runtime_auth = Some(runtime);
Arc::new(provider)
}
}