mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## 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.
503 lines
17 KiB
Rust
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")
|
|
);
|
|
}
|
|
}
|