diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7f0e1d18f8..22c7279833 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -215,6 +215,8 @@ impl MessageProcessor { config.as_ref(), auth_manager.clone(), session_source, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig { default_mode_request_user_input: config .features diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 427f6cc1f2..54fa0f8dc5 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -15,6 +15,7 @@ use std::path::Path; fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { ModelInfo { slug: preset.id.clone(), + request_model: None, display_name: preset.display_name.clone(), description: Some(preset.description.clone()), default_reasoning_level: Some(preset.default_reasoning_effort), diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3ffb2496b4..76aef1d9f4 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -54,6 +54,7 @@ async fn models_client_hits_models_endpoint() { let response = ModelsResponse { models: vec![ModelInfo { slug: "gpt-test".to_string(), + request_model: None, display_name: "gpt-test".to_string(), description: Some("desc".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 4b4c60ff5b..5c28366f19 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -646,6 +646,34 @@ }, "type": "object" }, + "CustomModelToml": { + "additionalProperties": false, + "properties": { + "model": { + "description": "Provider-facing model slug used on API requests.", + "type": "string" + }, + "model_auto_compact_token_limit": { + "description": "Optional auto-compaction token limit override applied when this alias is selected.", + "format": "int64", + "type": "integer" + }, + "model_context_window": { + "description": "Optional context window override applied when this alias is selected.", + "format": "int64", + "type": "integer" + }, + "name": { + "description": "User-facing alias shown in the model picker.", + "type": "string" + } + }, + "required": [ + "model", + "name" + ], + "type": "object" + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -1970,6 +1998,14 @@ "description": "Compact prompt used for history compaction.", "type": "string" }, + "custom_models": { + "default": [], + "description": "User-defined model aliases that can override model context settings.", + "items": { + "$ref": "#/definitions/CustomModelToml" + }, + "type": "array" + }, "default_permissions": { "description": "Default named permissions profile to apply from the `[permissions]` table.", "type": "string" diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index af5e3007b4..ddc4184f54 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -383,7 +383,7 @@ impl ModelClient { }; let text = create_text_param_for_request(verbosity, &prompt.output_schema); let payload = ApiCompactionInput { - model: &model_info.slug, + model: model_info.request_model_slug(), input: &input, instructions: &instructions, tools, @@ -436,7 +436,7 @@ impl ModelClient { .with_telemetry(Some(request_telemetry)); let payload = ApiMemorySummarizeInput { - model: model_info.slug.clone(), + model: model_info.request_model_slug().to_string(), raw_memories, reasoning: effort.map(|effort| Reasoning { effort: Some(effort), @@ -726,7 +726,7 @@ impl ModelClientSession { let text = create_text_param_for_request(verbosity, &prompt.output_schema); let prompt_cache_key = Some(self.client.state.conversation_id.to_string()); let request = ResponsesApiRequest { - model: model_info.slug.clone(), + model: model_info.request_model_slug().to_string(), instructions: instructions.clone(), input, tools, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c921be3a74..843d015b9d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3595,7 +3595,8 @@ impl Session { turn_context: &Arc, server_model: String, ) -> bool { - let requested_model = turn_context.model_info.slug.clone(); + let requested_model = turn_context.model_info.request_model_slug().to_string(); + let selected_model = turn_context.model_info.slug.clone(); let server_model_normalized = server_model.to_ascii_lowercase(); let requested_model_normalized = requested_model.to_ascii_lowercase(); if server_model_normalized == requested_model_normalized { @@ -3603,7 +3604,9 @@ impl Session { return false; } - warn!("server reported model {server_model} while requested model was {requested_model}"); + warn!( + "server reported model {server_model} while requested model was {requested_model} (selected alias: {selected_model})" + ); let warning_message = format!( "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: {CYBER_VERIFY_URL} or learn more: {CYBER_SAFETY_URL}" @@ -3612,7 +3615,7 @@ impl Session { self.send_event( turn_context, EventMsg::ModelReroute(ModelRerouteEvent { - from_model: requested_model.clone(), + from_model: selected_model, to_model: server_model.clone(), reason: ModelRerouteReason::HighRiskCyberActivity, }), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 83f3459901..618b656f83 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -96,6 +96,7 @@ use core_test_support::tracing::install_test_tracing; use core_test_support::wait_for_event; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; +use std::collections::HashMap; use std::path::Path; use std::time::Duration; use tokio::time::sleep; @@ -251,7 +252,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { ThreadId::try_from("00000000-0000-4000-8000-000000000001") .expect("test thread id should be valid"), crate::model_provider_info::ModelProviderInfo::create_openai_provider( - /* base_url */ /*base_url*/ None, + /*base_url*/ None, ), codex_protocol::protocol::SessionSource::Exec, /*model_verbosity*/ None, @@ -2490,6 +2491,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); @@ -2583,6 +2585,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); @@ -3425,6 +3428,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index f5698d58ff..3f709a8ab2 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -426,6 +426,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d706bc328b..6118b9de3d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -243,6 +243,48 @@ web_search = false ); } +#[test] +fn config_toml_deserializes_custom_models() { + let custom_models = r#" +[[custom_models]] +name = "gpt-5.4 1m" +model = "gpt-5.4" +model_context_window = 1000000 +model_auto_compact_token_limit = 900000 +"#; + let custom_models_cfg = toml::from_str::(custom_models) + .expect("TOML deserialization should succeed for custom models"); + + assert_eq!( + custom_models_cfg.custom_models, + vec![CustomModelToml { + name: "gpt-5.4 1m".to_string(), + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }] + ); + + let config = Config::load_from_base_config_with_overrides( + custom_models_cfg, + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config from custom models settings"); + + assert_eq!( + config.custom_models, + HashMap::from([( + "gpt-5.4 1m".to_string(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + )]) + ); +} + #[test] fn config_toml_deserializes_model_availability_nux() { let toml = r#" @@ -4426,6 +4468,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -4570,6 +4613,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai-custom".to_string(), model_provider: fixture.openai_custom_provider.clone(), @@ -4712,6 +4756,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -4840,6 +4885,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8cd59fa659..524c69c500 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -180,6 +180,12 @@ pub(crate) fn test_config() -> Config { .expect("load default test config") } +impl Config { + pub(crate) fn custom_model_alias(&self, alias: &str) -> Option<&CustomModelConfig> { + self.custom_models.get(alias) + } +} + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Permissions { @@ -391,6 +397,9 @@ pub struct Config { /// Combined provider map (defaults plus user-defined providers). pub model_providers: HashMap, + /// User-defined model aliases shown in the picker. + pub custom_models: HashMap, + /// Maximum number of bytes to include from an AGENTS.md project doc file. pub project_doc_max_bytes: usize, @@ -1203,6 +1212,10 @@ pub struct ConfigToml { #[serde(default, deserialize_with = "deserialize_model_providers")] pub model_providers: HashMap, + /// User-defined model aliases that can override model context settings. + #[serde(default)] + pub custom_models: Vec, + /// Maximum number of bytes to include from an AGENTS.md project doc file. pub project_doc_max_bytes: Option, @@ -1484,6 +1497,29 @@ pub struct RealtimeAudioToml { pub speaker: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomModelConfig { + /// Provider-facing model slug used on API requests. + pub model: String, + /// Optional context window override applied when this alias is selected. + pub model_context_window: Option, + /// Optional auto-compaction token limit override applied when this alias is selected. + pub model_auto_compact_token_limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct CustomModelToml { + /// User-facing alias shown in the model picker. + pub name: String, + /// Provider-facing model slug used on API requests. + pub model: String, + /// Optional context window override applied when this alias is selected. + pub model_context_window: Option, + /// Optional auto-compaction token limit override applied when this alias is selected. + pub model_auto_compact_token_limit: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ToolsToml { @@ -2304,6 +2340,24 @@ impl Config { for (key, provider) in cfg.model_providers.into_iter() { model_providers.entry(key).or_insert(provider); } + let mut custom_models = HashMap::new(); + for custom in cfg.custom_models { + let alias = custom.name; + if custom_models.contains_key(&alias) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("duplicate custom model alias: {alias}"), + )); + } + custom_models.insert( + alias, + CustomModelConfig { + model: custom.model, + model_context_window: custom.model_context_window, + model_auto_compact_token_limit: custom.model_auto_compact_token_limit, + }, + ); + } let model_provider_id = model_provider .or(config_profile.model_provider) @@ -2631,6 +2685,7 @@ impl Config { mcp_oauth_callback_port: cfg.mcp_oauth_callback_port, mcp_oauth_callback_url: cfg.mcp_oauth_callback_url.clone(), model_providers, + custom_models, project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES), project_doc_fallback_filenames: cfg .project_doc_fallback_filenames diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 29a1a85767..dc6a856e3f 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -7,6 +7,7 @@ use crate::auth::CodexAuth; use crate::auth_env_telemetry::AuthEnvTelemetry; use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::config::Config; +use crate::config::CustomModelConfig; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result as CoreResult; @@ -28,6 +29,8 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; +use std::collections::HashMap; +use std::collections::HashSet; use std::fmt; use std::path::PathBuf; use std::sync::Arc; @@ -175,6 +178,7 @@ enum CatalogMode { #[derive(Debug)] pub struct ModelsManager { remote_models: RwLock>, + custom_models: HashMap, catalog_mode: CatalogMode, collaboration_modes_config: CollaborationModesConfig, auth_manager: Arc, @@ -193,12 +197,14 @@ impl ModelsManager { codex_home: PathBuf, auth_manager: Arc, model_catalog: Option, + custom_models: HashMap, collaboration_modes_config: CollaborationModesConfig, ) -> Self { Self::new_with_provider( codex_home, auth_manager, model_catalog, + custom_models, collaboration_modes_config, ModelProviderInfo::create_openai_provider(/*base_url*/ None), ) @@ -209,6 +215,7 @@ impl ModelsManager { codex_home: PathBuf, auth_manager: Arc, model_catalog: Option, + custom_models: HashMap, collaboration_modes_config: CollaborationModesConfig, provider: ModelProviderInfo, ) -> Self { @@ -227,6 +234,7 @@ impl ModelsManager { }); Self { remote_models: RwLock::new(remote_models), + custom_models, catalog_mode, collaboration_modes_config, auth_manager, @@ -313,7 +321,7 @@ impl ModelsManager { #[instrument(level = "info", skip(self, config), fields(model = model))] pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo { let remote_models = self.get_remote_models().await; - Self::construct_model_info_from_candidates(model, &remote_models, config) + self.construct_model_info_from_candidates(model, &remote_models, config) } fn find_model_by_longest_prefix(model: &str, candidates: &[ModelInfo]) -> Option { @@ -353,10 +361,41 @@ impl ModelsManager { } fn construct_model_info_from_candidates( + &self, model: &str, candidates: &[ModelInfo], config: &Config, ) -> ModelInfo { + let custom_model = config + .custom_model_alias(model) + .or_else(|| self.custom_models.get(model)); + Self::construct_model_info_from_candidates_with_custom( + model, + candidates, + config, + custom_model, + ) + } + + fn construct_model_info_from_candidates_with_custom( + model: &str, + candidates: &[ModelInfo], + config: &Config, + custom_model: Option<&CustomModelConfig>, + ) -> ModelInfo { + if let Some(custom_model) = custom_model { + let mut config = config.clone(); + config.model_context_window = custom_model + .model_context_window + .or(config.model_context_window); + config.model_auto_compact_token_limit = custom_model + .model_auto_compact_token_limit + .or(config.model_auto_compact_token_limit); + let model_info = + Self::construct_model_info_for_custom_alias(model, custom_model, candidates); + return model_info::with_config_overrides(model_info, &config); + } + // First use the normal longest-prefix match. If that misses, allow a narrowly scoped // retry for namespaced slugs like `custom/gpt-5.3-codex`. let remote = Self::find_model_by_longest_prefix(model, candidates) @@ -373,6 +412,30 @@ impl ModelsManager { model_info::with_config_overrides(model_info, config) } + fn construct_model_info_for_custom_alias( + alias: &str, + custom_model: &CustomModelConfig, + candidates: &[ModelInfo], + ) -> ModelInfo { + let remote = Self::find_model_by_longest_prefix(&custom_model.model, candidates) + .or_else(|| Self::find_model_by_namespaced_suffix(&custom_model.model, candidates)); + if let Some(remote) = remote { + ModelInfo { + slug: alias.to_string(), + request_model: Some(custom_model.model.clone()), + display_name: alias.to_string(), + used_fallback_model_metadata: false, + ..remote + } + } else { + let mut fallback_model = model_info::model_info_from_slug(&custom_model.model); + fallback_model.slug = alias.to_string(); + fallback_model.request_model = Some(custom_model.model.clone()); + fallback_model.display_name = alias.to_string(); + fallback_model + } + } + /// Refresh models if the provided ETag differs from the cached ETag. /// /// Uses `Online` strategy to fetch latest models when ETags differ. @@ -520,12 +583,37 @@ impl ModelsManager { fn build_available_models(&self, mut remote_models: Vec) -> Vec { remote_models.sort_by(|a, b| a.priority.cmp(&b.priority)); - let mut presets: Vec = remote_models.into_iter().map(Into::into).collect(); + let mut presets: Vec = remote_models.iter().cloned().map(Into::into).collect(); + let mut existing_models: HashSet = + presets.iter().map(|preset| preset.model.clone()).collect(); + let mut custom_presets = Vec::new(); + + let mut custom_models = self.custom_models.iter().collect::>(); + custom_models.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (alias, custom) in custom_models { + if existing_models.contains(alias) { + continue; + } + + let model_info = + Self::construct_model_info_for_custom_alias(alias, custom, &remote_models); + let mut preset = ModelPreset::from(model_info); + preset.show_in_picker = true; + custom_presets.push(preset); + existing_models.insert(alias.to_string()); + } + let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt)); presets = ModelPreset::filter_by_auth(presets, chatgpt_mode); + custom_presets = ModelPreset::filter_by_auth(custom_presets, chatgpt_mode); ModelPreset::mark_default_by_picker_visibility(&mut presets); + if !presets.iter().any(|preset| preset.is_default) { + ModelPreset::mark_default_by_picker_visibility(&mut custom_presets); + } + custom_presets.extend(presets); + presets = custom_presets; presets } @@ -547,6 +635,7 @@ impl ModelsManager { codex_home, auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), provider, ) @@ -578,7 +667,12 @@ impl ModelsManager { } else { &[] }; - Self::construct_model_info_from_candidates(model, candidates, config) + Self::construct_model_info_from_candidates_with_custom( + model, + candidates, + config, + config.custom_model_alias(model), + ) } } diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 7b4b2be53b..68ff719f1a 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -146,6 +146,7 @@ async fn get_model_info_tracks_fallback_usage() { codex_home.path().to_path_buf(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); let known_slug = manager @@ -185,6 +186,7 @@ async fn get_model_info_uses_custom_catalog() { Some(ModelsResponse { models: vec![overlay], }), + HashMap::new(), CollaborationModesConfig::default(), ); @@ -217,6 +219,7 @@ async fn get_model_info_matches_namespaced_suffix() { Some(ModelsResponse { models: vec![remote], }), + HashMap::new(), CollaborationModesConfig::default(), ); let namespaced_model = "custom/gpt-image".to_string(); @@ -241,6 +244,7 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { codex_home.path().to_path_buf(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); let known_slug = manager @@ -717,6 +721,202 @@ fn build_available_models_picks_default_after_hiding_hidden_models() { assert_eq!(available, vec![expected_hidden, expected_visible]); } +#[tokio::test] +async fn get_model_info_uses_custom_alias_metadata_and_request_model() { + let codex_home = tempdir().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + + let alias = "gpt-5.4 1m".to_string(); + let custom_model = CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }; + config + .custom_models + .insert(alias.clone(), custom_model.clone()); + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new( + codex_home.path().to_path_buf(), + auth_manager, + Some(ModelsResponse { + models: vec![remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0)], + }), + HashMap::from([(alias.clone(), custom_model)]), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info(&alias, &config).await; + + assert_eq!(model_info.slug, alias); + assert_eq!(model_info.request_model.as_deref(), Some("gpt-5.4")); + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(800_000)); +} + +#[tokio::test] +async fn get_model_info_prefers_custom_alias_context_over_global_config() { + let codex_home = tempdir().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + + config.model_context_window = Some(250_000); + config.model_auto_compact_token_limit = Some(200_000); + + let alias = "gpt-5.4 1m".to_string(); + let custom_model = CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }; + config + .custom_models + .insert(alias.clone(), custom_model.clone()); + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new( + codex_home.path().to_path_buf(), + auth_manager, + Some(ModelsResponse { + models: vec![remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0)], + }), + HashMap::from([(alias.clone(), custom_model)]), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info(&alias, &config).await; + + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(800_000)); +} + +#[tokio::test] +async fn get_model_info_prefers_active_config_alias_over_startup_snapshot() { + let codex_home = tempdir().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + + let alias = "gpt-5.4 1m".to_string(); + config.custom_models.insert( + alias.clone(), + CustomModelConfig { + model: "gpt-5.4-updated".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + ); + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new( + codex_home.path().to_path_buf(), + auth_manager, + Some(ModelsResponse { + models: vec![ + remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0), + remote_model("gpt-5.4-updated", "GPT 5.4 Updated", /*priority*/ 1), + ], + }), + HashMap::from([( + alias.clone(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(500_000), + model_auto_compact_token_limit: Some(400_000), + }, + )]), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info(&alias, &config).await; + + assert_eq!(model_info.slug, alias); + assert_eq!(model_info.request_model.as_deref(), Some("gpt-5.4-updated")); + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(900_000)); +} + +#[test] +fn build_available_models_includes_custom_aliases() { + let codex_home = tempdir().expect("temp dir"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let provider = provider_for("http://example.test".to_string()); + let mut manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + manager.custom_models = HashMap::from([( + "gpt-5.4 1m".to_string(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }, + )]); + + let available = manager.build_available_models(vec![remote_model( + "gpt-5.4", "GPT 5.4", /*priority*/ 0, + )]); + let alias = available + .iter() + .find(|preset| preset.model == "gpt-5.4 1m") + .expect("custom alias should be listed"); + + assert!(alias.show_in_picker); + assert_eq!(alias.display_name, "gpt-5.4 1m"); +} + +#[test] +fn build_available_models_lists_custom_aliases_before_remote_models() { + let codex_home = tempdir().expect("temp dir"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let provider = provider_for("http://example.test".to_string()); + let mut manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + manager.custom_models = HashMap::from([( + "gpt-5.4 1m".to_string(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }, + )]); + + let available = manager.build_available_models(vec![ + remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0), + remote_model("gpt-5.3", "GPT 5.3", /*priority*/ 1), + ]); + + assert_eq!( + available + .iter() + .map(|preset| preset.model.as_str()) + .collect::>(), + vec!["gpt-5.4 1m", "gpt-5.4", "gpt-5.3"] + ); + assert_eq!( + available + .iter() + .find(|preset| preset.is_default) + .map(|preset| preset.model.as_str()), + Some("gpt-5.4") + ); +} + #[test] fn bundled_models_json_roundtrips() { let file_contents = include_str!("../../models.json"); diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 055a6459d4..200aa76b55 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -62,6 +62,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo { warn!("Unknown model {slug} is used. This will use fallback model metadata."); ModelInfo { slug: slug.to_string(), + request_model: None, display_name: slug.to_string(), description: None, default_reasoning_level: None, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index e051a851da..1bea2c8cc8 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -10,6 +10,7 @@ use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; use crate::codex_thread::CodexThread; use crate::config::Config; +use crate::config::CustomModelConfig; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::file_watcher::FileWatcher; @@ -33,6 +34,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; #[cfg(test)] use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::ForkReferenceItem; use codex_protocol::protocol::InitialHistory; @@ -218,6 +220,8 @@ impl ThreadManager { config: &Config, auth_manager: Arc, session_source: SessionSource, + model_catalog: Option, + custom_models: HashMap, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, ) -> Self { @@ -247,7 +251,8 @@ impl ThreadManager { models_manager: Arc::new(ModelsManager::new_with_provider( codex_home, auth_manager.clone(), - config.model_catalog.clone(), + model_catalog, + custom_models, collaboration_modes_config, openai_models_provider, )), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index dda9fa9484..fd59541e75 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -15,6 +15,7 @@ use core_test_support::PathExt; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use std::path::Path; +use std::collections::HashMap; use std::time::Duration; use tempfile::tempdir; use wiremock::MockServer; @@ -304,6 +305,8 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { &config, auth_manager, SessionSource::Exec, + /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, @@ -434,6 +437,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor &config, auth_manager.clone(), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, @@ -534,6 +539,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { &config, auth_manager.clone(), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, @@ -623,6 +630,8 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ &config, auth_manager.clone(), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 90ea36776e..82b18cf9c9 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -486,6 +486,8 @@ impl TestCodexBuilder { &config, codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::clone(&environment_manager), ) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f8c9492e4d..953bfcefd9 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -825,6 +825,8 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { &config, auth_manager, SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig { default_mode_request_user_input: config .features diff --git a/codex-rs/core/tests/suite/model_info_overrides.rs b/codex-rs/core/tests/suite/model_info_overrides.rs index fc1cbac765..479901cf01 100644 --- a/codex-rs/core/tests/suite/model_info_overrides.rs +++ b/codex-rs/core/tests/suite/model_info_overrides.rs @@ -2,8 +2,11 @@ use codex_core::CodexAuth; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::openai_models::WebSearchToolType; +use codex_protocol::openai_models::default_input_modalities; use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; +use std::collections::HashMap; use tempfile::TempDir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -17,6 +20,7 @@ async fn offline_model_info_without_tool_output_override() { config.codex_home.clone(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); @@ -40,6 +44,7 @@ async fn offline_model_info_with_tool_output_override() { config.codex_home.clone(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); @@ -50,3 +55,68 @@ async fn offline_model_info_with_tool_output_override() { TruncationPolicyConfig::tokens(/*limit*/ 123) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_model_alias_applies_request_model_and_context_overrides() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.custom_models.insert( + "gpt-5.4 1m".to_string(), + codex_core::config::CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + ); + + let auth_manager = codex_core::test_support::auth_manager_from_auth( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + ); + let manager = ModelsManager::new( + config.codex_home.clone(), + auth_manager, + Some(codex_protocol::openai_models::ModelsResponse { + models: vec![codex_protocol::openai_models::ModelInfo { + slug: "gpt-5.4".to_string(), + request_model: None, + display_name: "GPT-5.4".to_string(), + description: Some("desc".to_string()), + default_reasoning_level: None, + supported_reasoning_levels: Vec::new(), + shell_type: codex_protocol::openai_models::ConfigShellToolType::ShellCommand, + visibility: codex_protocol::openai_models::ModelVisibility::List, + supported_in_api: true, + priority: 1, + availability_nux: None, + upgrade: None, + base_instructions: "base".to_string(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: codex_protocol::config_types::ReasoningSummary::Auto, + support_verbosity: false, + default_verbosity: None, + supports_search_tool: false, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), + supports_parallel_tool_calls: false, + supports_image_detail_original: false, + context_window: Some(272_000), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + input_modalities: default_input_modalities(), + web_search_tool_type: WebSearchToolType::Text, + used_fallback_model_metadata: false, + }], + }), + config.custom_models.clone(), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info("gpt-5.4 1m", &config).await; + + assert_eq!(model_info.slug, "gpt-5.4 1m"); + assert_eq!(model_info.request_model.as_deref(), Some("gpt-5.4")); + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(900_000)); +} diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 808b04191d..b1a49229c1 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -68,6 +68,7 @@ fn test_model_info( ) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: display_name.to_string(), description: Some(description.to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -884,6 +885,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< let base_model = ModelInfo { slug: large_model_slug.to_string(), + request_model: None, display_name: "Larger Model".to_string(), description: Some("larger context window model".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 7d0ce8046d..da95dacf21 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -317,6 +317,7 @@ struct ModelsCache { fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: "Remote Test".to_string(), description: Some("remote model".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index cc68c57156..f5cf2ae21a 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -633,6 +633,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow let friendly_personality_message = "Friendly variant"; let remote_model = ModelInfo { slug: remote_slug.to_string(), + request_model: None, display_name: "Remote default personality test".to_string(), description: Some("Remote model with default personality template".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -749,6 +750,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - let remote_pragmatic_message = "Pragmatic from remote template"; let remote_model = ModelInfo { slug: remote_slug.to_string(), + request_model: None, display_name: "Remote personality test".to_string(), description: Some("Remote model with personality template".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 6b2da1116b..5a15087dc6 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -7,6 +7,7 @@ use anyhow::Result; use codex_core::CodexAuth; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; +use codex_core::config::CustomModelConfig; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::manager::RefreshStrategy; use codex_protocol::config_types::ReasoningSummary; @@ -268,6 +269,79 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_model_alias_sends_base_model_slug() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let alias = "gpt-5.4 1m"; + let base_model = "gpt-5.4"; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let mut builder = test_codex() + .with_model(alias) + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(move |config| { + config.custom_models.insert( + alias.to_string(), + CustomModelConfig { + model: base_model.to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + ); + config.model_catalog = Some(ModelsResponse { + models: vec![test_remote_model( + base_model, + ModelVisibility::List, + /*priority*/ 1, + )], + }); + }); + + let TestCodex { + codex, + cwd, + config, + session_configured, + .. + } = builder.build(&server).await?; + + assert_eq!(session_configured.model, alias); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "check custom alias model routing".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: alias.to_string(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let body = response_mock.single_request().body_json(); + assert_eq!(body["model"].as_str(), Some(base_model)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { skip_if_no_network!(Ok(())); @@ -280,6 +354,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { let remote_model = ModelInfo { slug: REMOTE_MODEL_SLUG.to_string(), + request_model: None, display_name: "Remote Test".to_string(), description: Some("A remote model that requires the test shell".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -524,6 +599,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { let remote_base = "Use the remote base instructions only."; let remote_model = ModelInfo { slug: model.to_string(), + request_model: None, display_name: "Parallel Remote".to_string(), description: Some("A remote model with custom instructions".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -1004,6 +1080,7 @@ fn test_remote_model_with_policy( ) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: format!("{slug} display"), description: Some(format!("{slug} description")), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 0335ead7e9..c81001218b 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -394,6 +394,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re ModelsResponse { models: vec![ModelInfo { slug: text_only_model_slug.to_string(), + request_model: None, display_name: "RMCP Text Only".to_string(), description: Some("Test model without image input support".to_string()), default_reasoning_level: None, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index e27e3466f7..a16f9980f4 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -56,6 +56,7 @@ fn test_model_info( ) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: display_name.to_string(), description: Some(description.to_string()), default_reasoning_level: Some(default_reasoning_level), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index fab2098ca5..71b976d499 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1335,6 +1335,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an let model_slug = "text-only-view-image-test-model"; let text_only_model = ModelInfo { slug: model_slug.to_string(), + request_model: None, display_name: "Text-only view_image test model".to_string(), description: Some("Remote model for view_image unsupported-path coverage".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 1fba41164c..e6e1da0cab 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -65,6 +65,8 @@ impl MessageProcessor { config.as_ref(), auth_manager, SessionSource::Mcp, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig { default_mode_request_user_input: config .features diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 3c633cbd88..4a9fd8dceb 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -243,6 +243,11 @@ const fn default_effective_context_window_percent() -> i64 { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInfo { pub slug: String, + /// Provider-facing model slug to send on API requests. + /// + /// When unset, `slug` is used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_model: Option, pub display_name: String, pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -294,6 +299,10 @@ pub struct ModelInfo { } impl ModelInfo { + pub fn request_model_slug(&self) -> &str { + self.request_model.as_deref().unwrap_or(self.slug.as_str()) + } + pub fn auto_compact_token_limit(&self) -> Option { let context_limit = self .context_window @@ -519,6 +528,7 @@ mod tests { fn test_model(spec: Option) -> ModelInfo { ModelInfo { slug: "test-model".to_string(), + request_model: None, display_name: "Test Model".to_string(), description: None, default_reasoning_level: None, diff --git a/docs/config.md b/docs/config.md index 47d381b80e..1b6a367fcf 100644 --- a/docs/config.md +++ b/docs/config.md @@ -115,4 +115,20 @@ developer message Codex inserts when realtime becomes active. It only affects the realtime start message in prompt history and does not change websocket backend prompt settings or the realtime end/inactive message. +## Custom model aliases + +You can add aliases to the model picker via `custom_models` in `~/.codex/config.toml`. +Each entry maps a user-facing alias to a provider-facing model slug and can override context settings: + +```toml +[[custom_models]] +name = "gpt-5.4 1m" +model = "gpt-5.4" +model_context_window = 1000000 +model_auto_compact_token_limit = 900000 +``` + +When selected, Codex sends `model = "gpt-5.4"` to the backend while using your +alias-specific context overrides for that session. + Ctrl+C/Ctrl+D quitting uses a ~1 second double-press hint (`ctrl + c again to quit`).