From 2bfb7ef5f6a6e1f9543077e1bee3b25fa6f93108 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 10 Dec 2025 10:28:30 -0800 Subject: [PATCH] config model --- .../app-server/src/codex_message_processor.rs | 4 +- codex-rs/app-server/src/models.rs | 8 +- codex-rs/app-server/tests/common/lib.rs | 3 + .../app-server/tests/common/models_cache.rs | 74 ++++++++++ .../app-server/tests/suite/v2/model_list.rs | 4 + codex-rs/common/src/config_summary.rs | 4 +- codex-rs/core/src/client.rs | 8 +- codex-rs/core/src/codex.rs | 62 ++++----- codex-rs/core/src/config/mod.rs | 21 +-- codex-rs/core/src/conversation_manager.rs | 19 ++- codex-rs/core/src/model_provider_info.rs | 80 +++++------ .../core/src/openai_models/model_family.rs | 4 + .../core/src/openai_models/models_manager.rs | 106 +++++++++++++-- codex-rs/core/src/tasks/review.rs | 2 + .../core/tests/chat_completions_payload.rs | 21 ++- codex-rs/core/tests/chat_completions_sse.rs | 5 +- codex-rs/core/tests/common/responses.rs | 31 +++++ codex-rs/core/tests/common/test_codex.rs | 14 +- codex-rs/core/tests/responses_headers.rs | 19 ++- codex-rs/core/tests/suite/client.rs | 126 +++++++++++------- codex-rs/core/tests/suite/compact.rs | 82 ++++++++---- .../core/tests/suite/compact_resume_fork.rs | 24 ++-- .../core/tests/suite/fork_conversation.rs | 5 +- codex-rs/core/tests/suite/list_models.rs | 22 ++- codex-rs/core/tests/suite/model_overrides.rs | 14 +- codex-rs/core/tests/suite/prompt_caching.rs | 29 +++- codex-rs/core/tests/suite/remote_models.rs | 91 ++++++++++--- codex-rs/core/tests/suite/resume_warning.rs | 14 +- codex-rs/core/tests/suite/review.rs | 27 ++-- codex-rs/core/tests/suite/rmcp_client.rs | 8 +- codex-rs/core/tests/suite/unified_exec.rs | 51 ++----- codex-rs/core/tests/suite/user_shell_cmd.rs | 12 +- .../src/event_processor_with_human_output.rs | 3 +- codex-rs/exec/src/lib.rs | 5 +- codex-rs/lmstudio/src/lib.rs | 5 +- codex-rs/ollama/src/lib.rs | 5 +- codex-rs/tui/src/app.rs | 52 +++++--- codex-rs/tui/src/app_backtrack.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 23 ++-- codex-rs/tui/src/chatwidget/tests.rs | 52 ++++---- codex-rs/tui/src/history_cell.rs | 35 ++--- codex-rs/tui/src/status/card.rs | 7 +- codex-rs/tui/src/status/helpers.rs | 4 +- codex-rs/tui/src/status/tests.rs | 78 +++++++---- 44 files changed, 838 insertions(+), 429 deletions(-) create mode 100644 codex-rs/app-server/tests/common/models_cache.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8576c5c381c..7876cccf89c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1885,7 +1885,7 @@ impl CodexMessageProcessor { async fn list_models(&self, request_id: RequestId, params: ModelListParams) { let ModelListParams { limit, cursor } = params; - let models = supported_models(self.conversation_manager.clone()).await; + let models = supported_models(self.conversation_manager.clone(), &self.config).await; let total = models.len(); if total == 0 { @@ -2796,7 +2796,7 @@ impl CodexMessageProcessor { })?; let mut config = self.config.as_ref().clone(); - config.model = self.config.review_model.clone(); + config.model = Some(self.config.review_model.clone()); let NewConversation { conversation_id, diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 3ac71e85b90..21411603547 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -3,12 +3,16 @@ use std::sync::Arc; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ConversationManager; +use codex_core::config::Config; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub async fn supported_models(conversation_manager: Arc) -> Vec { +pub async fn supported_models( + conversation_manager: Arc, + config: &Config, +) -> Vec { conversation_manager - .list_models() + .list_models(config) .await .into_iter() .map(model_from_preset) diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 6fd54a66dc4..825b063c988 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,6 +1,7 @@ mod auth_fixtures; mod mcp_process; mod mock_model_server; +mod models_cache; mod responses; mod rollout; @@ -14,6 +15,8 @@ pub use core_test_support::format_with_current_shell_display; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; pub use mock_model_server::create_mock_chat_completions_server_unchecked; +pub use models_cache::write_models_cache; +pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs new file mode 100644 index 00000000000..8306e343941 --- /dev/null +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -0,0 +1,74 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_core::openai_models::model_presets::all_model_presets; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use serde_json::json; +use std::path::Path; + +/// Convert a ModelPreset to ModelInfo for cache storage. +fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { + ModelInfo { + slug: preset.id.clone(), + display_name: preset.display_name.clone(), + description: Some(preset.description.clone()), + default_reasoning_level: preset.default_reasoning_effort, + supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), + shell_type: ConfigShellToolType::ShellCommand, + visibility: if preset.show_in_picker { + ModelVisibility::List + } else { + ModelVisibility::Hide + }, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority, + upgrade: preset.upgrade.as_ref().map(|u| u.id.clone()), + base_instructions: None, + } +} + +/// 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. +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) + let presets: Vec<&ModelPreset> = all_model_presets() + .iter() + .filter(|preset| preset.show_in_picker) + .collect(); + // Convert presets to ModelInfo, assigning priorities (higher = earlier in list) + // Priority is used for sorting, so first model gets highest priority + let models: Vec = presets + .iter() + .enumerate() + .map(|(idx, preset)| { + // Higher priority = earlier in list, so reverse the index + let priority = (presets.len() - idx) as i32; + preset_to_info(preset, priority) + }) + .collect(); + + write_models_cache_with_models(codex_home, models) +} + +/// Write a models_cache.json file with specific models. +/// Useful when tests need specific models to be available. +pub fn write_models_cache_with_models( + codex_home: &Path, + models: Vec, +) -> std::io::Result<()> { + let cache_path = codex_home.join("models_cache.json"); + // DateTime serializes to RFC3339 format by default with serde + let fetched_at: DateTime = Utc::now(); + let cache = json!({ + "fetched_at": fetched_at, + "etag": null, + "models": models + }); + std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?) +} 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 8ca85c9c3b9..0e0f607e268 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -4,6 +4,7 @@ use anyhow::Result; use anyhow::anyhow; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_models_cache; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::Model; @@ -22,6 +23,7 @@ const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -151,6 +153,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { #[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -248,6 +251,7 @@ async fn list_models_pagination_works() -> Result<()> { #[tokio::test] async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/common/src/config_summary.rs b/codex-rs/common/src/config_summary.rs index 32b837f1f52..5a5901880f2 100644 --- a/codex-rs/common/src/config_summary.rs +++ b/codex-rs/common/src/config_summary.rs @@ -4,10 +4,10 @@ use codex_core::config::Config; use crate::sandbox_summary::summarize_sandbox_policy; /// Build a list of key/value pairs summarizing the effective configuration. -pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { +pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'static str, String)> { let mut entries = vec![ ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), + ("model", model.to_string()), ("provider", config.model_provider_id.clone()), ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d4a714cdd52..72c23a3ea40 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -166,7 +166,7 @@ impl ModelClient { let stream_result = client .stream_prompt( - &self.config.model, + &self.get_model(), &api_prompt, Some(conversation_id.clone()), Some(session_source.clone()), @@ -260,7 +260,7 @@ impl ModelClient { }; let stream_result = client - .stream_prompt(&self.config.model, &api_prompt, options) + .stream_prompt(&self.get_model(), &api_prompt, options) .await; match stream_result { @@ -292,7 +292,7 @@ impl ModelClient { /// Returns the currently configured model slug. pub fn get_model(&self) -> String { - self.config.model.clone() + self.get_model_family().get_model_slug().to_string() } /// Returns the currently configured model family. @@ -337,7 +337,7 @@ impl ModelClient { .get_full_instructions(&self.get_model_family()) .into_owned(); let payload = ApiCompactionInput { - model: &self.config.model, + model: &self.get_model(), input: &prompt.input, instructions: &instructions, }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6f637d143c7..22570ad1b35 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -181,10 +181,15 @@ impl Codex { let exec_policy = Arc::new(RwLock::new(exec_policy)); let config = Arc::new(config); - + if config.features.enabled(Feature::RemoteModels) + && let Err(err) = models_manager.refresh_available_models(&config).await + { + error!("failed to refresh available models: {err:?}"); + } + let model = models_manager.get_model(&config.model, &config).await; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model: model.clone(), model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -398,10 +403,11 @@ pub(crate) struct SessionSettingsUpdate { } impl Session { + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.model = session_configuration.model.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.features = config.features.clone(); @@ -421,7 +427,7 @@ impl Session { ) -> TurnContext { let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), - model_family.slug.as_str(), + model_family.get_model_slug(), ); let per_turn_config = Arc::new(per_turn_config); @@ -544,14 +550,11 @@ impl Session { }); } - let model_family = models_manager - .construct_model_family(&config.model, &config) - .await; // todo(aibrahim): why are we passing model here while it can change? let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), - model_family.slug.as_str(), + session_configuration.model.as_str(), + session_configuration.model.as_str(), auth_manager.auth().and_then(|a| a.get_account_id()), auth_manager.auth().and_then(|a| a.get_account_email()), auth_manager.auth().map(|a| a.mode), @@ -780,7 +783,7 @@ impl Session { let model_family = self .services .models_manager - .construct_model_family(&per_turn_config.model, &per_turn_config) + .construct_model_family(session_configuration.model.as_str(), &per_turn_config) .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -1444,16 +1447,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv let mut previous_context: Option> = Some(sess.new_turn(SessionSettingsUpdate::default()).await); - if config.features.enabled(Feature::RemoteModels) - && let Err(err) = sess - .services - .models_manager - .refresh_available_models(&config.model_provider) - .await - { - error!("failed to refresh available models: {err}"); - } - // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); @@ -1925,7 +1918,6 @@ async fn spawn_review_thread( // Build per‑turn client with the requested model/family. let mut per_turn_config = (*config).clone(); - per_turn_config.model = model.clone(); per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; per_turn_config.features = review_features.clone(); @@ -1934,7 +1926,7 @@ async fn spawn_review_thread( .client .get_otel_event_manager() .with_model( - per_turn_config.model.as_str(), + config.review_model.as_str(), review_model_family.slug.as_str(), ); @@ -2555,9 +2547,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2626,9 +2619,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2803,7 +2797,7 @@ mod tests { ) -> OtelEventManager { OtelEventManager::new( conversation_id, - config.model.as_str(), + ModelsManager::get_model_offline(config.model.as_deref()).as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -2827,9 +2821,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2844,8 +2839,10 @@ mod tests { session_source: SessionSource::Exec, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_family = - ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &model_family); @@ -2909,9 +2906,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2926,8 +2924,10 @@ mod tests { session_source: SessionSource::Exec, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_family = - ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &model_family); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bdf7a541772..e0e6985a39d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -59,7 +59,6 @@ pub mod edit; pub mod profile; pub mod types; -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -73,7 +72,7 @@ pub const CONFIG_TOML_FILE: &str = "config.toml"; #[derive(Debug, Clone, PartialEq)] pub struct Config { /// Optional override of model selection. - pub model: String, + pub model: Option, /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, @@ -1108,11 +1107,7 @@ impl Config { let forced_login_method = cfg.forced_login_method; - // todo(aibrahim): make model optional - let model = model - .or(config_profile.model) - .or(cfg.model) - .unwrap_or_else(default_model); + let model = model.or(config_profile.model).or(cfg.model); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -1313,10 +1308,6 @@ impl Config { } } -fn default_model() -> String { - OPENAI_DEFAULT_MODEL.to_string() -} - fn default_review_model() -> String { OPENAI_DEFAULT_REVIEW_MODEL.to_string() } @@ -2940,7 +2931,7 @@ model_verbosity = "high" )?; assert_eq!( Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3014,7 +3005,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt3_profile_config = Config { - model: "gpt-3.5-turbo".to_string(), + model: Some("gpt-3.5-turbo".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3103,7 +3094,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_zdr_profile_config = Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3178,7 +3169,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt5_profile_config = Config { - model: "gpt-5.1".to_string(), + model: Some("gpt-5.1".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index b1818849eb4..f340e1a8333 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,5 +1,7 @@ use crate::AuthManager; use crate::CodexAuth; +#[cfg(any(test, feature = "test-support"))] +use crate::ModelProviderInfo; use crate::codex::Codex; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; @@ -54,11 +56,14 @@ impl ConversationManager { #[cfg(any(test, feature = "test-support"))] /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. - pub fn with_auth(auth: CodexAuth) -> Self { - Self::new( - crate::AuthManager::from_auth_for_testing(auth), - SessionSource::Exec, - ) + pub fn with_models_provider(auth: CodexAuth, provider: ModelProviderInfo) -> Self { + let auth_manager = crate::AuthManager::from_auth_for_testing(auth); + Self { + conversations: Arc::new(RwLock::new(HashMap::new())), + auth_manager: auth_manager.clone(), + session_source: SessionSource::Exec, + models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)), + } } pub fn session_source(&self) -> SessionSource { @@ -213,8 +218,8 @@ impl ConversationManager { self.finalize_spawn(codex, conversation_id).await } - pub async fn list_models(&self) -> Vec { - self.models_manager.list_models().await + pub async fn list_models(&self, config: &Config) -> Vec { + self.models_manager.list_models(config).await } pub fn get_models_manager(&self) -> Arc { diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 4912a64694b..82072fc2aa5 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -208,6 +208,45 @@ impl ModelProviderInfo { .map(Duration::from_millis) .unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS)) } + pub fn create_openai_provider() -> ModelProviderInfo { + ModelProviderInfo { + name: "OpenAI".into(), + // Allow users to override the default OpenAI endpoint by + // exporting `OPENAI_BASE_URL`. This is useful when pointing + // Codex at a proxy, mock server, or Azure-style deployment + // without requiring a full TOML override for the built-in + // OpenAI provider. + base_url: std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some( + [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] + .into_iter() + .collect(), + ), + env_http_headers: Some( + [ + ( + "OpenAI-Organization".to_string(), + "OPENAI_ORGANIZATION".to_string(), + ), + ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), + ] + .into_iter() + .collect(), + ), + // Use global defaults for retry/timeout unless overridden in config.toml. + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: true, + } + } } pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234; @@ -225,46 +264,7 @@ pub fn built_in_model_providers() -> HashMap { // open source ("oss") providers by default. Users are encouraged to add to // `model_providers` in config.toml to add their own providers. [ - ( - "openai", - P { - name: "OpenAI".into(), - // Allow users to override the default OpenAI endpoint by - // exporting `OPENAI_BASE_URL`. This is useful when pointing - // Codex at a proxy, mock server, or Azure-style deployment - // without requiring a full TOML override for the built-in - // OpenAI provider. - base_url: std::env::var("OPENAI_BASE_URL") - .ok() - .filter(|v| !v.trim().is_empty()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some( - [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] - .into_iter() - .collect(), - ), - env_http_headers: Some( - [ - ( - "OpenAI-Organization".to_string(), - "OPENAI_ORGANIZATION".to_string(), - ), - ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), - ] - .into_iter() - .collect(), - ), - // Use global defaults for retry/timeout unless overridden in config.toml. - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: true, - }, - ), + ("openai", P::create_openai_provider()), ( OLLAMA_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat), diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 8a3853d60bf..2cc6fd08442 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -116,6 +116,10 @@ impl ModelFamily { const fn default_auto_compact_limit(context_window: i64) -> i64 { (context_window * 9) / 10 } + + pub fn get_model_slug(&self) -> &str { + &self.slug + } } macro_rules! model_family { diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 03dbd39d3d4..de9aa0f7c87 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -21,6 +21,7 @@ use crate::auth::AuthManager; use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::Result as CoreResult; +use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; @@ -28,6 +29,8 @@ use crate::openai_models::model_presets::builtin_model_presets; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); +const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; +const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; /// Coordinates remote model discovery plus cached metadata on disk. #[derive(Debug)] @@ -39,6 +42,7 @@ pub struct ModelsManager { etag: RwLock>, codex_home: PathBuf, cache_ttl: Duration, + provider: ModelProviderInfo, } impl ModelsManager { @@ -52,18 +56,37 @@ impl ModelsManager { etag: RwLock::new(None), codex_home, cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider: ModelProviderInfo::create_openai_provider(), + } + } + + #[cfg(any(test, feature = "test-support"))] + /// Construct a manager scoped to the provided `AuthManager` with a specific provider. Used for integration tests. + pub fn with_provider(auth_manager: Arc, provider: ModelProviderInfo) -> Self { + let codex_home = auth_manager.codex_home().to_path_buf(); + Self { + available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), + remote_models: RwLock::new(Vec::new()), + auth_manager, + etag: RwLock::new(None), + codex_home, + cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider, } } /// Fetch the latest remote models, using the on-disk cache when still fresh. - pub async fn refresh_available_models(&self, provider: &ModelProviderInfo) -> CoreResult<()> { + pub async fn refresh_available_models(&self, config: &Config) -> CoreResult<()> { + if !config.features.enabled(Feature::RemoteModels) { + return Ok(()); + } if self.try_load_cache().await { return Ok(()); } let auth = self.auth_manager.auth(); - let api_provider = provider.to_api_provider(Some(AuthMode::ChatGPT))?; - let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; + let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?; let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); @@ -81,7 +104,10 @@ impl ModelsManager { Ok(()) } - pub async fn list_models(&self) -> Vec { + pub async fn list_models(&self, config: &Config) -> Vec { + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } self.available_models.read().await.clone() } @@ -98,6 +124,33 @@ impl ModelsManager { .with_remote_overrides(self.remote_models.read().await.clone()) } + pub async fn get_model(&self, model: &Option, config: &Config) -> String { + if let Some(model) = model.as_ref() { + return model.to_string(); + } + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } + // if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model + let auth_mode = self.auth_manager.get_auth_mode(); + if auth_mode == Some(AuthMode::ChatGPT) + && self + .available_models + .read() + .await + .iter() + .any(|m| m.model == CODEX_AUTO_BALANCED_MODEL) + { + return CODEX_AUTO_BALANCED_MODEL.to_string(); + } + OPENAI_DEFAULT_MODEL.to_string() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_model_offline(model: Option<&str>) -> String { + model.unwrap_or(OPENAI_DEFAULT_MODEL).to_string() + } + #[cfg(any(test, feature = "test-support"))] /// Offline helper that builds a `ModelFamily` without consulting remote state. pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { @@ -112,6 +165,7 @@ impl ModelsManager { /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. async fn try_load_cache(&self) -> bool { + // todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk let cache_path = self.cache_path(); let cache = match cache::load_cache(&cache_path).await { Ok(cache) => cache, @@ -197,6 +251,10 @@ mod tests { use super::*; use crate::CodexAuth; use crate::auth::AuthCredentialsStoreMode; + use crate::config::Config; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::features::Feature; use crate::model_provider_info::WireApi; use codex_protocol::openai_models::ModelsResponse; use core_test_support::responses::mount_models_once; @@ -256,19 +314,27 @@ mod tests { ) .await; + let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("refresh succeeds"); let cached_remote = manager.remote_models.read().await.clone(); assert_eq!(cached_remote, remote_models); - let available = manager.list_models().await; + let available = manager.list_models(&config).await; assert_eq!(available.len(), 2); assert_eq!(available[0].model, "priority-high"); assert!( @@ -298,16 +364,23 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, )); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("first refresh succeeds"); assert_eq!( @@ -318,7 +391,7 @@ mod tests { // Second call should read from cache and avoid the network. manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("cached refresh succeeds"); assert_eq!( @@ -347,16 +420,23 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, )); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("initial refresh succeeds"); @@ -382,7 +462,7 @@ mod tests { .await; manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("second refresh succeeds"); assert_eq!( diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 5c2e8d08b9a..da7f29d4ad6 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -92,6 +92,8 @@ async fn start_review_conversation( // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); + + sub_agent_config.model = Some(config.review_model.clone()); (run_codex_conversation_one_shot( sub_agent_config, session.auth_manager(), diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 1449a833dae..6bfad437833 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use std::sync::Arc; use codex_app_server_protocol::AuthMode; @@ -71,10 +73,11 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -108,11 +111,15 @@ async fn run_request(input: Vec) -> Value { } } - let requests = match server.received_requests().await { - Some(reqs) => reqs, - None => panic!("request not made"), - }; - match requests[0].body_json() { + let all_requests = server.received_requests().await.expect("received requests"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); + let request = requests + .first() + .unwrap_or_else(|| panic!("expected POST request to /chat/completions")); + match request.body_json() { Ok(v) => v, Err(e) => panic!("invalid json body: {e}"), } diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index fe7ec58945a..9124d59d13c 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -74,10 +74,11 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let auth_mode = auth_manager.get_auth_mode(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index c67daeda875..b98b29625eb 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -689,6 +689,33 @@ pub async fn start_mock_server() -> MockServer { server } +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get all POST requests to `/responses` endpoints from the mock server. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_requests(server: &MockServer) -> Vec { + server + .received_requests() + .await + .expect("mock server should not fail") + .into_iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/responses")) + .collect() +} + +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get request bodies as JSON values from POST requests to `/responses` endpoints. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_request_bodies(server: &MockServer) -> Vec { + get_responses_requests(server) + .await + .into_iter() + .map(|req| { + req.body_json::() + .expect("request body to be valid JSON") + }) + .collect() +} + #[derive(Clone)] pub struct FunctionCallResponseMocks { pub function_call: ResponseMock, @@ -769,6 +796,10 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> Res /// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call` /// in the `input` must have a matching output entry. fn validate_request_body_invariants(request: &wiremock::Request) { + // Skip GET requests (e.g., /models) + if request.method != "POST" || !request.url.path().ends_with("/responses") { + return; + } let Ok(body): Result = request.body_json() else { return; }; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 23bcadadf15..5f38dbd4b50 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -23,6 +23,7 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::load_default_config_for_test; +use crate::responses::get_responses_request_bodies; use crate::responses::start_mock_server; use crate::wait_for_event; @@ -69,7 +70,7 @@ impl TestCodexBuilder { pub fn with_model(self, model: &str) -> Self { let new_model = model.to_string(); self.with_config(move |config| { - config.model = new_model.clone(); + config.model = Some(new_model.clone()); }) } @@ -96,7 +97,8 @@ impl TestCodexBuilder { let (config, cwd) = self.prepare_config(server, &home).await?; let auth = self.auth.clone(); - let conversation_manager = ConversationManager::with_auth(auth.clone()); + let conversation_manager = + ConversationManager::with_models_provider(auth.clone(), config.model_provider.clone()); let new_conversation = match resume_from { Some(path) => { @@ -272,13 +274,7 @@ impl TestCodexHarness { } pub async fn request_bodies(&self) -> Vec { - self.server - .received_requests() - .await - .expect("requests") - .into_iter() - .map(|req| serde_json::from_slice(&req.body).expect("request body json")) - .collect() + get_responses_request_bodies(&self.server).await } pub async fn function_call_output_value(&self, call_id: &str) -> Value { diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index d79de721671..934c327a6c9 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -61,14 +61,16 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -151,15 +153,17 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -235,7 +239,7 @@ async fn responses_respects_model_family_overrides_from_config() { let codex_home = TempDir::new().expect("failed to create TempDir"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); config.model_supports_reasoning_summaries = Some(true); @@ -243,15 +247,16 @@ async fn responses_respects_model_family_overrides_from_config() { config.model_reasoning_summary = ReasoningSummary::Detailed; let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = config.model.clone().expect("model configured"); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")).get_auth_mode(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8b3d63a4140..faa9801f86f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -30,7 +30,12 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; -use core_test_support::responses; +use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::get_responses_requests; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::sse; +use core_test_support::responses::sse_failed; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; @@ -240,7 +245,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Mock server that will receive the resumed request let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; // Configure Codex to resume from our file let model_provider = ModelProviderInfo { @@ -253,8 +258,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Also configure user instructions to ensure they are NOT delivered on resume. config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let NewConversation { @@ -337,8 +344,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -360,7 +369,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -381,7 +393,7 @@ async fn includes_base_instructions_override_in_request() { skip_if_no_network!(); // Mock server let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -393,8 +405,10 @@ async fn includes_base_instructions_override_in_request() { config.base_instructions = Some("test instructions".to_string()); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -451,7 +465,10 @@ async fn chatgpt_auth_sends_correct_request() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -473,7 +490,10 @@ async fn chatgpt_auth_sends_correct_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -569,7 +589,7 @@ async fn includes_user_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -581,8 +601,10 @@ async fn includes_user_instructions_message_in_request() { config.model_provider = model_provider; config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -627,7 +649,7 @@ async fn skills_append_to_instructions_when_feature_enabled() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -648,8 +670,10 @@ async fn skills_append_to_instructions_when_feature_enabled() { config.features.enable(Feature::Skills); config.cwd = codex_home.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -695,7 +719,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -734,7 +758,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .build(&server) @@ -771,7 +795,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_fami skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -804,7 +828,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -837,7 +861,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -875,7 +899,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1") .with_config(|config| { @@ -914,7 +938,7 @@ async fn includes_developer_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -927,8 +951,10 @@ async fn includes_developer_instructions_message_in_request() { config.user_instructions = Some("be nice".to_string()); config.developer_instructions = Some("be useful".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1014,13 +1040,15 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -1103,11 +1131,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { } } - let requests = server - .received_requests() - .await - .expect("mock server collected requests"); - assert_eq!(requests.len(), 1, "expected a single request"); + let requests = get_responses_requests(&server).await; + assert_eq!(requests.len(), 1, "expected a single POST request"); let body: serde_json::Value = requests[0] .body_json() .expect("request body to be valid JSON"); @@ -1128,7 +1153,7 @@ async fn token_count_includes_rate_limits_snapshot() { skip_if_no_network!(); let server = MockServer::start().await; - let sse_body = responses::sse(vec![responses::ev_completed_with_tokens("resp_rate", 123)]); + let sse_body = sse(vec![ev_completed_with_tokens("resp_rate", 123)]); let response = ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") @@ -1154,7 +1179,10 @@ async fn token_count_includes_rate_limits_snapshot() { let mut config = load_default_config_for_test(&home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1361,10 +1389,10 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res const EFFECTIVE_CONTEXT_WINDOW: i64 = (272_000 * 95) / 100; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("trigger context window"), - responses::sse_failed( + sse_failed( "resp_context_window", "context_length_exceeded", "Your input exceeds the context window of this model. Please adjust your input and try again.", @@ -1372,7 +1400,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res ) .await; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("seed turn"), sse_completed("resp_seed"), @@ -1381,7 +1409,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.model = "gpt-5.1".to_string(); + config.model = Some("gpt-5.1".to_string()); config.model_context_window = Some(272_000); }) .build(&server) @@ -1505,7 +1533,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1583,7 +1614,10 @@ async fn env_var_overrides_loaded_auth() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1661,8 +1695,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -1699,7 +1735,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Inspect the three captured requests. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)"); // Replace full-array compare with tail-only raw JSON compare using a single hard-coded value. diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index aa74ec89782..521a76845ab 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -28,6 +28,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; +use core_test_support::responses::get_responses_requests; use core_test_support::responses::mount_compact_json_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; @@ -135,7 +136,10 @@ async fn summarize_context_three_requests_and_instructions() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -329,7 +333,10 @@ async fn manual_compact_uses_custom_prompt() { config.model_provider = model_provider; config.compact_prompt = Some(custom_prompt.to_string()); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -344,7 +351,7 @@ async fn manual_compact_uses_custom_prompt() { assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("collect requests"); + let requests = get_responses_requests(&server).await; let body = requests .iter() .find_map(|req| req.body_json::().ok()) @@ -409,7 +416,10 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -570,7 +580,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // collect the requests payloads from the model - let requests_payloads = server.received_requests().await.unwrap(); + let requests_payloads = get_responses_requests(&server).await; let body = requests_payloads[0] .body_json::() @@ -1050,7 +1060,10 @@ async fn auto_compact_runs_after_token_limit_hit() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1090,7 +1103,7 @@ async fn auto_compact_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!( requests.len(), 5, @@ -1295,7 +1308,10 @@ async fn auto_compact_persists_rollout_entries() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -1397,11 +1413,14 @@ async fn manual_compact_retries_after_context_window_error() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1529,11 +1548,14 @@ async fn manual_compact_twice_preserves_latest_user_messages() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1731,7 +1753,10 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1771,10 +1796,8 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "auto compact should not emit task lifecycle events" ); - let request_bodies: Vec = server - .received_requests() - .await - .unwrap() + let requests = get_responses_requests(&server).await; + let request_bodies: Vec = requests .into_iter() .map(|request| String::from_utf8(request.body).unwrap_or_default()) .collect(); @@ -1845,11 +1868,14 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { config.model_context_window = Some(context_window); config.model_auto_compact_token_limit = Some(limit); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index f81294baf30..5d3d9e4b8a7 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -26,6 +26,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::get_responses_request_bodies; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::wait_for_event; @@ -771,17 +772,11 @@ fn normalize_line_endings(value: &mut Value) { } async fn gather_request_bodies(server: &MockServer) -> Vec { - server - .received_requests() - .await - .expect("mock server should not fail") - .into_iter() - .map(|req| { - let mut value = req.body_json::().expect("valid JSON body"); - normalize_line_endings(&mut value); - value - }) - .collect() + let mut bodies = get_responses_request_bodies(server).await; + for body in &mut bodies { + normalize_line_endings(body); + } + bodies } async fn mount_initial_flow(server: &MockServer) { @@ -870,9 +865,12 @@ async fn start_test_conversation( config.model_provider = model_provider; config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); if let Some(model) = model { - config.model = model.to_string(); + config.model = Some(model.to_string()); } - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation, .. } = manager .new_conversation(config.clone()) .await diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index 75b37ae7ef2..a82b4762147 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -55,7 +55,10 @@ async fn fork_conversation_twice_drops_to_first_message() { config.model_provider = model_provider.clone(); let config_for_fork = config.clone(); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 6348841c6fb..70df5174f3e 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -1,15 +1,23 @@ use anyhow::Result; use codex_core::CodexAuth; use codex_core::ConversationManager; +use codex_core::built_in_model_providers; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; +use tempfile::tempdir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_api_key_models() -> Result<()> { - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("sk-test")); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("sk-test"), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_api_key(); assert_eq!(expected_models, models); @@ -19,9 +27,13 @@ async fn list_models_returns_api_key_models() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_chatgpt_models() -> Result<()> { - let manager = - ConversationManager::with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_chatgpt(); assert_eq!(expected_models, models); diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index f67196312fc..53a45e67868 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -20,10 +20,12 @@ async fn override_turn_context_does_not_persist_when_config_exists() { .expect("seed config.toml"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-4o".to_string(); + config.model = Some("gpt-4o".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -62,8 +64,10 @@ async fn override_turn_context_does_not_create_config_file() { let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 2bc71298d43..94158df6d85 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -71,7 +71,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); - config.model = "codex-mini-latest".to_string(); + config.model = Some("codex-mini-latest".to_string()); }) .build(&server) .await?; @@ -131,12 +131,19 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.model = Some("gpt-5.1-codex-max".to_string()); }) .build(&server) .await?; let base_instructions = conversation_manager .get_models_manager() - .construct_model_family(&config.model, &config) + .construct_model_family( + config + .model + .as_deref() + .expect("test config should have a model"), + &config, + ) .await .base_instructions .clone(); @@ -572,7 +579,12 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -582,7 +594,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -659,7 +671,12 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -669,7 +686,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 0f80407473e..707ab6fa45a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -3,6 +3,12 @@ use std::sync::Arc; use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::built_in_model_providers; +use codex_core::config::Config; use codex_core::features::Feature; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::AskForApproval; @@ -20,6 +26,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::user_input::UserInput; +use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -30,11 +37,10 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use serde_json::json; +use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; use tokio::time::sleep; @@ -80,21 +86,23 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { + let harness = build_remote_models_harness(&server, |config| { config.features.enable(Feature::RemoteModels); - config.model = "gpt-5.1".to_string(); - }); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; - let TestCodex { + let RemoteModelsHarness { codex, cwd, config, conversation_manager, .. - } = builder.build(&server).await?; + } = harness; let models_manager = conversation_manager.get_models_manager(); - let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; + let available_model = + wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG, &config).await; assert_eq!(available_model.model, REMOTE_MODEL_SLUG); @@ -218,20 +226,22 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { + let harness = build_remote_models_harness(&server, |config| { config.features.enable(Feature::RemoteModels); - config.model = "gpt-5.1".to_string(); - }); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; - let TestCodex { + let RemoteModelsHarness { codex, cwd, + config, conversation_manager, .. - } = builder.build(&server).await?; + } = harness; let models_manager = conversation_manager.get_models_manager(); - wait_for_model_available(&models_manager, model).await; + wait_for_model_available(&models_manager, model, &config).await; codex .submit(Op::OverrideTurnContext { @@ -268,11 +278,15 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { Ok(()) } -async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { +async fn wait_for_model_available( + manager: &Arc, + slug: &str, + config: &Config, +) -> ModelPreset { let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(model) = { - let guard = manager.list_models().await; + let guard = manager.list_models(config).await; guard.iter().find(|model| model.model == slug).cloned() } { return model; @@ -283,3 +297,48 @@ async fn wait_for_model_available(manager: &Arc, slug: &str) -> M sleep(Duration::from_millis(25)).await; } } + +struct RemoteModelsHarness { + codex: Arc, + cwd: Arc, + config: Config, + conversation_manager: Arc, +} + +// todo(aibrahim): move this to with_model_provier in test_codex +async fn build_remote_models_harness( + server: &MockServer, + mutate_config: F, +) -> Result +where + F: FnOnce(&mut Config), +{ + let auth = CodexAuth::from_api_key("dummy"); + let home = Arc::new(TempDir::new()?); + let cwd = Arc::new(TempDir::new()?); + + let mut config = load_default_config_for_test(&home); + config.cwd = cwd.path().to_path_buf(); + config.features.enable(Feature::RemoteModels); + + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + config.model_provider = provider.clone(); + + mutate_config(&mut config); + + let conversation_manager = Arc::new(ConversationManager::with_models_provider(auth, provider)); + + let new_conversation = conversation_manager + .new_conversation(config.clone()) + .await?; + + Ok(RemoteModelsHarness { + codex: new_conversation.conversation, + cwd, + config, + conversation_manager, + }) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index c8376e41099..cb83ab06dc5 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -4,6 +4,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::NewConversation; +use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InitialHistory; use codex_core::protocol::ResumedHistory; @@ -16,7 +17,11 @@ use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use tempfile::TempDir; -fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory { +fn resume_history( + config: &codex_core::config::Config, + previous_model: &str, + rollout_path: &std::path::Path, +) -> InitialHistory { let turn_ctx = TurnContextItem { cwd: config.cwd.clone(), approval_policy: config.approval_policy, @@ -38,7 +43,7 @@ async fn emits_warning_when_resumed_model_differs() { // Arrange a config with a current model and a prior rollout recorded under a different model. let home = TempDir::new().expect("tempdir"); let mut config = load_default_config_for_test(&home); - config.model = "current-model".to_string(); + config.model = Some("current-model".to_string()); // Ensure cwd is absolute (the helper sets it to the temp dir already). assert!(config.cwd.is_absolute()); @@ -47,7 +52,10 @@ async fn emits_warning_when_resumed_model_differs() { let initial_history = resume_history(&config, "previous-model", &rollout_path); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); // Act: resume the conversation. diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index b3a52cfa540..ca8af6ad1e2 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -23,6 +23,7 @@ use codex_core::review_format::render_review_output_text; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; +use core_test_support::responses::get_responses_requests; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -394,7 +395,7 @@ async fn review_uses_custom_review_model_from_config() { let codex_home = TempDir::new().unwrap(); // Choose a review model different from the main model; ensure it is used. let codex = new_conversation_for_server(&server, &codex_home, |cfg| { - cfg.model = "gpt-4.1".to_string(); + cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = "gpt-5.1".to_string(); }) .await; @@ -425,7 +426,10 @@ async fn review_uses_custom_review_model_from_config() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request body model equals the configured review model - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); assert_eq!(body["model"].as_str().unwrap(), "gpt-5.1"); @@ -543,7 +547,10 @@ async fn review_input_isolated_from_parent_history() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request `input` contains the environment context followed by the user review prompt. - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); assert_eq!( @@ -673,7 +680,7 @@ async fn review_history_surfaces_in_parent_session() { // Inspect the second request (parent turn) input contents. // Parent turns include session initial messages (user_instructions, environment_context). // Critically, no messages from the review thread should appear. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 2); let body = requests[1].body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); @@ -743,8 +750,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); conversation_manager .new_conversation(config) .await @@ -770,8 +779,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); conversation_manager diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cc653a9c56e..ef2fc16ede0 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -487,9 +487,13 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { // Chat Completions assertion: the second POST should include a tool role message // with an array `content` containing an item with the expected data URL. - let requests = server.received_requests().await.expect("requests captured"); + let all_requests = server.received_requests().await.expect("requests captured"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); assert!(requests.len() >= 2, "expected two chat completion calls"); - let second = &requests[1]; + let second = requests[1]; let body: Value = serde_json::from_slice(&second.body)?; let messages = body .get("messages") diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index e2dcb0c5679..15ce32e53f1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -18,6 +18,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::get_responses_request_bodies; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -1240,10 +1241,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let metadata = outputs @@ -1347,10 +1345,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs @@ -1475,10 +1470,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1727,10 +1719,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1864,10 +1853,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1976,10 +1962,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -2065,10 +2048,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let large_output = outputs.get(call_id).expect("missing large output summary"); @@ -2145,10 +2125,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs.get(call_id).expect("missing output"); @@ -2246,10 +2223,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let startup_output = outputs @@ -2339,10 +2313,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs.get(call_id).expect("missing output"); diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 964cc58d506..8472399ce42 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -42,8 +42,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -99,8 +101,10 @@ async fn user_shell_cmd_can_be_interrupted() { // Set up isolated config and conversation. let codex_home = TempDir::new().unwrap(); let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 6eec8b71fcf..1da0796a752 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -140,7 +140,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { VERSION ); - let mut entries = create_config_summary_entries(config); + let mut entries = + create_config_summary_entries(config, session_configured_event.model.as_str()); entries.push(( "session id", session_configured_event.session_id.to_string(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7dfeeecf7b9..7d7d4c301fb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -263,7 +263,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -278,6 +277,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config.cli_auth_credentials_store_mode, ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); + let default_model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; // Handle resume subcommand by resolving a rollout path and using explicit resume API. let NewConversation { diff --git a/codex-rs/lmstudio/src/lib.rs b/codex-rs/lmstudio/src/lib.rs index bb8c8cef6a3..fd4f82a728a 100644 --- a/codex-rs/lmstudio/src/lib.rs +++ b/codex-rs/lmstudio/src/lib.rs @@ -11,7 +11,10 @@ pub const DEFAULT_OSS_MODEL: &str = "openai/gpt-oss-20b"; /// - Ensures a local LM Studio server is reachable. /// - Checks if the model exists locally and downloads it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { - let model: &str = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local LM Studio is reachable. let lmstudio_client = LMStudioClient::try_from_provider(config).await?; diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 0ebf1662ac2..4ced3b62760 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -19,7 +19,10 @@ pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b"; /// - Checks if the model exists locally and pulls it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { // Only download when the requested model is the default OSS model (or when -m is not provided). - let model = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local Ollama is reachable. let ollama_client = crate::OllamaClient::try_from_oss_provider(config).await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0a09b15e7c8..1ce3b4fd519 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -123,14 +123,15 @@ fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Optio async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, + model: &str, app_event_tx: &AppEventSender, auth_mode: Option, models_manager: Arc, ) -> Option { - let available_models = models_manager.list_models().await; + let available_models = models_manager.list_models(config).await; let upgrade = available_models .iter() - .find(|preset| preset.model == config.model) + .find(|preset| preset.model == model) .and_then(|preset| preset.upgrade.as_ref()); if let Some(ModelUpgrade { @@ -146,7 +147,7 @@ async fn handle_model_migration_prompt_if_needed( let target_model = target_model.to_string(); let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); if !should_show_model_migration_prompt( - &config.model, + model, &target_model, hide_prompt_flag, available_models.clone(), @@ -160,7 +161,7 @@ async fn handle_model_migration_prompt_if_needed( app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { migration_config: migration_config_key.to_string(), }); - config.model = target_model.to_string(); + config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -207,6 +208,7 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, + pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -269,9 +271,14 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); + let mut model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; let exit_info = handle_model_migration_prompt_if_needed( tui, &mut config, + model.as_str(), &app_event_tx, auth_mode, conversation_manager.get_models_manager(), @@ -280,6 +287,9 @@ impl App { if let Some(exit_info) = exit_info { return Ok(exit_info); } + if let Some(updated_model) = config.model.clone() { + model = updated_model; + } let skills_outcome = load_skills(&config); if !skills_outcome.errors.is_empty() { @@ -304,7 +314,7 @@ impl App { let enhanced_keys_supported = tui.enhanced_keys_supported(); let model_family = conversation_manager .get_models_manager() - .construct_model_family(&config.model, &config) + .construct_model_family(model.as_str(), &config) .await; let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { @@ -320,7 +330,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, - model_family, + model_family: model_family.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -347,7 +357,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, - model_family, + model_family: model_family.clone(), }; ChatWidget::new_from_existing( init, @@ -369,6 +379,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, + current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -489,7 +500,7 @@ impl App { let model_family = self .server .get_models_manager() - .construct_model_family(&self.config.model, &self.config) + .construct_model_family(self.current_model.as_str(), &self.config) .await; match event { AppEvent::NewSession => { @@ -510,9 +521,10 @@ impl App { feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, - model_family, + model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -567,6 +579,7 @@ impl App { resumed.conversation, resumed.session_configured, ); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -695,7 +708,7 @@ impl App { .construct_model_family(&model, &self.config) .await; self.chat_widget.set_model(&model, model_family); - self.config.model = model; + self.current_model = model; } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1167,9 +1180,11 @@ mod tests { fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); @@ -1180,6 +1195,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1204,9 +1220,11 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); @@ -1218,6 +1236,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1343,6 +1362,7 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), + app.current_model.as_str(), event, is_first, )) as Arc diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index ca9de52e2a5..deb629765a2 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -338,9 +338,10 @@ impl App { ) { let conv = new_conv.conversation; let session_configured = new_conv.session_configured; + let model_family = self.chat_widget.get_model_family(); let init = crate::chatwidget::ChatWidgetInit { config: cfg, - model_family: self.chat_widget.get_model_family(), + model_family: model_family.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, @@ -354,6 +355,7 @@ impl App { }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); + self.current_model = model_family.get_model_slug().to_string(); // Trim transcript up to the selected user message and re-render it. self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f9e53c80552..ea29c00d937 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -399,6 +399,7 @@ impl ChatWidget { self.session_header.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, + &model_for_header, event, self.show_welcome_banner, )); @@ -625,7 +626,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.config.model != NUDGE_MODEL_SLUG + && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1265,6 +1266,9 @@ impl ChatWidget { is_first_run, model_family, } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut config = config; + config.model = Some(model_slug.clone()); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); @@ -1284,11 +1288,11 @@ impl ChatWidget { skills, }), active_cell: None, - config: config.clone(), + config, model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1348,6 +1352,7 @@ impl ChatWidget { model_family, .. } = common; + let model_slug = model_family.get_model_slug().to_string(); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1369,11 +1374,11 @@ impl ChatWidget { skills, }), active_cell: None, - config: config.clone(), + config, model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -2035,6 +2040,7 @@ impl ChatWidget { self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), + self.model_family.get_model_slug(), )); } fn stop_rate_limit_poller(&mut self) { @@ -2177,7 +2183,7 @@ impl ChatWidget { /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { - let current_model = self.config.model.clone(); + let current_model = self.model_family.get_model_slug().to_string(); let presets: Vec = // todo(aibrahim): make this async function match self.models_manager.try_list_models() { @@ -2284,7 +2290,7 @@ impl ChatWidget { return; } - let current_model = self.config.model.clone(); + let current_model = self.model_family.get_model_slug().to_string(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { let description = @@ -2413,7 +2419,7 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.config.model == preset.model; + let is_current_model = self.model_family.get_model_slug() == preset.model; let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { @@ -2970,7 +2976,6 @@ impl ChatWidget { /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); - self.config.model = model.to_string(); self.model_family = model_family; } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 23554932503..c54f0da3d5a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -75,6 +75,7 @@ fn set_windows_sandbox_enabled(enabled: bool) { fn test_config() -> Config { // Use base defaults to avoid depending on host state. + Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), @@ -346,10 +347,12 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config(); - let model_family = ModelsManager::construct_model_family_offline(&cfg.model, &cfg); - let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "test", - ))); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); + let conversation_manager = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { config: cfg, @@ -382,8 +385,11 @@ fn make_chatwidget_manual( let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let mut cfg = test_config(); + let resolved_model = model_override + .map(str::to_owned) + .unwrap_or_else(|| ModelsManager::get_model_offline(cfg.model.as_deref())); if let Some(model) = model_override { - cfg.model = model.to_string(); + cfg.model = Some(model.to_string()); } let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), @@ -402,10 +408,10 @@ fn make_chatwidget_manual( bottom_pane: bottom, active_cell: None, config: cfg.clone(), - model_family: ModelsManager::construct_model_family_offline(&cfg.model, &cfg), + model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), - session_header: SessionHeader::new(cfg.model), + session_header: SessionHeader::new(resolved_model.clone()), initial_user_message: None, token_info: None, rate_limit_snapshot: None, @@ -650,10 +656,9 @@ fn rate_limit_snapshot_updates_and_retains_plan_type() { #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(None); + let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = NUDGE_MODEL_SLUG.to_string(); chat.on_rate_limit_snapshot(Some(snapshot(95.0))); @@ -666,8 +671,7 @@ fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[test] fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.on_rate_limit_snapshot(Some(snapshot(90.0))); @@ -691,8 +695,7 @@ fn rate_limit_switch_prompt_shows_once_per_session() { #[test] fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.config.notices.hide_rate_limit_model_nudge = Some(true); @@ -707,8 +710,7 @@ fn rate_limit_switch_prompt_respects_hidden_notice() { #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.bottom_pane.set_task_running(true); @@ -728,10 +730,9 @@ fn rate_limit_switch_prompt_defers_until_task_complete() { #[test] fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = "gpt-5".to_string(); chat.on_rate_limit_snapshot(Some(snapshot(92.0))); chat.maybe_show_pending_rate_limit_prompt(); @@ -1774,9 +1775,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[test] fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); - - chat.config.model = "gpt-5-codex".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); chat.open_model_popup(); let popup = render_bottom_popup(&chat, 80); @@ -1879,10 +1878,9 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { #[test] fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1894,10 +1892,9 @@ fn model_reasoning_selection_popup_snapshot() { #[test] fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1909,10 +1906,9 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[test] fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -1992,9 +1988,7 @@ fn feedback_upload_consent_popup_snapshot() { #[test] fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); - - chat.config.model = "gpt-5.1".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")); chat.open_model_popup(); let preset = get_available_model(&chat, "gpt-5.1-codex"); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 945ed1f4916..41470673668 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -621,6 +621,7 @@ impl HistoryCell for SessionInfoCell { pub(crate) fn new_session_info( config: &Config, + requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, ) -> SessionInfoCell { @@ -679,10 +680,10 @@ pub(crate) fn new_session_info( { parts.push(Box::new(tooltips)); } - if config.model != model { + if requested_model != model { let lines = vec![ "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), + format!("requested: {requested_model}").into(), format!("used: {model}").into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); @@ -2321,10 +2322,7 @@ mod tests { } #[test] fn reasoning_summary_block() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), reasoning_format, @@ -2339,10 +2337,7 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "Detailed reasoning goes here.".to_string(), reasoning_format, @@ -2355,10 +2350,11 @@ mod tests { #[test] fn reasoning_summary_block_respects_config_overrides() { let mut config = test_config(); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = + ModelsManager::construct_model_family_offline(&config.model.clone().unwrap(), &config); assert_eq!( model_family.reasoning_summary_format, ReasoningSummaryFormat::Experimental @@ -2375,10 +2371,7 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_header_is_missing() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), reasoning_format, @@ -2390,10 +2383,7 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), reasoning_format.clone(), @@ -2413,10 +2403,7 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), reasoning_format, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 7049d13fff1..aac981c764e 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -78,6 +78,7 @@ pub(crate) fn new_status_output( rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -90,6 +91,7 @@ pub(crate) fn new_status_output( rate_limits, plan_type, now, + model_name, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -107,9 +109,10 @@ impl StatusHistoryCell { rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> Self { - let config_entries = create_config_summary_entries(config); - let (model_name, model_details) = compose_model_display(config, &config_entries); + let config_entries = create_config_summary_entries(config, model_name); + let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() .find(|(k, _)| *k == "approval") diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index cb6b7b54b29..8ba7ec37751 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -17,7 +17,7 @@ fn normalize_agents_display_path(path: &Path) -> String { } pub(crate) fn compose_model_display( - config: &Config, + model_name: &str, entries: &[(&str, String)], ) -> (String, Vec) { let mut details: Vec = Vec::new(); @@ -33,7 +33,7 @@ pub(crate) fn compose_model_display( } } - (config.model.clone(), details) + (model_name.to_string(), details) } pub(crate) fn compose_agents_summary(config: &Config) -> String { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 1b16453c421..53c728526a2 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -39,8 +39,8 @@ fn test_auth_manager(config: &Config) -> AuthManager { ) } -fn test_model_family(config: &Config) -> ModelFamily { - ModelsManager::construct_model_family_offline(config.model.as_str(), config) +fn test_model_family(model_slug: &str, config: &Config) -> ModelFamily { + ModelsManager::construct_model_family_offline(model_slug, config) } fn render_lines(lines: &[Line<'static>]) -> Vec { @@ -88,7 +88,7 @@ fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -130,7 +130,8 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, @@ -142,6 +143,7 @@ fn status_snapshot_includes_reasoning_details() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -157,7 +159,7 @@ fn status_snapshot_includes_reasoning_details() { fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.cwd = PathBuf::from("/workspace/tests"); @@ -186,7 +188,8 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -197,6 +200,7 @@ fn status_snapshot_includes_monthly_limit() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -229,7 +233,8 @@ fn status_snapshot_shows_unlimited_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -240,6 +245,7 @@ fn status_snapshot_shows_unlimited_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -271,7 +277,8 @@ fn status_snapshot_shows_positive_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -282,6 +289,7 @@ fn status_snapshot_shows_positive_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -313,7 +321,8 @@ fn status_snapshot_hides_zero_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -324,6 +333,7 @@ fn status_snapshot_hides_zero_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -353,7 +363,8 @@ fn status_snapshot_hides_when_has_no_credits_flag() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -364,6 +375,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -376,7 +388,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -393,7 +405,8 @@ fn status_card_token_usage_excludes_cached_tokens() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -404,6 +417,7 @@ fn status_card_token_usage_excludes_cached_tokens() { None, None, now, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); @@ -417,7 +431,7 @@ fn status_card_token_usage_excludes_cached_tokens() { fn status_snapshot_truncates_in_narrow_terminal() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -448,7 +462,8 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -459,6 +474,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -475,7 +491,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -492,7 +508,8 @@ fn status_snapshot_shows_missing_limits_message() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -503,6 +520,7 @@ fn status_snapshot_shows_missing_limits_message() { None, None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -518,7 +536,7 @@ fn status_snapshot_shows_missing_limits_message() { fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -554,7 +572,8 @@ fn status_snapshot_includes_credits_and_limits() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -565,6 +584,7 @@ fn status_snapshot_includes_credits_and_limits() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -580,7 +600,7 @@ fn status_snapshot_includes_credits_and_limits() { fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -604,7 +624,8 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -615,6 +636,7 @@ fn status_snapshot_shows_empty_limits_message() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -630,7 +652,7 @@ fn status_snapshot_shows_empty_limits_message() { fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -663,7 +685,8 @@ fn status_snapshot_shows_stale_limits_message() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -674,6 +697,7 @@ fn status_snapshot_shows_stale_limits_message() { Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -689,7 +713,7 @@ fn status_snapshot_shows_stale_limits_message() { fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -726,7 +750,8 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -737,6 +762,7 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -775,7 +801,8 @@ fn status_context_window_uses_last_usage() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -786,6 +813,7 @@ fn status_context_window_uses_last_usage() { None, None, now, + &model_slug, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines