From 7e46e5b9c214554db68fd1f227dd93c7c5b5322f Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 23 Feb 2026 22:35:51 -0800 Subject: [PATCH] chore: rm hardcoded PRESETS list (#12650) rm `PRESETS` list harcoded in `model_presets` as we now have bundled `models.json` with equivalent info. update logic to rely on bundled models instead, update tests. --- codex-rs/Cargo.lock | 1 - codex-rs/Cargo.toml | 1 - .../app-server/tests/common/models_cache.rs | 6 +- .../app-server/tests/suite/v2/model_list.rs | 180 +++------ codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/config/mod.rs | 40 +- codex-rs/core/src/models_manager/manager.rs | 45 +-- .../core/src/models_manager/model_presets.rs | 371 +----------------- codex-rs/core/src/test_support.rs | 15 +- codex-rs/core/tests/suite/remote_models.rs | 8 +- codex-rs/protocol/src/openai_models.rs | 33 +- codex-rs/tui/src/app.rs | 9 +- codex-rs/tui/src/chatwidget/tests.rs | 12 +- ...gration_prompt_shows_for_hidden_model.snap | 8 +- 14 files changed, 154 insertions(+), 576 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c47be4d5a..718e627ca 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1734,7 +1734,6 @@ dependencies = [ "http 1.4.0", "image", "indexmap 2.13.0", - "indoc", "insta", "keyring", "landlock", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 64988910c..d61ae1ee2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -180,7 +180,6 @@ ignore = "0.4.23" image = { version = "^0.25.9", default-features = false } include_dir = "0.7.4" indexmap = "2.12.0" -indoc = "2.0" insta = "1.46.3" inventory = "0.3.19" itertools = "0.14.0" diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 5095bf0f3..218a1d0e4 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -24,7 +24,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { } else { ModelVisibility::Hide }, - supported_in_api: true, + supported_in_api: preset.supported_in_api, priority, upgrade: preset.upgrade.as_ref().map(|u| u.into()), base_instructions: "base instructions".to_string(), @@ -48,9 +48,9 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { /// Write a models_cache.json file to the codex home directory. /// This prevents ModelsManager from making network requests to refresh models. /// The cache will be treated as fresh (within TTL) and used instead of fetching from the network. -/// Uses the built-in model presets from ModelsManager, converted to ModelInfo format. +/// Uses bundled-catalog-derived presets, converted to ModelInfo format. pub fn write_models_cache(codex_home: &Path) -> std::io::Result<()> { - // Get all presets and filter for show_in_picker (same as builtin_model_presets does) + // Get a stable bundled-catalog-derived preset list and filter for picker-visible entries. let presets: Vec<&ModelPreset> = all_model_presets() .iter() .filter(|preset| preset.show_in_picker) diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index ebcbdf99e..759721249 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -12,8 +12,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; -use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ModelPreset; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -21,6 +20,48 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +fn model_from_preset(preset: &ModelPreset) -> Model { + Model { + id: preset.id.clone(), + model: preset.model.clone(), + upgrade: preset.upgrade.as_ref().map(|upgrade| upgrade.id.clone()), + display_name: preset.display_name.clone(), + description: preset.description.clone(), + hidden: !preset.show_in_picker, + supported_reasoning_efforts: preset + .supported_reasoning_efforts + .iter() + .map(|preset| ReasoningEffortOption { + reasoning_effort: preset.effort, + description: preset.description.clone(), + }) + .collect(), + default_reasoning_effort: preset.default_reasoning_effort, + input_modalities: preset.input_modalities.clone(), + // `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not + // preserve personality placeholders in base instructions, so app-server list results from + // cache report `supports_personality = false`. + // todo(sayan): fix, maybe make roundtrip use ModelInfo only + supports_personality: false, + is_default: preset.is_default, + } +} + +fn expected_visible_models() -> Vec { + // Filter by supported_in_api to support testing with both ChatGPT and non-ChatGPT auth modes. + let mut presets = + ModelPreset::filter_by_auth(codex_core::test_support::all_model_presets().clone(), false); + + // Mirror `ModelsManager::build_available_models()` default selection after auth filtering. + ModelPreset::mark_default_by_picker_visibility(&mut presets); + + presets + .iter() + .filter(|preset| preset.show_in_picker) + .map(model_from_preset) + .collect() +} + #[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; @@ -48,130 +89,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { next_cursor, } = to_response::(response)?; - let expected_models = vec![ - Model { - id: "gpt-5.2-codex".to_string(), - model: "gpt-5.2-codex".to_string(), - upgrade: None, - display_name: "gpt-5.2-codex".to_string(), - description: "Latest frontier agentic coding model.".to_string(), - hidden: false, - supported_reasoning_efforts: vec![ - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks" - .to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::High, - description: "Greater reasoning depth for complex problems".to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - default_reasoning_effort: ReasoningEffort::Medium, - input_modalities: vec![InputModality::Text, InputModality::Image], - supports_personality: false, - is_default: true, - }, - Model { - id: "gpt-5.1-codex-max".to_string(), - model: "gpt-5.1-codex-max".to_string(), - upgrade: Some("gpt-5.2-codex".to_string()), - display_name: "gpt-5.1-codex-max".to_string(), - description: "Codex-optimized flagship for deep and fast reasoning.".to_string(), - hidden: false, - supported_reasoning_efforts: vec![ - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks" - .to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::High, - description: "Greater reasoning depth for complex problems".to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - default_reasoning_effort: ReasoningEffort::Medium, - input_modalities: vec![InputModality::Text, InputModality::Image], - supports_personality: false, - is_default: false, - }, - Model { - id: "gpt-5.1-codex-mini".to_string(), - model: "gpt-5.1-codex-mini".to_string(), - upgrade: Some("gpt-5.2-codex".to_string()), - display_name: "gpt-5.1-codex-mini".to_string(), - description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), - hidden: false, - supported_reasoning_efforts: vec![ - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ], - default_reasoning_effort: ReasoningEffort::Medium, - input_modalities: vec![InputModality::Text, InputModality::Image], - supports_personality: false, - is_default: false, - }, - Model { - id: "gpt-5.2".to_string(), - model: "gpt-5.2".to_string(), - upgrade: Some("gpt-5.2-codex".to_string()), - display_name: "gpt-5.2".to_string(), - description: - "Latest frontier model with improvements across knowledge, reasoning and coding" - .to_string(), - hidden: false, - supported_reasoning_efforts: vec![ - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward \ - queries and short explanations" - .to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for \ - general-purpose tasks" - .to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ReasoningEffortOption { - reasoning_effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - default_reasoning_effort: ReasoningEffort::Medium, - input_modalities: vec![InputModality::Text, InputModality::Image], - supports_personality: false, - is_default: false, - }, - ]; + let expected_models = expected_visible_models(); assert_eq!(items, expected_models); assert!(next_cursor.is_none()); @@ -237,8 +155,10 @@ async fn list_models_pagination_works() -> Result<()> { next_cursor: first_cursor, } = to_response::(first_response)?; + let expected_models = expected_visible_models(); + assert_eq!(first_items.len(), 1); - assert_eq!(first_items[0].id, "gpt-5.2-codex"); + assert_eq!(first_items[0].id, expected_models[0].id); let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?; let second_request = mcp @@ -261,7 +181,7 @@ async fn list_models_pagination_works() -> Result<()> { } = to_response::(second_response)?; assert_eq!(second_items.len(), 1); - assert_eq!(second_items[0].id, "gpt-5.1-codex-max"); + assert_eq!(second_items[0].id, expected_models[1].id); let third_cursor = second_cursor.ok_or_else(|| anyhow!("cursor for third page"))?; let third_request = mcp @@ -284,7 +204,7 @@ async fn list_models_pagination_works() -> Result<()> { } = to_response::(third_response)?; assert_eq!(third_items.len(), 1); - assert_eq!(third_items[0].id, "gpt-5.1-codex-mini"); + assert_eq!(third_items[0].id, expected_models[2].id); let fourth_cursor = third_cursor.ok_or_else(|| anyhow!("cursor for fourth page"))?; let fourth_request = mcp @@ -307,7 +227,7 @@ async fn list_models_pagination_works() -> Result<()> { } = to_response::(fourth_response)?; assert_eq!(fourth_items.len(), 1); - assert_eq!(fourth_items[0].id, "gpt-5.2"); + assert_eq!(fourth_items[0].id, expected_models[3].id); assert!(fourth_cursor.is_none()); Ok(()) } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 15f9d7d11..5425b819d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -59,7 +59,6 @@ eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } indexmap = { workspace = true } -indoc = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } notify = { workspace = true } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ffd35e036..1f81eaca4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -665,7 +665,7 @@ pub(crate) fn deserialize_config_toml_with_base( fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result { let file_contents = std::fs::read_to_string(path)?; - serde_json::from_str::(&file_contents).map_err(|err| { + let catalog = serde_json::from_str::(&file_contents).map_err(|err| { std::io::Error::new( ErrorKind::InvalidData, format!( @@ -673,7 +673,17 @@ fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result path.display() ), ) - }) + })?; + if catalog.models.is_empty() { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + format!( + "model_catalog_json path `{}` must contain at least one model", + path.display() + ), + )); + } + Ok(catalog) } fn load_model_catalog( @@ -4466,6 +4476,32 @@ config_file = "./agents/researcher.toml" Ok(()) } + #[test] + fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let catalog_path = codex_home.path().join("catalog.json"); + std::fs::write(&catalog_path, r#"{"models":[]}"#)?; + + let cfg = ConfigToml { + model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + ..Default::default() + }; + + let err = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect_err("empty custom catalog should fail config load"); + + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!( + err.to_string().contains("must contain at least one model"), + "unexpected error: {err}" + ); + Ok(()) + } + fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 080532714..e43e318a1 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -10,7 +10,6 @@ use crate::error::Result as CoreResult; use crate::model_provider_info::ModelProviderInfo; use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use crate::models_manager::model_info; -use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_protocol::config_types::CollaborationModeMask; @@ -45,7 +44,6 @@ pub enum RefreshStrategy { /// Coordinates remote model discovery plus cached metadata on disk. #[derive(Debug)] pub struct ModelsManager { - local_models: Vec, remote_models: RwLock>, has_custom_model_catalog: bool, auth_manager: Arc, @@ -57,7 +55,7 @@ pub struct ModelsManager { impl ModelsManager { /// Construct a manager scoped to the provided `AuthManager`. /// - /// Uses `codex_home` to store cached model metadata and initializes with built-in presets. + /// 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( @@ -70,9 +68,11 @@ impl ModelsManager { let has_custom_model_catalog = model_catalog.is_some(); let remote_models = model_catalog .map(|catalog| catalog.models) - .unwrap_or_else(|| Self::load_remote_models_from_file().unwrap_or_default()); + .unwrap_or_else(|| { + Self::load_remote_models_from_file() + .unwrap_or_else(|err| panic!("failed to load bundled models.json: {err}")) + }); Self { - local_models: builtin_model_presets(auth_manager.auth_mode()), remote_models: RwLock::new(remote_models), has_custom_model_catalog, auth_manager, @@ -309,29 +309,17 @@ impl ModelsManager { true } - /// Merge remote model metadata into picker-ready presets, preserving existing entries. + /// Build picker-ready presets from the active catalog snapshot. fn build_available_models(&self, mut remote_models: Vec) -> Vec { remote_models.sort_by(|a, b| a.priority.cmp(&b.priority)); - let remote_presets: Vec = remote_models.into_iter().map(Into::into).collect(); - let existing_presets = self.local_models.clone(); - let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets); + let mut presets: Vec = remote_models.into_iter().map(Into::into).collect(); let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt)); - merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); + presets = ModelPreset::filter_by_auth(presets, chatgpt_mode); - for preset in &mut merged_presets { - preset.is_default = false; - } - if let Some(default) = merged_presets - .iter_mut() - .find(|preset| preset.show_in_picker) - { - default.is_default = true; - } else if let Some(default) = merged_presets.first_mut() { - default.is_default = true; - } + ModelPreset::mark_default_by_picker_visibility(&mut presets); - merged_presets + presets } async fn get_remote_models(&self) -> Vec { @@ -351,8 +339,10 @@ impl ModelsManager { let cache_path = codex_home.join(MODEL_CACHE_FILE); let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL); Self { - local_models: builtin_model_presets(auth_manager.auth_mode()), - remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()), + remote_models: RwLock::new( + Self::load_remote_models_from_file() + .unwrap_or_else(|err| panic!("failed to load bundled models.json: {err}")), + ), has_custom_model_catalog: false, auth_manager, etag: RwLock::new(None), @@ -366,7 +356,9 @@ impl ModelsManager { if let Some(model) = model { return model.to_string(); } - let presets = builtin_model_presets(None); + 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 = models.into_iter().map(Into::into).collect(); presets .iter() .find(|preset| preset.show_in_picker) @@ -862,12 +854,11 @@ mod tests { 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 mut manager = ModelsManager::with_provider_for_tests( + let manager = ModelsManager::with_provider_for_tests( codex_home.path().to_path_buf(), auth_manager, provider, ); - manager.local_models = Vec::new(); let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide"); let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list"); diff --git a/codex-rs/core/src/models_manager/model_presets.rs b/codex-rs/core/src/models_manager/model_presets.rs index dd24c1829..0039b52ea 100644 --- a/codex-rs/core/src/models_manager/model_presets.rs +++ b/codex-rs/core/src/models_manager/model_presets.rs @@ -1,371 +1,6 @@ -use crate::auth::AuthMode; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::openai_models::default_input_modalities; -use indoc::indoc; -use once_cell::sync::Lazy; - +/// 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"; - -pub(crate) static PRESETS: Lazy> = Lazy::new(|| { - vec![ - ModelPreset { - id: "gpt-5.2-codex".to_string(), - model: "gpt-5.2-codex".to_string(), - display_name: "gpt-5.2-codex".to_string(), - description: "Latest frontier agentic coding model.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Greater reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supports_personality: true, - is_default: true, - upgrade: None, - show_in_picker: true, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "gpt-5.1-codex-max".to_string(), - model: "gpt-5.1-codex-max".to_string(), - display_name: "gpt-5.1-codex-max".to_string(), - description: "Codex-optimized flagship for deep and fast reasoning.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Greater reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: true, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "gpt-5.1-codex-mini".to_string(), - model: "gpt-5.1-codex-mini".to_string(), - display_name: "gpt-5.1-codex-mini".to_string(), - description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: true, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "gpt-5.2".to_string(), - model: "gpt-5.2".to_string(), - display_name: "gpt-5.2".to_string(), - description: "Latest frontier model with improvements across knowledge, reasoning and coding".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: true, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "bengalfox".to_string(), - model: "bengalfox".to_string(), - display_name: "bengalfox".to_string(), - description: "bengalfox".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Greater reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supports_personality: true, - is_default: false, - upgrade: None, - show_in_picker: false, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "boomslang".to_string(), - model: "boomslang".to_string(), - display_name: "boomslang".to_string(), - description: "boomslang".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: None, - show_in_picker: false, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - // Deprecated models. - ModelPreset { - id: "gpt-5-codex".to_string(), - model: "gpt-5-codex".to_string(), - display_name: "gpt-5-codex".to_string(), - description: "Optimized for codex.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: false, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "gpt-5-codex-mini".to_string(), - model: "gpt-5-codex-mini".to_string(), - display_name: "gpt-5-codex-mini".to_string(), - description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: false, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "gpt-5.1-codex".to_string(), - model: "gpt-5.1-codex".to_string(), - display_name: "gpt-5.1-codex".to_string(), - description: "Optimized for codex.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: false, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "gpt-5".to_string(), - model: "gpt-5".to_string(), - display_name: "gpt-5".to_string(), - description: "Broad world knowledge with strong general reasoning.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Minimal, - description: "Fastest responses with little reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: false, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ModelPreset { - id: "gpt-5.1".to_string(), - model: "gpt-5.1".to_string(), - display_name: "gpt-5.1".to_string(), - description: "Broad world knowledge with strong general reasoning.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supports_personality: false, - is_default: false, - upgrade: Some(gpt_52_codex_upgrade()), - show_in_picker: false, - supported_in_api: true, - input_modalities: default_input_modalities(), - }, - ] -}); - -fn gpt_52_codex_upgrade() -> ModelUpgrade { - ModelUpgrade { - id: "gpt-5.2-codex".to_string(), - reasoning_effort_mapping: None, - migration_config_key: "gpt-5.2-codex".to_string(), - model_link: Some("https://openai.com/index/introducing-gpt-5-2-codex".to_string()), - upgrade_copy: Some( - "Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work." - .to_string(), - ), - migration_markdown: Some( - indoc! {r#" - **Codex just got an upgrade. Introducing {model_to}.** - - Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex - - You can continue using {model_from} if you prefer. - "#} - .to_string(), - ), - } -} - -pub(super) fn builtin_model_presets(_auth_mode: Option) -> Vec { - PRESETS.iter().cloned().collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_one_default_model_is_configured() { - let default_models = PRESETS.iter().filter(|preset| preset.is_default).count(); - assert!(default_models == 1); - } -} diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index c216ee2f8..5e6d611ad 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -10,6 +10,8 @@ use std::sync::Arc; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelsResponse; +use once_cell::sync::Lazy; use crate::AuthManager; use crate::CodexAuth; @@ -18,10 +20,19 @@ use crate::ThreadManager; use crate::config::Config; use crate::models_manager::collaboration_mode_presets; use crate::models_manager::manager::ModelsManager; -use crate::models_manager::model_presets; use crate::thread_manager; use crate::unified_exec; +static TEST_MODEL_PRESETS: Lazy> = Lazy::new(|| { + let file_contents = include_str!("../models.json"); + let mut response: ModelsResponse = serde_json::from_str(file_contents) + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + response.models.sort_by(|a, b| a.priority.cmp(&b.priority)); + let mut presets: Vec = response.models.into_iter().map(Into::into).collect(); + ModelPreset::mark_default_by_picker_visibility(&mut presets); + presets +}); + pub fn set_thread_manager_test_mode(enabled: bool) { thread_manager::set_thread_manager_test_mode_for_tests(enabled); } @@ -70,7 +81,7 @@ pub fn construct_model_info_offline(model: &str, config: &Config) -> ModelInfo { } pub fn all_model_presets() -> &'static Vec { - &model_presets::PRESETS + &TEST_MODEL_PRESETS } pub fn builtin_collaboration_mode_presets() -> Vec { diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index f4d0d1b84..e83399e8c 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -548,7 +548,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_models_preserve_builtin_presets() -> Result<()> { +async fn remote_models_do_not_append_removed_builtin_presets() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -593,12 +593,6 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { 1, "expected a single default model" ); - assert!( - available - .iter() - .any(|model| model.model == "gpt-5.1-codex-max"), - "builtin presets should remain available after refresh" - ); assert_eq!( models_mock.requests().len(), 1, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index e61860698..8d01e10d0 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -4,7 +4,6 @@ //! are used to preserve compatibility when older payloads omit newly introduced attributes. use std::collections::HashMap; -use std::collections::HashSet; use schemars::JsonSchema; use serde::Deserialize; @@ -425,32 +424,18 @@ impl ModelPreset { .collect() } - /// Merge remote presets with existing presets, preferring remote when slugs match. + /// Recompute the single default preset using picker visibility. /// - /// Remote presets take precedence. Existing presets not in remote are appended with `is_default` set to false. - pub fn merge( - remote_presets: Vec, - existing_presets: Vec, - ) -> Vec { - if remote_presets.is_empty() { - return existing_presets; - } - - let remote_slugs: HashSet<&str> = remote_presets - .iter() - .map(|preset| preset.model.as_str()) - .collect(); - - let mut merged_presets = remote_presets.clone(); - for mut preset in existing_presets { - if remote_slugs.contains(preset.model.as_str()) { - continue; - } + /// The first picker-visible model wins; if none are picker-visible, the first model wins. + pub fn mark_default_by_picker_visibility(models: &mut [ModelPreset]) { + for preset in models.iter_mut() { preset.is_default = false; - merged_presets.push(preset); } - - merged_presets + if let Some(default) = models.iter_mut().find(|preset| preset.show_in_picker) { + default.is_default = true; + } else if let Some(default) = models.first_mut() { + default.is_default = true; + } } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e554f6a60..936ff2d85 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3878,7 +3878,7 @@ mod tests { .await .expect("config"); - let available_models = all_model_presets(); + let mut available_models = all_model_presets(); let current = available_models .iter() .find(|preset| preset.model == "gpt-5.1-codex") @@ -3890,6 +3890,13 @@ mod tests { ); let upgrade = current.upgrade.as_ref().expect("upgrade configured"); + // Test "hidden current model still prompts" even if bundled + // catalog data changes the target model's picker visibility. + available_models + .iter_mut() + .find(|preset| preset.model == upgrade.id) + .expect("upgrade target present") + .show_in_picker = true; assert!( should_show_model_migration_prompt( ¤t.model, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1c01b6808..f05f7bd82 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4592,10 +4592,10 @@ async fn collab_mode_applies_default_preset() { #[tokio::test] async fn user_turn_includes_personality_from_config() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("bengalfox")).await; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; chat.set_feature_enabled(Feature::Personality, true); chat.thread_id = Some(ThreadId::new()); - chat.set_model("bengalfox"); + chat.set_model("gpt-5.2-codex"); chat.set_personality(Personality::Friendly); chat.bottom_pane @@ -5715,7 +5715,7 @@ async fn model_selection_popup_snapshot() { #[tokio::test] async fn personality_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("bengalfox")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; chat.thread_id = Some(ThreadId::new()); chat.open_personality_popup(); @@ -5763,8 +5763,8 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { #[tokio::test] async fn server_overloaded_error_does_not_switch_models() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("boomslang")).await; - chat.set_model("boomslang"); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + chat.set_model("gpt-5.2-codex"); while rx.try_recv().is_ok() {} while op_rx.try_recv().is_ok() {} @@ -5779,7 +5779,7 @@ async fn server_overloaded_error_does_not_switch_models() { while let Ok(event) = rx.try_recv() { if let AppEvent::UpdateModel(model) = event { assert_eq!( - model, "boomslang", + model, "gpt-5.2-codex", "did not expect model switch on server-overloaded error" ); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap index 4c9f59f4c..23fb02d56 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap @@ -2,8 +2,10 @@ source: tui/src/app.rs expression: model_migration_copy_to_plain_text(©) --- -**Codex just got an upgrade. Introducing gpt-5.2-codex.** +**Codex just got an upgrade. Introducing gpt-5.3-codex.** -Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about gpt-5.2-codex at https://openai.com/index/introducing-gpt-5-2-codex +Codex is now powered by gpt-5.3-codex, our most capable agentic coding model yet. It's built for long-running, project-scale work, with mid-turn steering + frequent progress updates so you can collaborate while it runs (and it's faster too). -You can continue using gpt-5.1-codex if you prefer. +Learn more: https://openai.com/index/introducing-gpt-5-3-codex/ + +You can keep using gpt-5.1-codex if you prefer.