make model optional in config (#7769)

- Make Config.model optional and centralize default-selection logic in
ModelsManager, including a default_model helper (with
codex-auto-balanced when available) so sessions now carry an explicit
chosen model separate from the base config.
- Resolve `model` once in `core` and `tui` from config. Then store the
state of it on other structs.
- Move refreshing models to be before resolving the default model
This commit is contained in:
Ahmed Ibrahim
2025-12-10 11:19:00 -08:00
committed by GitHub
parent 8a71f8b634
commit cb9a189857
44 changed files with 838 additions and 429 deletions
@@ -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,
+6 -2
View File
@@ -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<ConversationManager>) -> Vec<Model> {
pub async fn supported_models(
conversation_manager: Arc<ConversationManager>,
config: &Config,
) -> Vec<Model> {
conversation_manager
.list_models()
.list_models(config)
.await
.into_iter()
.map(model_from_preset)
+3
View File
@@ -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;
@@ -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<ModelInfo> = 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<ModelInfo>,
) -> std::io::Result<()> {
let cache_path = codex_home.join("models_cache.json");
// DateTime<Utc> serializes to RFC3339 format by default with serde
let fetched_at: DateTime<Utc> = Utc::now();
let cache = json!({
"fetched_at": fetched_at,
"etag": null,
"models": models
});
std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?)
}
@@ -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??;
+2 -2
View File
@@ -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)),
+4 -4
View File
@@ -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,
};
+31 -31
View File
@@ -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<Session>, config: Arc<Config>, rx_sub: Receiv
let mut previous_context: Option<Arc<TurnContext>> =
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 perturn 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);
+6 -15
View File
@@ -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<String>,
/// 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,
+12 -7
View File
@@ -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<ModelPreset> {
self.models_manager.list_models().await
pub async fn list_models(&self, config: &Config) -> Vec<ModelPreset> {
self.models_manager.list_models(config).await
}
pub fn get_models_manager(&self) -> Arc<ModelsManager> {
+40 -40
View File
@@ -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<String, ModelProviderInfo> {
// 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),
@@ -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 {
@@ -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<Option<String>>,
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<AuthManager>, 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<ModelPreset> {
pub async fn list_models(&self, config: &Config) -> Vec<ModelPreset> {
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<String>, 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!(
+2
View File
@@ -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(),
@@ -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<ResponseItem>) -> 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<ResponseItem>) -> 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}"),
}
+3 -2
View File
@@ -74,10 +74,11 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec<ResponseEvent> {
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()),
+31
View File
@@ -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<wiremock::Request> {
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<Value> {
get_responses_requests(server)
.await
.into_iter()
.map(|req| {
req.body_json::<Value>()
.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<String>) -> 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<Value, _> = request.body_json() else {
return;
};
+5 -9
View File
@@ -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<Value> {
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 {
+12 -7
View File
@@ -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()),
+81 -45
View File
@@ -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.
+54 -28
View File
@@ -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::<serde_json::Value>().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::<serde_json::Value>()
@@ -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<String> = server
.received_requests()
.await
.unwrap()
let requests = get_responses_requests(&server).await;
let request_bodies: Vec<String> = 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 {
@@ -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<Value> {
server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.map(|req| {
let mut value = req.body_json::<Value>().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
@@ -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,
..
+17 -5
View File
@@ -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);
+9 -5
View File
@@ -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
+23 -6
View File
@@ -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;
+75 -16
View File
@@ -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<ModelsManager>, slug: &str) -> ModelPreset {
async fn wait_for_model_available(
manager: &Arc<ModelsManager>,
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<ModelsManager>, slug: &str) -> M
sleep(Duration::from_millis(25)).await;
}
}
struct RemoteModelsHarness {
codex: Arc<CodexConversation>,
cwd: Arc<TempDir>,
config: Config,
conversation_manager: Arc<ConversationManager>,
}
// todo(aibrahim): move this to with_model_provier in test_codex
async fn build_remote_models_harness<F>(
server: &MockServer,
mutate_config: F,
) -> Result<RemoteModelsHarness>
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,
})
}
+11 -3
View File
@@ -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.
+19 -8
View File
@@ -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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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
+6 -2
View File
@@ -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")
+11 -40
View File
@@ -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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>().expect("request json"))
.collect::<Vec<_>>();
let bodies = get_responses_request_bodies(&server).await;
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs.get(call_id).expect("missing output");
+8 -4
View File
@@ -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,
..
@@ -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(),
+4 -1
View File
@@ -263,7 +263,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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<PathBuf>) -> 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 {
+4 -1
View File
@@ -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?;
+4 -1
View File
@@ -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?;
+36 -16
View File
@@ -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<AuthMode>,
models_manager: Arc<ModelsManager>,
) -> Option<AppExitInfo> {
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<AuthManager>,
/// 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<String>,
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<Line<'static>> = 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<Line<'static>> =
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<dyn HistoryCell>
+3 -1
View File
@@ -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);
+14 -9
View File
@@ -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<ModelPreset> =
// 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<SelectionItem> = 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;
}
+23 -29
View File
@@ -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::<AppEvent>();
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::<Op>();
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");
+11 -24
View File
@@ -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,
+5 -2
View File
@@ -78,6 +78,7 @@ pub(crate) fn new_status_output(
rate_limits: Option<&RateLimitSnapshotDisplay>,
plan_type: Option<PlanType>,
now: DateTime<Local>,
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<PlanType>,
now: DateTime<Local>,
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")
+2 -2
View File
@@ -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<String>) {
let mut details: Vec<String> = 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 {
+53 -25
View File
@@ -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<String> {
@@ -88,7 +88,7 @@ fn reset_at_from(captured_at: &chrono::DateTime<chrono::Local>, 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