Files
codex/codex-rs/model-provider/src/provider.rs
T
Celia ChenandGitHub e8d8080818 feat: let model providers own model discovery (#18950)
## Why

`codex-models-manager` had grown to own provider-specific concerns:
constructing OpenAI-compatible `/models` requests, resolving provider
auth, emitting request telemetry, and deciding how provider catalogs
should be sourced. That made the manager harder to reuse for providers
whose model catalog is not fetched from the OpenAI `/models` endpoint,
such as Amazon Bedrock.

This change moves provider-specific model discovery behind
provider-owned implementations, so the models manager can focus on
refresh policy, cache behavior, picker ordering, and model metadata
merging.

## What Changed

- Introduced a `ModelsManager` trait with separate `OpenAiModelsManager`
and `StaticModelsManager` implementations.
- Added `ModelsEndpointClient` so OpenAI-compatible HTTP fetching lives
outside `codex-models-manager`.
- Moved `/models` request construction, provider auth resolution,
timeout handling, and request telemetry into `codex-model-provider` via
`OpenAiModelsEndpoint`.
- Added provider-owned `models_manager(...)` construction so configured
OpenAI-compatible providers use `OpenAiModelsManager`, while
static/catalog-backed providers can return `StaticModelsManager`.
- Added an Amazon Bedrock static model catalog for the GPT OSS Bedrock
model IDs.
- Updated core/session/thread manager code and tests to depend on
`Arc<dyn ModelsManager>`.
- Moved offline model test helpers into
`codex_models_manager::test_support`.
## Metadata References

The Bedrock catalog metadata is based on the official Amazon Bedrock
OpenAI model documentation:

- [Amazon Bedrock OpenAI
models](https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-openai.html)
lists the Bedrock model IDs, text input/output modalities, and `128,000`
token context window for `gpt-oss-20b` and `gpt-oss-120b`.
- [Amazon Bedrock `gpt-oss-120b` model
card](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-oss-120b.html)
lists the `bedrock-runtime` model ID `openai.gpt-oss-120b-1:0`, the
`bedrock-mantle` model ID `openai.gpt-oss-120b`, text-only modalities,
and `128K` context window.
- [OpenAI `gpt-oss-120b` model
docs](https://developers.openai.com/api/docs/models/gpt-oss-120b)
document configurable reasoning effort with `low`, `medium`, and `high`,
plus text input/output modality.

The display names, default reasoning effort, and priority ordering are
Codex-local catalog choices.

## Test Plan
- Manually verified app-server model listing with an AWS profile:

```shell
CODEX_HOME="$(mktemp -d)" cargo run -p codex-app-server-test-client -- \
  --codex-bin ./target/debug/codex \
  -c 'model_provider="amazon-bedrock"' \
  -c 'model_providers.amazon-bedrock.aws.profile="codex-bedrock"' \
  -c 'model_providers.amazon-bedrock.aws.region="us-west-2"' \
  model-list
```

The response returned the Bedrock catalog with `openai.gpt-oss-120b-1:0`
as the default model and `openai.gpt-oss-20b-1:0` as the second listed
model, both text-only and supporting low/medium/high reasoning effort.
2026-04-24 04:28:25 +00:00

503 lines
17 KiB
Rust

use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use codex_api::Provider;
use codex_api::SharedAuthProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
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::openai_models::ModelsResponse;
use crate::amazon_bedrock::AmazonBedrockModelProvider;
use crate::auth::auth_manager_for_provider;
use crate::auth::resolve_provider_auth;
use crate::models_endpoint::OpenAiModelsEndpoint;
/// 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,
}
/// Error returned when a provider cannot construct its app-visible account state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAccountError {
MissingChatgptAccountDetails,
}
impl fmt::Display for ProviderAccountError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingChatgptAccountDetails => {
write!(
f,
"email and plan type are required for chatgpt authentication"
)
}
}
}
}
impl std::error::Error for ProviderAccountError {}
pub type ProviderAccountResult = std::result::Result<ProviderAccountState, ProviderAccountError>;
/// 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.
#[async_trait::async_trait]
pub trait ModelProvider: fmt::Debug + Send + Sync {
/// Returns the configured provider metadata.
fn info(&self) -> &ModelProviderInfo;
/// 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>>;
/// Returns the current provider-scoped auth value, if one is configured.
async fn auth(&self) -> Option<CodexAuth>;
/// Returns the current app-visible account state for this provider.
fn account_state(&self) -> ProviderAccountResult;
/// Returns provider configuration adapted for the API client.
async fn api_provider(&self) -> codex_protocol::error::Result<Provider> {
let auth = self.auth().await;
self.info()
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))
}
/// Returns the auth provider used to attach request credentials.
async fn api_auth(&self) -> codex_protocol::error::Result<SharedAuthProvider> {
let auth = self.auth().await;
resolve_provider_auth(auth.as_ref(), self.info())
}
/// Creates the model manager implementation appropriate for this provider.
fn models_manager(
&self,
codex_home: PathBuf,
config_model_catalog: Option<ModelsResponse>,
collaboration_modes_config: CollaborationModesConfig,
) -> SharedModelsManager;
}
/// Shared runtime model provider handle.
pub type SharedModelProvider = Arc<dyn ModelProvider>;
/// Creates the default runtime model provider for configured provider metadata.
pub fn create_model_provider(
provider_info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
) -> SharedModelProvider {
if provider_info.is_amazon_bedrock() {
Arc::new(AmazonBedrockModelProvider::new(provider_info))
} else {
Arc::new(ConfiguredModelProvider::new(provider_info, auth_manager))
}
}
/// Runtime model provider backed by configured `ModelProviderInfo`.
#[derive(Clone, Debug)]
struct ConfiguredModelProvider {
info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
}
impl ConfiguredModelProvider {
fn new(provider_info: ModelProviderInfo, auth_manager: Option<Arc<AuthManager>>) -> Self {
let auth_manager = auth_manager_for_provider(auth_manager, &provider_info);
Self {
info: provider_info,
auth_manager,
}
}
}
#[async_trait::async_trait]
impl ModelProvider for ConfiguredModelProvider {
fn info(&self) -> &ModelProviderInfo {
&self.info
}
fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
async fn auth(&self) -> Option<CodexAuth> {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
None => None,
}
}
fn account_state(&self) -> ProviderAccountResult {
let account = if self.info.requires_openai_auth {
self.auth_manager
.as_ref()
.and_then(|auth_manager| auth_manager.auth_cached())
.map(|auth| match &auth {
CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey),
CodexAuth::Chatgpt(_)
| CodexAuth::ChatgptAuthTokens(_)
| CodexAuth::AgentIdentity(_) => {
let email = auth.get_account_email();
let plan_type = auth.account_plan_type();
match (email, plan_type) {
(Some(email), Some(plan_type)) => {
Ok(ProviderAccount::Chatgpt { email, plan_type })
}
_ => Err(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>,
collaboration_modes_config: CollaborationModesConfig,
) -> SharedModelsManager {
match config_model_catalog {
Some(model_catalog) => Arc::new(StaticModelsManager::new(
self.auth_manager.clone(),
model_catalog,
collaboration_modes_config,
)),
None => {
let endpoint = Arc::new(OpenAiModelsEndpoint::new(
self.info.clone(),
self.auth_manager.clone(),
));
Arc::new(OpenAiModelsManager::new(
codex_home,
endpoint,
self.auth_manager.clone(),
collaboration_modes_config,
))
}
}
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU64;
use codex_model_provider_info::ModelProviderAwsAuthInfo;
use codex_model_provider_info::WireApi;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
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::*;
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),
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: 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,
}
}
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,
"base_instructions": "base instructions",
"supports_reasoning_summaries": false,
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {"mode": "bytes", "limit": 10_000},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": 272_000,
"max_context_window": 272_000,
"experimental_supported_tools": [],
}))
.expect("valid model")
}
#[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,
})),
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"openai-api-key",
))),
);
assert!(provider.auth_manager().is_none());
}
#[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 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),
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,
Default::default(),
);
let catalog = manager.raw_model_catalog(RefreshStrategy::Online).await;
let model_ids = catalog
.models
.iter()
.map(|model| model.slug.as_str())
.collect::<Vec<_>>();
assert_eq!(
model_ids,
vec![
"openai.gpt-5.4-cmb",
"openai.gpt-oss-120b",
"openai.gpt-oss-20b"
]
);
let default_model = manager
.list_models(RefreshStrategy::Online)
.await
.into_iter()
.find(|preset| preset.is_default)
.expect("Bedrock catalog should have a default model");
assert_eq!(default_model.model, "openai.gpt-5.4-cmb");
}
#[tokio::test]
async fn amazon_bedrock_provider_uses_configured_static_catalog_when_present() {
let custom_model =
codex_models_manager::model_info::model_info_from_slug("custom-bedrock-model");
let provider = create_model_provider(
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
/*auth_manager*/ None,
);
let manager = provider.models_manager(
test_codex_home(),
Some(ModelsResponse {
models: vec![custom_model],
}),
Default::default(),
);
let catalog = manager.raw_model_catalog(RefreshStrategy::Online).await;
assert_eq!(catalog.models.len(), 1);
assert_eq!(catalog.models[0].slug, "custom-bedrock-model");
}
#[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(1)
.mount(&server)
.await;
let mut provider_info = provider_for(server.uri());
provider_info.experimental_bearer_token = Some("provider-token".to_string());
let provider = create_model_provider(
provider_info,
Some(AuthManager::from_auth_for_testing(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
)),
);
let manager = provider.models_manager(
test_codex_home(),
/*config_model_catalog*/ None,
Default::default(),
);
let catalog = manager.raw_model_catalog(RefreshStrategy::Online).await;
assert!(
catalog
.models
.iter()
.any(|model| model.slug == "provider-model")
);
}
}