extract models manager and related ownership from core (#16508)

## Summary
- split `models-manager` out of `core` and add `ModelsManagerConfig`
plus `Config::to_models_manager_config()` so model metadata paths stop
depending on `core::Config`
- move login-owned/auth-owned code out of `core` into `codex-login`,
move model provider config into `codex-model-provider-info`, move API
bridge mapping into `codex-api`, move protocol-owned types/impls into
`codex-protocol`, and move response debug helpers into a dedicated
`response-debug-context` crate
- move feedback tag emission into `codex-feedback`, relocate tests to
the crates that now own the code, and keep broad temporary re-exports so
this PR avoids a giant import-only rewrite

## Major moves and decisions
- created `codex-models-manager` as the owner for model
cache/catalog/config/model info logic, including the new
`ModelsManagerConfig` struct
- created `codex-model-provider-info` as the owner for provider config
parsing/defaults and kept temporary `codex-login`/`codex-core`
re-exports for old import paths
- moved `api_bridge` error mapping + `CoreAuthProvider` into
`codex-api`, while `codex-login::api_bridge` temporarily re-exports
those symbols and keeps the `auth_provider_from_auth` wrapper
- moved `auth_env_telemetry` and `provider_auth` ownership to
`codex-login`
- moved `CodexErr` ownership to `codex-protocol::error`, plus
`StreamOutput`, `bytes_to_string_smart`, and network policy helpers to
protocol-owned modules
- created `codex-response-debug-context` for
`extract_response_debug_context`, `telemetry_transport_error_message`,
and related response-debug plumbing instead of leaving that behavior in
`core`
- moved `FeedbackRequestTags`, `emit_feedback_request_tags`, and
`emit_feedback_request_tags_with_auth_env` to `codex-feedback`
- deferred removal of temporary re-exports and the mechanical import
rewrites to a stacked follow-up PR so this PR stays reviewable

## Test moves
- moved auth refresh coverage from `core/tests/suite/auth_refresh.rs` to
`login/tests/suite/auth_refresh.rs`
- moved text encoding coverage from
`core/tests/suite/text_encoding_fix.rs` to
`protocol/src/exec_output_tests.rs`
- moved model info override coverage from
`core/tests/suite/model_info_overrides.rs` to
`models-manager/src/model_info_overrides_tests.rs`

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-04-02 23:00:02 -07:00
committed by GitHub
co-authored by Codex
parent 8cd7f20b48
commit 6fff9955f1
99 changed files with 1095 additions and 1137 deletions
+183
View File
@@ -0,0 +1,183 @@
use chrono::DateTime;
use chrono::Utc;
use codex_protocol::openai_models::ModelInfo;
use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::io::ErrorKind;
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use tracing::error;
use tracing::info;
/// Manages loading and saving of models cache to disk.
#[derive(Debug)]
pub(crate) struct ModelsCacheManager {
cache_path: PathBuf,
cache_ttl: Duration,
}
impl ModelsCacheManager {
/// Create a new cache manager with the given path and TTL.
pub(crate) fn new(cache_path: PathBuf, cache_ttl: Duration) -> Self {
Self {
cache_path,
cache_ttl,
}
}
/// Attempt to load a fresh cache entry. Returns `None` if the cache doesn't exist or is stale.
pub(crate) async fn load_fresh(&self, expected_version: &str) -> Option<ModelsCache> {
info!(
cache_path = %self.cache_path.display(),
expected_version,
"models cache: attempting load_fresh"
);
let cache = match self.load().await {
Ok(cache) => cache?,
Err(err) => {
error!("failed to load models cache: {err}");
return None;
}
};
info!(
cache_path = %self.cache_path.display(),
cached_version = ?cache.client_version,
fetched_at = %cache.fetched_at,
"models cache: loaded cache file"
);
if cache.client_version.as_deref() != Some(expected_version) {
info!(
cache_path = %self.cache_path.display(),
expected_version,
cached_version = ?cache.client_version,
"models cache: cache version mismatch"
);
return None;
}
if !cache.is_fresh(self.cache_ttl) {
info!(
cache_path = %self.cache_path.display(),
cache_ttl_secs = self.cache_ttl.as_secs(),
fetched_at = %cache.fetched_at,
"models cache: cache is stale"
);
return None;
}
info!(
cache_path = %self.cache_path.display(),
cache_ttl_secs = self.cache_ttl.as_secs(),
"models cache: cache hit"
);
Some(cache)
}
/// Persist the cache to disk, creating parent directories as needed.
pub(crate) async fn persist_cache(
&self,
models: &[ModelInfo],
etag: Option<String>,
client_version: String,
) {
let cache = ModelsCache {
fetched_at: Utc::now(),
etag,
client_version: Some(client_version),
models: models.to_vec(),
};
if let Err(err) = self.save_internal(&cache).await {
error!("failed to write models cache: {err}");
}
}
/// Renew the cache TTL by updating the fetched_at timestamp to now.
pub(crate) async fn renew_cache_ttl(&self) -> io::Result<()> {
let mut cache = match self.load().await? {
Some(cache) => cache,
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
};
cache.fetched_at = Utc::now();
self.save_internal(&cache).await
}
async fn load(&self) -> io::Result<Option<ModelsCache>> {
match fs::read(&self.cache_path).await {
Ok(contents) => {
let cache = serde_json::from_slice(&contents)
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
Ok(Some(cache))
}
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
async fn save_internal(&self, cache: &ModelsCache) -> io::Result<()> {
if let Some(parent) = self.cache_path.parent() {
fs::create_dir_all(parent).await?;
}
let json = serde_json::to_vec_pretty(cache)
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
fs::write(&self.cache_path, json).await
}
#[cfg(test)]
/// Set the cache TTL.
pub(crate) fn set_ttl(&mut self, ttl: Duration) {
self.cache_ttl = ttl;
}
#[cfg(test)]
/// Manipulate cache file for testing. Allows setting a custom fetched_at timestamp.
pub(crate) async fn manipulate_cache_for_test<F>(&self, f: F) -> io::Result<()>
where
F: FnOnce(&mut DateTime<Utc>),
{
let mut cache = match self.load().await? {
Some(cache) => cache,
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
};
f(&mut cache.fetched_at);
self.save_internal(&cache).await
}
#[cfg(test)]
/// Mutate the full cache contents for testing.
pub(crate) async fn mutate_cache_for_test<F>(&self, f: F) -> io::Result<()>
where
F: FnOnce(&mut ModelsCache),
{
let mut cache = match self.load().await? {
Some(cache) => cache,
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
};
f(&mut cache);
self.save_internal(&cache).await
}
}
/// Serialized snapshot of models and metadata cached on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ModelsCache {
pub(crate) fetched_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) client_version: Option<String>,
pub(crate) models: Vec<ModelInfo>,
}
impl ModelsCache {
/// Returns `true` when the cache entry has not exceeded the configured TTL.
fn is_fresh(&self, ttl: Duration) -> bool {
if ttl.is_zero() {
return false;
}
let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else {
return false;
};
let age = Utc::now().signed_duration_since(self.fetched_at);
age <= ttl_duration
}
}
@@ -0,0 +1,115 @@
use codex_collaboration_mode_templates::DEFAULT as COLLABORATION_MODE_DEFAULT;
use codex_collaboration_mode_templates::PLAN as COLLABORATION_MODE_PLAN;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_template::Template;
use std::sync::LazyLock;
const KNOWN_MODE_NAMES_TEMPLATE_KEY: &str = "KNOWN_MODE_NAMES";
const REQUEST_USER_INPUT_AVAILABILITY_TEMPLATE_KEY: &str = "REQUEST_USER_INPUT_AVAILABILITY";
const ASKING_QUESTIONS_GUIDANCE_TEMPLATE_KEY: &str = "ASKING_QUESTIONS_GUIDANCE";
static COLLABORATION_MODE_DEFAULT_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(COLLABORATION_MODE_DEFAULT)
.unwrap_or_else(|err| panic!("collaboration mode default template must parse: {err}"))
});
/// Stores feature flags that control collaboration-mode behavior.
///
/// Keep mode-related flags here so new collaboration-mode capabilities can be
/// added without large cross-cutting diffs to constructor and call-site
/// signatures.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CollaborationModesConfig {
/// Enables `request_user_input` availability in Default mode.
pub default_mode_request_user_input: bool,
}
pub fn builtin_collaboration_mode_presets(
collaboration_modes_config: CollaborationModesConfig,
) -> Vec<CollaborationModeMask> {
vec![plan_preset(), default_preset(collaboration_modes_config)]
}
fn plan_preset() -> CollaborationModeMask {
CollaborationModeMask {
name: ModeKind::Plan.display_name().to_string(),
mode: Some(ModeKind::Plan),
model: None,
reasoning_effort: Some(Some(ReasoningEffort::Medium)),
developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())),
}
}
fn default_preset(collaboration_modes_config: CollaborationModesConfig) -> CollaborationModeMask {
CollaborationModeMask {
name: ModeKind::Default.display_name().to_string(),
mode: Some(ModeKind::Default),
model: None,
reasoning_effort: None,
developer_instructions: Some(Some(default_mode_instructions(collaboration_modes_config))),
}
}
fn default_mode_instructions(collaboration_modes_config: CollaborationModesConfig) -> String {
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
let request_user_input_availability = request_user_input_availability_message(
ModeKind::Default,
collaboration_modes_config.default_mode_request_user_input,
);
let asking_questions_guidance = asking_questions_guidance_message(
collaboration_modes_config.default_mode_request_user_input,
);
COLLABORATION_MODE_DEFAULT_TEMPLATE
.render([
(KNOWN_MODE_NAMES_TEMPLATE_KEY, known_mode_names.as_str()),
(
REQUEST_USER_INPUT_AVAILABILITY_TEMPLATE_KEY,
request_user_input_availability.as_str(),
),
(
ASKING_QUESTIONS_GUIDANCE_TEMPLATE_KEY,
asking_questions_guidance.as_str(),
),
])
.unwrap_or_else(|err| panic!("collaboration mode default template must render: {err}"))
}
fn format_mode_names(modes: &[ModeKind]) -> String {
let mode_names: Vec<&str> = modes.iter().map(|mode| mode.display_name()).collect();
match mode_names.as_slice() {
[] => "none".to_string(),
[mode_name] => (*mode_name).to_string(),
[first, second] => format!("{first} and {second}"),
[..] => mode_names.join(", "),
}
}
fn request_user_input_availability_message(
mode: ModeKind,
default_mode_request_user_input: bool,
) -> String {
let mode_name = mode.display_name();
if mode.allows_request_user_input()
|| (default_mode_request_user_input && mode == ModeKind::Default)
{
format!("The `request_user_input` tool is available in {mode_name} mode.")
} else {
format!(
"The `request_user_input` tool is unavailable in {mode_name} mode. If you call it while in {mode_name} mode, it will return an error."
)
}
}
fn asking_questions_guidance_message(default_mode_request_user_input: bool) -> String {
if default_mode_request_user_input {
"In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `request_user_input` tool rather than writing a multiple choice question as a textual assistant message. Never write a multiple choice question as a textual assistant message.".to_string()
} else {
"In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string()
}
}
#[cfg(test)]
#[path = "collaboration_mode_presets_tests.rs"]
mod tests;
@@ -0,0 +1,53 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn preset_names_use_mode_display_names() {
assert_eq!(plan_preset().name, ModeKind::Plan.display_name());
assert_eq!(
default_preset(CollaborationModesConfig::default()).name,
ModeKind::Default.display_name()
);
assert_eq!(
plan_preset().reasoning_effort,
Some(Some(ReasoningEffort::Medium))
);
}
#[test]
fn default_mode_instructions_replace_mode_names_placeholder() {
let default_instructions = default_preset(CollaborationModesConfig {
default_mode_request_user_input: true,
})
.developer_instructions
.expect("default preset should include instructions")
.expect("default instructions should be set");
assert!(!default_instructions.contains("{{KNOWN_MODE_NAMES}}"));
assert!(!default_instructions.contains("{{REQUEST_USER_INPUT_AVAILABILITY}}"));
assert!(!default_instructions.contains("{{ASKING_QUESTIONS_GUIDANCE}}"));
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
let expected_snippet = format!("Known mode names are {known_mode_names}.");
assert!(default_instructions.contains(&expected_snippet));
let expected_availability_message = request_user_input_availability_message(
ModeKind::Default,
/*default_mode_request_user_input*/ true,
);
assert!(default_instructions.contains(&expected_availability_message));
assert!(default_instructions.contains("prefer using the `request_user_input` tool"));
}
#[test]
fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() {
let default_instructions = default_preset(CollaborationModesConfig::default())
.developer_instructions
.expect("default preset should include instructions")
.expect("default instructions should be set");
assert!(!default_instructions.contains("prefer using the `request_user_input` tool"));
assert!(
default_instructions.contains("ask the user directly with a concise plain-text question")
);
}
+12
View File
@@ -0,0 +1,12 @@
use codex_protocol::openai_models::ModelsResponse;
#[derive(Debug, Clone, Default)]
pub struct ModelsManagerConfig {
pub model_context_window: Option<i64>,
pub model_auto_compact_token_limit: Option<i64>,
pub tool_output_token_limit: Option<usize>,
pub base_instructions: Option<String>,
pub personality_enabled: bool,
pub model_supports_reasoning_summaries: Option<bool>,
pub model_catalog: Option<ModelsResponse>,
}
+30
View File
@@ -0,0 +1,30 @@
pub mod cache;
pub mod collaboration_mode_presets;
pub mod config;
pub mod manager;
pub mod model_info;
pub mod model_presets;
pub use codex_login::AuthCredentialsStoreMode;
pub use codex_login::AuthManager;
pub use codex_login::AuthMode;
pub use codex_login::CodexAuth;
pub use codex_login::ModelProviderInfo;
pub use codex_login::WireApi;
pub use config::ModelsManagerConfig;
/// Load the bundled model catalog shipped with `codex-models-manager`.
pub fn bundled_models_response()
-> std::result::Result<codex_protocol::openai_models::ModelsResponse, serde_json::Error> {
serde_json::from_str(include_str!("../models.json"))
}
/// Convert the client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3").
pub fn client_version_to_whole() -> String {
format!(
"{}.{}.{}",
env!("CARGO_PKG_VERSION_MAJOR"),
env!("CARGO_PKG_VERSION_MINOR"),
env!("CARGO_PKG_VERSION_PATCH")
)
}
+586
View File
@@ -0,0 +1,586 @@
use super::cache::ModelsCacheManager;
use crate::collaboration_mode_presets::CollaborationModesConfig;
use crate::collaboration_mode_presets::builtin_collaboration_mode_presets;
use crate::config::ModelsManagerConfig;
use crate::model_info;
use codex_api::ModelsClient;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
use codex_api::TransportError;
use codex_feedback::FeedbackRequestTags;
use codex_feedback::emit_feedback_request_tags_with_auth_env;
use codex_login::AuthEnvTelemetry;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_login::ModelProviderInfo;
use codex_login::auth_provider_from_auth;
use codex_login::collect_auth_env_telemetry;
use codex_login::default_client::build_reqwest_client;
use codex_login::map_api_error;
use codex_login::required_auth_manager_for_provider;
use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CoreResult;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelsResponse;
use codex_response_debug_context::extract_response_debug_context;
use codex_response_debug_context::telemetry_transport_error_message;
use http::HeaderMap;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::sync::TryLockError;
use tokio::time::timeout;
use tracing::error;
use tracing::info;
use tracing::instrument;
const MODEL_CACHE_FILE: &str = "models_cache.json";
const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300);
const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
const MODELS_ENDPOINT: &str = "/models";
#[derive(Clone)]
struct ModelsRequestTelemetry {
auth_mode: Option<String>,
auth_header_attached: bool,
auth_header_name: Option<&'static str>,
auth_env: AuthEnvTelemetry,
}
impl RequestTelemetry for ModelsRequestTelemetry {
fn on_request(
&self,
attempt: u64,
status: Option<http::StatusCode>,
error: Option<&TransportError>,
duration: Duration,
) {
let success = status.is_some_and(|code| code.is_success()) && error.is_none();
let error_message = error.map(telemetry_transport_error_message);
let response_debug = error
.map(extract_response_debug_context)
.unwrap_or_default();
let status = status.map(|status| status.as_u16());
tracing::event!(
target: "codex_otel.log_only",
tracing::Level::INFO,
event.name = "codex.api_request",
duration_ms = %duration.as_millis(),
http.response.status_code = status,
success = success,
error.message = error_message.as_deref(),
attempt = attempt,
endpoint = MODELS_ENDPOINT,
auth.header_attached = self.auth_header_attached,
auth.header_name = self.auth_header_name,
auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present,
auth.request_id = response_debug.request_id.as_deref(),
auth.cf_ray = response_debug.cf_ray.as_deref(),
auth.error = response_debug.auth_error.as_deref(),
auth.error_code = response_debug.auth_error_code.as_deref(),
auth.mode = self.auth_mode.as_deref(),
);
tracing::event!(
target: "codex_otel.trace_safe",
tracing::Level::INFO,
event.name = "codex.api_request",
duration_ms = %duration.as_millis(),
http.response.status_code = status,
success = success,
error.message = error_message.as_deref(),
attempt = attempt,
endpoint = MODELS_ENDPOINT,
auth.header_attached = self.auth_header_attached,
auth.header_name = self.auth_header_name,
auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present,
auth.request_id = response_debug.request_id.as_deref(),
auth.cf_ray = response_debug.cf_ray.as_deref(),
auth.error = response_debug.auth_error.as_deref(),
auth.error_code = response_debug.auth_error_code.as_deref(),
auth.mode = self.auth_mode.as_deref(),
);
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {
endpoint: MODELS_ENDPOINT,
auth_header_attached: self.auth_header_attached,
auth_header_name: self.auth_header_name,
auth_mode: self.auth_mode.as_deref(),
auth_retry_after_unauthorized: None,
auth_recovery_mode: None,
auth_recovery_phase: None,
auth_connection_reused: None,
auth_request_id: response_debug.request_id.as_deref(),
auth_cf_ray: response_debug.cf_ray.as_deref(),
auth_error: response_debug.auth_error.as_deref(),
auth_error_code: response_debug.auth_error_code.as_deref(),
auth_recovery_followup_success: None,
auth_recovery_followup_status: None,
},
&self.auth_env,
);
}
}
/// Strategy for refreshing available models.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshStrategy {
/// Always fetch from the network, ignoring cache.
Online,
/// Only use cached data, never fetch from the network.
Offline,
/// Use cache if available and fresh, otherwise fetch from the network.
OnlineIfUncached,
}
impl RefreshStrategy {
const fn as_str(self) -> &'static str {
match self {
Self::Online => "online",
Self::Offline => "offline",
Self::OnlineIfUncached => "online_if_uncached",
}
}
}
impl fmt::Display for RefreshStrategy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// How the manager's base catalog is sourced for the lifetime of the process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CatalogMode {
/// Start from bundled `models.json` and allow cache/network refresh updates.
Default,
/// Use a caller-provided catalog as authoritative and do not mutate it via refresh.
Custom,
}
/// Coordinates remote model discovery plus cached metadata on disk.
#[derive(Debug)]
pub struct ModelsManager {
remote_models: RwLock<Vec<ModelInfo>>,
catalog_mode: CatalogMode,
collaboration_modes_config: CollaborationModesConfig,
auth_manager: Arc<AuthManager>,
etag: RwLock<Option<String>>,
cache_manager: ModelsCacheManager,
provider: ModelProviderInfo,
}
impl ModelsManager {
/// Construct a manager scoped to the provided `AuthManager`.
///
/// Uses `codex_home` to store cached model metadata and initializes with bundled catalog
/// When `model_catalog` is provided, it becomes the authoritative remote model list and
/// background refreshes from `/models` are disabled.
pub fn new(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
model_catalog: Option<ModelsResponse>,
collaboration_modes_config: CollaborationModesConfig,
) -> Self {
Self::new_with_provider(
codex_home,
auth_manager,
model_catalog,
collaboration_modes_config,
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
)
}
/// Construct a manager with an explicit provider used for remote model refreshes.
pub fn new_with_provider(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
model_catalog: Option<ModelsResponse>,
collaboration_modes_config: CollaborationModesConfig,
provider: ModelProviderInfo,
) -> Self {
let auth_manager = required_auth_manager_for_provider(auth_manager, &provider);
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
let catalog_mode = if model_catalog.is_some() {
CatalogMode::Custom
} else {
CatalogMode::Default
};
let remote_models = model_catalog
.map(|catalog| catalog.models)
.unwrap_or_else(|| Self::load_remote_models_from_file().unwrap_or_default());
Self {
remote_models: RwLock::new(remote_models),
catalog_mode,
collaboration_modes_config,
auth_manager,
etag: RwLock::new(None),
cache_manager,
provider,
}
}
/// List all available models, refreshing according to the specified strategy.
///
/// Returns model presets sorted by priority and filtered by auth mode and visibility.
#[instrument(
level = "info",
skip(self),
fields(refresh_strategy = %refresh_strategy)
)]
pub async fn list_models(&self, refresh_strategy: RefreshStrategy) -> Vec<ModelPreset> {
if let Err(err) = self.refresh_available_models(refresh_strategy).await {
error!("failed to refresh available models: {err}");
}
let remote_models = self.get_remote_models().await;
self.build_available_models(remote_models)
}
/// List collaboration mode presets.
///
/// Returns a static set of presets seeded with the configured model.
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
self.list_collaboration_modes_for_config(self.collaboration_modes_config)
}
pub fn list_collaboration_modes_for_config(
&self,
collaboration_modes_config: CollaborationModesConfig,
) -> Vec<CollaborationModeMask> {
builtin_collaboration_mode_presets(collaboration_modes_config)
}
/// Attempt to list models without blocking, using the current cached state.
///
/// Returns an error if the internal lock cannot be acquired.
pub fn try_list_models(&self) -> Result<Vec<ModelPreset>, TryLockError> {
let remote_models = self.try_get_remote_models()?;
Ok(self.build_available_models(remote_models))
}
// todo(aibrahim): should be visible to core only and sent on session_configured event
/// Get the model identifier to use, refreshing according to the specified strategy.
///
/// If `model` is provided, returns it directly. Otherwise selects the default based on
/// auth mode and available models.
#[instrument(
level = "info",
skip(self, model),
fields(
model.provided = model.is_some(),
refresh_strategy = %refresh_strategy
)
)]
pub async fn get_default_model(
&self,
model: &Option<String>,
refresh_strategy: RefreshStrategy,
) -> String {
if let Some(model) = model.as_ref() {
return model.to_string();
}
if let Err(err) = self.refresh_available_models(refresh_strategy).await {
error!("failed to refresh available models: {err}");
}
let remote_models = self.get_remote_models().await;
let available = self.build_available_models(remote_models);
available
.iter()
.find(|model| model.is_default)
.or_else(|| available.first())
.map(|model| model.model.clone())
.unwrap_or_default()
}
// todo(aibrahim): look if we can tighten it to pub(crate)
/// Look up model metadata, applying remote overrides and config adjustments.
#[instrument(level = "info", skip(self, config), fields(model = model))]
pub async fn get_model_info(&self, model: &str, config: &ModelsManagerConfig) -> ModelInfo {
let remote_models = self.get_remote_models().await;
Self::construct_model_info_from_candidates(model, &remote_models, config)
}
fn find_model_by_longest_prefix(model: &str, candidates: &[ModelInfo]) -> Option<ModelInfo> {
let mut best: Option<ModelInfo> = None;
for candidate in candidates {
if !model.starts_with(&candidate.slug) {
continue;
}
let is_better_match = if let Some(current) = best.as_ref() {
candidate.slug.len() > current.slug.len()
} else {
true
};
if is_better_match {
best = Some(candidate.clone());
}
}
best
}
/// Retry metadata lookup for a single namespaced slug like `namespace/model-name`.
///
/// This only strips one leading namespace segment and only when the namespace is ASCII
/// alphanumeric/underscore (`\\w+`) to avoid broadly matching arbitrary aliases.
fn find_model_by_namespaced_suffix(model: &str, candidates: &[ModelInfo]) -> Option<ModelInfo> {
let (namespace, suffix) = model.split_once('/')?;
if suffix.contains('/') {
return None;
}
if !namespace
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return None;
}
Self::find_model_by_longest_prefix(suffix, candidates)
}
fn construct_model_info_from_candidates(
model: &str,
candidates: &[ModelInfo],
config: &ModelsManagerConfig,
) -> ModelInfo {
// First use the normal longest-prefix match. If that misses, allow a narrowly scoped
// retry for namespaced slugs like `custom/gpt-5.3-codex`.
let remote = Self::find_model_by_longest_prefix(model, candidates)
.or_else(|| Self::find_model_by_namespaced_suffix(model, candidates));
let model_info = if let Some(remote) = remote {
ModelInfo {
slug: model.to_string(),
used_fallback_model_metadata: false,
..remote
}
} else {
model_info::model_info_from_slug(model)
};
model_info::with_config_overrides(model_info, config)
}
/// Refresh models if the provided ETag differs from the cached ETag.
///
/// Uses `Online` strategy to fetch latest models when ETags differ.
pub async fn refresh_if_new_etag(&self, etag: String) {
let current_etag = self.get_etag().await;
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
if let Err(err) = self.cache_manager.renew_cache_ttl().await {
error!("failed to renew cache TTL: {err}");
}
return;
}
if let Err(err) = self.refresh_available_models(RefreshStrategy::Online).await {
error!("failed to refresh available models: {err}");
}
}
/// Refresh available models according to the specified strategy.
async fn refresh_available_models(&self, refresh_strategy: RefreshStrategy) -> CoreResult<()> {
// don't override the custom model catalog if one was provided by the user
if matches!(self.catalog_mode, CatalogMode::Custom) {
return Ok(());
}
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt)
&& !self.provider.has_command_auth()
{
if matches!(
refresh_strategy,
RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached
) {
self.try_load_cache().await;
}
return Ok(());
}
match refresh_strategy {
RefreshStrategy::Offline => {
// Only try to load from cache, never fetch
self.try_load_cache().await;
Ok(())
}
RefreshStrategy::OnlineIfUncached => {
// Try cache first, fall back to online if unavailable
if self.try_load_cache().await {
info!("models cache: using cached models for OnlineIfUncached");
return Ok(());
}
info!("models cache: cache miss, fetching remote models");
self.fetch_and_update_models().await
}
RefreshStrategy::Online => {
// Always fetch from network
self.fetch_and_update_models().await
}
}
}
async fn fetch_and_update_models(&self) -> CoreResult<()> {
let _timer =
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
let auth = self.auth_manager.auth().await;
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
let api_provider = self.provider.to_api_provider(auth_mode)?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
let auth_env = collect_auth_env_telemetry(
&self.provider,
self.auth_manager.codex_api_key_env_enabled(),
);
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry: Arc<dyn RequestTelemetry> = Arc::new(ModelsRequestTelemetry {
auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()),
auth_header_attached: api_auth.auth_header_attached(),
auth_header_name: api_auth.auth_header_name(),
auth_env,
});
let client = ModelsClient::new(transport, api_provider, api_auth)
.with_telemetry(Some(request_telemetry));
let client_version = crate::client_version_to_whole();
let (models, etag) = timeout(
MODELS_REFRESH_TIMEOUT,
client.list_models(&client_version, HeaderMap::new()),
)
.await
.map_err(|_| CodexErr::Timeout)?
.map_err(map_api_error)?;
self.apply_remote_models(models.clone()).await;
*self.etag.write().await = etag.clone();
self.cache_manager
.persist_cache(&models, etag, client_version)
.await;
Ok(())
}
async fn get_etag(&self) -> Option<String> {
self.etag.read().await.clone()
}
/// Replace the cached remote models and rebuild the derived presets list.
async fn apply_remote_models(&self, models: Vec<ModelInfo>) {
let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default();
for model in models {
if let Some(existing_index) = existing_models
.iter()
.position(|existing| existing.slug == model.slug)
{
existing_models[existing_index] = model;
} else {
existing_models.push(model);
}
}
*self.remote_models.write().await = existing_models;
}
fn load_remote_models_from_file() -> Result<Vec<ModelInfo>, std::io::Error> {
Ok(crate::bundled_models_response()?.models)
}
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
async fn try_load_cache(&self) -> bool {
let _timer =
codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]);
let client_version = crate::client_version_to_whole();
info!(client_version, "models cache: evaluating cache eligibility");
let cache = match self.cache_manager.load_fresh(&client_version).await {
Some(cache) => cache,
None => {
info!("models cache: no usable cache entry");
return false;
}
};
let models = cache.models.clone();
*self.etag.write().await = cache.etag.clone();
self.apply_remote_models(models.clone()).await;
info!(
models_count = models.len(),
etag = ?cache.etag,
"models cache: cache entry applied"
);
true
}
/// Build picker-ready presets from the active catalog snapshot.
fn build_available_models(&self, mut remote_models: Vec<ModelInfo>) -> Vec<ModelPreset> {
remote_models.sort_by(|a, b| a.priority.cmp(&b.priority));
let mut presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt));
presets = ModelPreset::filter_by_auth(presets, chatgpt_mode);
ModelPreset::mark_default_by_picker_visibility(&mut presets);
presets
}
async fn get_remote_models(&self) -> Vec<ModelInfo> {
self.remote_models.read().await.clone()
}
fn try_get_remote_models(&self) -> Result<Vec<ModelInfo>, TryLockError> {
Ok(self.remote_models.try_read()?.clone())
}
/// Construct a manager with a specific provider for testing.
pub fn with_provider_for_tests(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
provider: ModelProviderInfo,
) -> Self {
Self::new_with_provider(
codex_home,
auth_manager,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
provider,
)
}
/// Get model identifier without consulting remote state or cache.
pub fn get_model_offline_for_tests(model: Option<&str>) -> String {
if let Some(model) = model {
return model.to_string();
}
let mut models = Self::load_remote_models_from_file().unwrap_or_default();
models.sort_by(|a, b| a.priority.cmp(&b.priority));
let presets: Vec<ModelPreset> = models.into_iter().map(Into::into).collect();
presets
.iter()
.find(|preset| preset.show_in_picker)
.or_else(|| presets.first())
.map(|preset| preset.model.clone())
.unwrap_or_default()
}
/// Build `ModelInfo` without consulting remote state or cache.
pub fn construct_model_info_offline_for_tests(
model: &str,
config: &ModelsManagerConfig,
) -> ModelInfo {
let candidates: &[ModelInfo] = if let Some(model_catalog) = config.model_catalog.as_ref() {
&model_catalog.models
} else {
&[]
};
Self::construct_model_info_from_candidates(model, candidates, config)
}
}
#[cfg(test)]
#[path = "manager_tests.rs"]
mod tests;
@@ -0,0 +1,875 @@
use super::*;
use crate::ModelsManagerConfig;
use base64::Engine as _;
use chrono::Utc;
use codex_api::TransportError;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::WireApi;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::responses::mount_models_once;
use http::HeaderMap;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::num::NonZeroU64;
use std::sync::Arc;
use std::sync::Mutex;
use tempfile::TempDir;
use tempfile::tempdir;
use tracing::Event;
use tracing::Subscriber;
use tracing::field::Visit;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[path = "model_info_overrides_tests.rs"]
mod model_info_overrides_tests;
fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo {
remote_model_with_visibility(slug, display, priority, "list")
}
fn remote_model_with_visibility(
slug: &str,
display: &str,
priority: i32,
visibility: &str,
) -> ModelInfo {
serde_json::from_value(json!({
"slug": slug,
"display_name": display,
"description": format!("{display} desc"),
"default_reasoning_level": "medium",
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}],
"shell_type": "shell_command",
"visibility": visibility,
"minimal_client_version": [0, 1, 0],
"supported_in_api": true,
"priority": priority,
"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,
"experimental_supported_tools": [],
}))
.expect("valid model")
}
fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) {
for model in expected {
assert!(
actual.iter().any(|candidate| candidate.slug == model.slug),
"expected model {} in cached list",
model.slug
);
}
}
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,
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,
}
}
struct ProviderAuthScript {
tempdir: TempDir,
command: String,
args: Vec<String>,
}
impl ProviderAuthScript {
fn new(tokens: &[&str]) -> std::io::Result<Self> {
let tempdir = tempfile::tempdir()?;
let tokens_file = tempdir.path().join("tokens.txt");
// `cmd.exe`'s `set /p` treats LF-only input as one line, so use CRLF on Windows.
let token_line_ending = if cfg!(windows) { "\r\n" } else { "\n" };
let mut token_file_contents = String::new();
for token in tokens {
token_file_contents.push_str(token);
token_file_contents.push_str(token_line_ending);
}
std::fs::write(&tokens_file, token_file_contents)?;
#[cfg(unix)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.sh");
std::fs::write(
&script_path,
r#"#!/bin/sh
first_line=$(sed -n '1p' tokens.txt)
printf '%s\n' "$first_line"
tail -n +2 tokens.txt > tokens.next
mv tokens.next tokens.txt
"#,
)?;
let mut permissions = std::fs::metadata(&script_path)?.permissions();
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&script_path, permissions)?;
("./print-token.sh".to_string(), Vec::new())
};
#[cfg(windows)]
let (command, args) = {
let script_path = tempdir.path().join("print-token.cmd");
std::fs::write(
&script_path,
r#"@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "first_line="
<tokens.txt set /p "first_line="
if not defined first_line exit /b 1
setlocal EnableDelayedExpansion
echo(!first_line!
endlocal
more +1 tokens.txt > tokens.next
move /y tokens.next tokens.txt >nul
"#,
)?;
(
"cmd.exe".to_string(),
vec![
"/d".to_string(),
"/s".to_string(),
"/c".to_string(),
".\\print-token.cmd".to_string(),
],
)
};
Ok(Self {
tempdir,
command,
args,
})
}
fn auth_config(&self) -> ModelProviderAuthInfo {
let timeout_ms = if cfg!(windows) {
// Process startup can be slow on loaded Windows CI workers.
10_000
} else {
2_000
};
ModelProviderAuthInfo {
command: self.command.clone(),
args: self.args.clone(),
timeout_ms: NonZeroU64::new(timeout_ms).unwrap(),
refresh_interval_ms: 60_000,
cwd: match AbsolutePathBuf::try_from(self.tempdir.path()) {
Ok(cwd) => cwd,
Err(err) => panic!("tempdir should be absolute: {err}"),
},
}
}
}
#[derive(Default)]
struct TagCollectorVisitor {
tags: BTreeMap<String, String>,
}
impl Visit for TagCollectorVisitor {
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.tags
.insert(field.name().to_string(), value.to_string());
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.tags
.insert(field.name().to_string(), value.to_string());
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.tags
.insert(field.name().to_string(), format!("{value:?}"));
}
}
#[derive(Clone)]
struct TagCollectorLayer {
tags: Arc<Mutex<BTreeMap<String, String>>>,
}
impl<S> Layer<S> for TagCollectorLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if event.metadata().target() != "feedback_tags" {
return;
}
let mut visitor = TagCollectorVisitor::default();
event.record(&mut visitor);
self.tags.lock().unwrap().extend(visitor.tags);
}
}
#[tokio::test]
async fn get_model_info_tracks_fallback_usage() {
let codex_home = tempdir().expect("temp dir");
let config = ModelsManagerConfig::default();
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
);
let known_slug = manager
.get_remote_models()
.await
.first()
.expect("bundled models should include at least one model")
.slug
.clone();
let known = manager.get_model_info(known_slug.as_str(), &config).await;
assert!(!known.used_fallback_model_metadata);
assert_eq!(known.slug, known_slug);
let unknown = manager
.get_model_info("model-that-does-not-exist", &config)
.await;
assert!(unknown.used_fallback_model_metadata);
assert_eq!(unknown.slug, "model-that-does-not-exist");
}
#[tokio::test]
async fn get_model_info_uses_custom_catalog() {
let codex_home = tempdir().expect("temp dir");
let config = ModelsManagerConfig::default();
let mut overlay = remote_model("gpt-overlay", "Overlay", /*priority*/ 0);
overlay.supports_image_detail_original = true;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
Some(ModelsResponse {
models: vec![overlay],
}),
CollaborationModesConfig::default(),
);
let model_info = manager
.get_model_info("gpt-overlay-experiment", &config)
.await;
assert_eq!(model_info.slug, "gpt-overlay-experiment");
assert_eq!(model_info.display_name, "Overlay");
assert_eq!(model_info.context_window, Some(272_000));
assert!(model_info.supports_image_detail_original);
assert!(!model_info.supports_parallel_tool_calls);
assert!(!model_info.used_fallback_model_metadata);
}
#[tokio::test]
async fn get_model_info_matches_namespaced_suffix() {
let codex_home = tempdir().expect("temp dir");
let config = ModelsManagerConfig::default();
let mut remote = remote_model("gpt-image", "Image", /*priority*/ 0);
remote.supports_image_detail_original = true;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
Some(ModelsResponse {
models: vec![remote],
}),
CollaborationModesConfig::default(),
);
let namespaced_model = "custom/gpt-image".to_string();
let model_info = manager.get_model_info(&namespaced_model, &config).await;
assert_eq!(model_info.slug, namespaced_model);
assert!(model_info.supports_image_detail_original);
assert!(!model_info.used_fallback_model_metadata);
}
#[tokio::test]
async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() {
let codex_home = tempdir().expect("temp dir");
let config = ModelsManagerConfig::default();
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
);
let known_slug = manager
.get_remote_models()
.await
.first()
.expect("bundled models should include at least one model")
.slug
.clone();
let namespaced_model = format!("ns1/ns2/{known_slug}");
let model_info = manager.get_model_info(&namespaced_model, &config).await;
assert_eq!(model_info.slug, namespaced_model);
assert!(model_info.used_fallback_model_metadata);
}
#[tokio::test]
async fn refresh_available_models_sorts_by_priority() {
let server = MockServer::start().await;
let remote_models = vec![
remote_model("priority-low", "Low", /*priority*/ 1),
remote_model("priority-high", "High", /*priority*/ 0),
];
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: remote_models.clone(),
},
)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = provider_for(server.uri());
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("refresh succeeds");
let cached_remote = manager.get_remote_models().await;
assert_models_contain(&cached_remote, &remote_models);
let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await;
let high_idx = available
.iter()
.position(|model| model.model == "priority-high")
.expect("priority-high should be listed");
let low_idx = available
.iter()
.position(|model| model.model == "priority-low")
.expect("priority-low should be listed");
assert!(
high_idx < low_idx,
"higher priority should be listed before lower priority"
);
assert_eq!(
models_mock.requests().len(),
1,
"expected a single /models request"
);
}
#[tokio::test]
async fn refresh_available_models_uses_provider_auth_token() {
let server = MockServer::start().await;
let auth_script = ProviderAuthScript::new(&["provider-token"]).unwrap();
let remote_models = vec![remote_model(
"provider-model",
"Provider",
/*priority*/ 0,
)];
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 codex_home = tempdir().expect("temp dir");
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("unused"));
let provider = ModelProviderInfo {
auth: Some(auth_script.auth_config()),
..provider_for(server.uri())
};
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::Online)
.await
.expect("refresh succeeds");
assert_models_contain(&manager.get_remote_models().await, &remote_models);
}
#[tokio::test]
async fn refresh_available_models_uses_cache_when_fresh() {
let server = MockServer::start().await;
let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)];
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: remote_models.clone(),
},
)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = provider_for(server.uri());
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("first refresh succeeds");
assert_models_contain(&manager.get_remote_models().await, &remote_models);
// Second call should read from cache and avoid the network.
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("cached refresh succeeds");
assert_models_contain(&manager.get_remote_models().await, &remote_models);
assert_eq!(
models_mock.requests().len(),
1,
"cache hit should avoid a second /models request"
);
}
#[tokio::test]
async fn refresh_available_models_refetches_when_cache_stale() {
let server = MockServer::start().await;
let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)];
let initial_mock = mount_models_once(
&server,
ModelsResponse {
models: initial_models.clone(),
},
)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = provider_for(server.uri());
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("initial refresh succeeds");
// Rewrite cache with an old timestamp so it is treated as stale.
manager
.cache_manager
.manipulate_cache_for_test(|fetched_at| {
*fetched_at = Utc::now() - chrono::Duration::hours(1);
})
.await
.expect("cache manipulation succeeds");
let updated_models = vec![remote_model("fresh", "Fresh", /*priority*/ 9)];
server.reset().await;
let refreshed_mock = mount_models_once(
&server,
ModelsResponse {
models: updated_models.clone(),
},
)
.await;
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("second refresh succeeds");
assert_models_contain(&manager.get_remote_models().await, &updated_models);
assert_eq!(
initial_mock.requests().len(),
1,
"initial refresh should only hit /models once"
);
assert_eq!(
refreshed_mock.requests().len(),
1,
"stale cache refresh should fetch /models once"
);
}
#[tokio::test]
async fn refresh_available_models_refetches_when_version_mismatch() {
let server = MockServer::start().await;
let initial_models = vec![remote_model("old", "Old", /*priority*/ 1)];
let initial_mock = mount_models_once(
&server,
ModelsResponse {
models: initial_models.clone(),
},
)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = provider_for(server.uri());
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("initial refresh succeeds");
manager
.cache_manager
.mutate_cache_for_test(|cache| {
let client_version = crate::client_version_to_whole();
cache.client_version = Some(format!("{client_version}-mismatch"));
})
.await
.expect("cache mutation succeeds");
let updated_models = vec![remote_model("new", "New", /*priority*/ 2)];
server.reset().await;
let refreshed_mock = mount_models_once(
&server,
ModelsResponse {
models: updated_models.clone(),
},
)
.await;
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("second refresh succeeds");
assert_models_contain(&manager.get_remote_models().await, &updated_models);
assert_eq!(
initial_mock.requests().len(),
1,
"initial refresh should only hit /models once"
);
assert_eq!(
refreshed_mock.requests().len(),
1,
"version mismatch should fetch /models once"
);
}
#[tokio::test]
async fn refresh_available_models_drops_removed_remote_models() {
let server = MockServer::start().await;
let initial_models = vec![remote_model(
"remote-old",
"Remote Old",
/*priority*/ 1,
)];
let initial_mock = mount_models_once(
&server,
ModelsResponse {
models: initial_models,
},
)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = provider_for(server.uri());
let mut manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager.cache_manager.set_ttl(Duration::ZERO);
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("initial refresh succeeds");
server.reset().await;
let refreshed_models = vec![remote_model(
"remote-new",
"Remote New",
/*priority*/ 1,
)];
let refreshed_mock = mount_models_once(
&server,
ModelsResponse {
models: refreshed_models,
},
)
.await;
manager
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
.await
.expect("second refresh succeeds");
let available = manager
.try_list_models()
.expect("models should be available");
assert!(
available.iter().any(|preset| preset.model == "remote-new"),
"new remote model should be listed"
);
assert!(
!available.iter().any(|preset| preset.model == "remote-old"),
"removed remote model should not be listed"
);
assert_eq!(
initial_mock.requests().len(),
1,
"initial refresh should only hit /models once"
);
assert_eq!(
refreshed_mock.requests().len(),
1,
"second refresh should only hit /models once"
);
}
#[tokio::test]
async fn refresh_available_models_skips_network_without_chatgpt_auth() {
let server = MockServer::start().await;
let dynamic_slug = "dynamic-model-only-for-test-noauth";
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model(dynamic_slug, "No Auth", /*priority*/ 1)],
},
)
.await;
let codex_home = tempdir().expect("temp dir");
let auth_manager = Arc::new(AuthManager::new(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
));
let provider = provider_for(server.uri());
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
manager
.refresh_available_models(RefreshStrategy::Online)
.await
.expect("refresh should no-op without chatgpt auth");
let cached_remote = manager.get_remote_models().await;
assert!(
!cached_remote
.iter()
.any(|candidate| candidate.slug == dynamic_slug),
"remote refresh should be skipped without chatgpt auth"
);
assert_eq!(
models_mock.requests().len(),
0,
"no auth should avoid /models requests"
);
}
#[test]
fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() {
let tags = Arc::new(Mutex::new(BTreeMap::new()));
let _guard = tracing_subscriber::registry()
.with(TagCollectorLayer { tags: tags.clone() })
.set_default();
let telemetry = ModelsRequestTelemetry {
auth_mode: Some(TelemetryAuthMode::Chatgpt.to_string()),
auth_header_attached: true,
auth_header_name: Some("authorization"),
auth_env: codex_login::AuthEnvTelemetry {
openai_api_key_env_present: false,
codex_api_key_env_present: false,
codex_api_key_env_enabled: false,
provider_env_key_name: Some("configured".to_string()),
provider_env_key_present: Some(false),
refresh_token_url_override_present: false,
},
};
let mut headers = HeaderMap::new();
headers.insert("x-request-id", "req-models-401".parse().unwrap());
headers.insert("cf-ray", "ray-models-401".parse().unwrap());
headers.insert(
"x-openai-authorization-error",
"missing_authorization_header".parse().unwrap(),
);
headers.insert(
"x-error-json",
base64::engine::general_purpose::STANDARD
.encode(r#"{"error":{"code":"token_expired"}}"#)
.parse()
.unwrap(),
);
telemetry.on_request(
/*attempt*/ 1,
Some(StatusCode::UNAUTHORIZED),
Some(&TransportError::Http {
status: StatusCode::UNAUTHORIZED,
url: Some("https://example.test/models".to_string()),
headers: Some(headers),
body: Some("plain text error".to_string()),
}),
Duration::from_millis(17),
);
let tags = tags.lock().unwrap().clone();
assert_eq!(
tags.get("endpoint").map(String::as_str),
Some("\"/models\"")
);
assert_eq!(
tags.get("auth_mode").map(String::as_str),
Some("\"Chatgpt\"")
);
assert_eq!(
tags.get("auth_request_id").map(String::as_str),
Some("\"req-models-401\"")
);
assert_eq!(
tags.get("auth_error").map(String::as_str),
Some("\"missing_authorization_header\"")
);
assert_eq!(
tags.get("auth_error_code").map(String::as_str),
Some("\"token_expired\"")
);
assert_eq!(
tags.get("auth_env_openai_api_key_present")
.map(String::as_str),
Some("false")
);
assert_eq!(
tags.get("auth_env_codex_api_key_present")
.map(String::as_str),
Some("false")
);
assert_eq!(
tags.get("auth_env_codex_api_key_enabled")
.map(String::as_str),
Some("false")
);
assert_eq!(
tags.get("auth_env_provider_key_name").map(String::as_str),
Some("\"configured\"")
);
assert_eq!(
tags.get("auth_env_provider_key_present")
.map(String::as_str),
Some("\"false\"")
);
assert_eq!(
tags.get("auth_env_refresh_token_url_override_present")
.map(String::as_str),
Some("false")
);
}
#[test]
fn build_available_models_picks_default_after_hiding_hidden_models() {
let codex_home = tempdir().expect("temp dir");
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let provider = provider_for("http://example.test".to_string());
let manager = ModelsManager::with_provider_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
);
let hidden_model =
remote_model_with_visibility("hidden", "Hidden", /*priority*/ 0, "hide");
let visible_model =
remote_model_with_visibility("visible", "Visible", /*priority*/ 1, "list");
let expected_hidden = ModelPreset::from(hidden_model.clone());
let mut expected_visible = ModelPreset::from(visible_model.clone());
expected_visible.is_default = true;
let available = manager.build_available_models(vec![hidden_model, visible_model]);
assert_eq!(available, vec![expected_hidden, expected_visible]);
}
#[test]
fn bundled_models_json_roundtrips() {
let response = crate::bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
let serialized =
serde_json::to_string(&response).expect("bundled models.json should serialize");
let roundtripped: ModelsResponse =
serde_json::from_str(&serialized).expect("serialized models.json should deserialize");
assert_eq!(
response, roundtripped,
"bundled models.json should round trip through serde"
);
assert!(
!response.models.is_empty(),
"bundled models.json should contain at least one model"
);
}
+113
View File
@@ -0,0 +1,113 @@
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelInstructionsVariables;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::TruncationMode;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::openai_models::WebSearchToolType;
use codex_protocol::openai_models::default_input_modalities;
use crate::config::ModelsManagerConfig;
use codex_utils_output_truncation::approx_bytes_for_tokens;
use tracing::warn;
pub const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
const DEFAULT_PERSONALITY_HEADER: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.";
const LOCAL_FRIENDLY_TEMPLATE: &str =
"You optimize for team morale and being a supportive teammate as much as code quality.";
const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer.";
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) -> ModelInfo {
if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries
&& supports_reasoning_summaries
{
model.supports_reasoning_summaries = true;
}
if let Some(context_window) = config.model_context_window {
model.context_window = Some(context_window);
}
if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit {
model.auto_compact_token_limit = Some(auto_compact_token_limit);
}
if let Some(token_limit) = config.tool_output_token_limit {
model.truncation_policy = match model.truncation_policy.mode {
TruncationMode::Bytes => {
let byte_limit =
i64::try_from(approx_bytes_for_tokens(token_limit)).unwrap_or(i64::MAX);
TruncationPolicyConfig::bytes(byte_limit)
}
TruncationMode::Tokens => {
let limit = i64::try_from(token_limit).unwrap_or(i64::MAX);
TruncationPolicyConfig::tokens(limit)
}
};
}
if let Some(base_instructions) = &config.base_instructions {
model.base_instructions = base_instructions.clone();
model.model_messages = None;
} else if !config.personality_enabled {
model.model_messages = None;
}
model
}
/// Build a minimal fallback model descriptor for missing/unknown slugs.
pub fn model_info_from_slug(slug: &str) -> ModelInfo {
warn!("Unknown model {slug} is used. This will use fallback model metadata.");
ModelInfo {
slug: slug.to_string(),
display_name: slug.to_string(),
description: None,
default_reasoning_level: None,
supported_reasoning_levels: Vec::new(),
shell_type: ConfigShellToolType::Default,
visibility: ModelVisibility::None,
supported_in_api: true,
priority: 99,
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
model_messages: local_personality_messages_for_slug(slug),
supports_reasoning_summaries: false,
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
apply_patch_tool_type: None,
web_search_tool_type: WebSearchToolType::Text,
truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
context_window: Some(272_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
input_modalities: default_input_modalities(),
used_fallback_model_metadata: true, // this is the fallback model metadata
supports_search_tool: false,
}
}
fn local_personality_messages_for_slug(slug: &str) -> Option<ModelMessages> {
match slug {
"gpt-5.2-codex" | "exp-codex-personality" => Some(ModelMessages {
instructions_template: Some(format!(
"{DEFAULT_PERSONALITY_HEADER}\n\n{PERSONALITY_PLACEHOLDER}\n\n{BASE_INSTRUCTIONS}"
)),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some(String::new()),
personality_friendly: Some(LOCAL_FRIENDLY_TEMPLATE.to_string()),
personality_pragmatic: Some(LOCAL_PRAGMATIC_TEMPLATE.to_string()),
}),
}),
_ => None,
}
}
#[cfg(test)]
#[path = "model_info_tests.rs"]
mod tests;
@@ -0,0 +1,52 @@
use codex_login::AuthManager;
use codex_login::CodexAuth;
use crate::ModelsManagerConfig;
use crate::collaboration_mode_presets::CollaborationModesConfig;
use crate::manager::ModelsManager;
use codex_protocol::openai_models::TruncationPolicyConfig;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn offline_model_info_without_tool_output_override() {
let codex_home = TempDir::new().expect("create temp dir");
let config = ModelsManagerConfig::default();
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
);
let model_info = manager.get_model_info("gpt-5.1", &config).await;
assert_eq!(
model_info.truncation_policy,
TruncationPolicyConfig::bytes(/*limit*/ 10_000)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn offline_model_info_with_tool_output_override() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = ModelsManagerConfig::default();
config.tool_output_token_limit = Some(123);
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
/*model_catalog*/ None,
CollaborationModesConfig::default(),
);
let model_info = manager.get_model_info("gpt-5.1-codex", &config).await;
assert_eq!(
model_info.truncation_policy,
TruncationPolicyConfig::tokens(/*limit*/ 123)
);
}
@@ -0,0 +1,39 @@
use super::*;
use crate::ModelsManagerConfig;
use pretty_assertions::assert_eq;
#[test]
fn reasoning_summaries_override_true_enables_support() {
let model = model_info_from_slug("unknown-model");
let mut config = ModelsManagerConfig::default();
config.model_supports_reasoning_summaries = Some(true);
let updated = with_config_overrides(model.clone(), &config);
let mut expected = model;
expected.supports_reasoning_summaries = true;
assert_eq!(updated, expected);
}
#[test]
fn reasoning_summaries_override_false_does_not_disable_support() {
let mut model = model_info_from_slug("unknown-model");
model.supports_reasoning_summaries = true;
let mut config = ModelsManagerConfig::default();
config.model_supports_reasoning_summaries = Some(false);
let updated = with_config_overrides(model.clone(), &config);
assert_eq!(updated, model);
}
#[test]
fn reasoning_summaries_override_false_is_noop_when_model_is_false() {
let model = model_info_from_slug("unknown-model");
let mut config = ModelsManagerConfig::default();
config.model_supports_reasoning_summaries = Some(false);
let updated = with_config_overrides(model.clone(), &config);
assert_eq!(updated, model);
}
@@ -0,0 +1,6 @@
/// Legacy notice keys kept for config compatibility with older migration prompts.
///
/// Hardcoded model presets were removed; model listings are now derived from the active catalog.
pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt";
pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str =
"hide_gpt-5.1-codex-max_migration_prompt";