feat(core): support custom model aliases in config.toml

This adds config.toml-defined model aliases that map to provider model slugs while applying alias-specific context settings for the active session.

- added custom_models config entries plus schema and docs coverage
- taught ModelsManager to resolve aliases to a provider-facing request_model while preserving the user-facing alias slug
- applied alias-specific context_window and model_auto_compact_token_limit overrides during model info resolution
- updated session/test plumbing and added regression coverage for alias resolution with local and remote model catalogs

Model selection and per-session context overrides already flow through ModelsManager and Config. Resolving aliases there keeps the provider slug separate from the user-facing alias while reusing the existing override plumbing.

- just write-config-schema
- just fmt
- cargo test -p codex-app-server -p codex-api -p codex-exec -p codex-mcp-server
- cargo test -p codex-tui
- cargo test -p codex-protocol -p codex-core (same two existing seatbelt failures remained in this environment: create_seatbelt_args_with_read_only_git_pointer_file and create_seatbelt_args_with_read_only_git_and_codex_subpaths)
- just fix -p codex-core -p codex-protocol -p codex-app-server -p codex-api -p codex-exec -p codex-mcp-server -p codex-tui
This commit is contained in:
Friel
2026-03-06 19:42:35 -08:00
parent 22ac6b9aaa
commit 043a059919
25 changed files with 438 additions and 9 deletions
@@ -188,6 +188,7 @@ impl MessageProcessor {
auth_manager.clone(),
SessionSource::VSCode,
config.model_catalog.clone(),
config.custom_models.clone(),
CollaborationModesConfig {
default_mode_request_user_input: config
.features
@@ -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),
@@ -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),
+31
View File
@@ -558,6 +558,29 @@
},
"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"
}
},
"required": [
"model"
],
"type": "object"
},
"FeedbackConfigToml": {
"additionalProperties": false,
"properties": {
@@ -1737,6 +1760,14 @@
"description": "Compact prompt used for history compaction.",
"type": "string"
},
"custom_models": {
"additionalProperties": {
"$ref": "#/definitions/CustomModelToml"
},
"default": {},
"description": "User-defined model aliases that can override model context settings.",
"type": "object"
},
"default_permissions": {
"description": "Default named permissions profile to apply from the `[permissions]` table.",
"type": "string"
+3 -3
View File
@@ -295,7 +295,7 @@ impl ModelClient {
let instructions = prompt.base_instructions.text.clone();
let payload = ApiCompactionInput {
model: &model_info.slug,
model: model_info.request_model_slug(),
input: &prompt.input,
instructions: &instructions,
};
@@ -335,7 +335,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),
@@ -534,7 +534,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,
+6 -3
View File
@@ -3062,7 +3062,8 @@ impl Session {
turn_context: &Arc<TurnContext>,
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 {
@@ -3070,7 +3071,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}"
@@ -3079,7 +3082,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,
}),
+48
View File
@@ -358,6 +358,9 @@ pub struct Config {
/// Combined provider map (defaults merged with user-defined overrides).
pub model_providers: HashMap<String, ModelProviderInfo>,
/// User-defined model aliases shown in the picker.
pub custom_models: HashMap<String, CustomModelConfig>,
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: usize,
@@ -687,6 +690,10 @@ impl Config {
.build()
.await
}
pub(crate) fn custom_model_alias(&self, alias: &str) -> Option<&CustomModelConfig> {
self.custom_models.get(alias)
}
}
/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working
@@ -1143,6 +1150,10 @@ pub struct ConfigToml {
#[serde(default)]
pub model_providers: HashMap<String, ModelProviderInfo>,
/// User-defined model aliases that can override model context settings.
#[serde(default)]
pub custom_models: HashMap<String, CustomModelToml>,
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: Option<usize>,
@@ -1358,6 +1369,27 @@ pub struct ProjectConfig {
pub trust_level: Option<TrustLevel>,
}
#[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<i64>,
/// Optional auto-compaction token limit override applied when this alias is selected.
pub model_auto_compact_token_limit: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct CustomModelToml {
/// 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<i64>,
/// Optional auto-compaction token limit override applied when this alias is selected.
pub model_auto_compact_token_limit: Option<i64>,
}
impl ProjectConfig {
pub fn is_trusted(&self) -> bool {
matches!(self.trust_level, Some(TrustLevel::Trusted))
@@ -2047,6 +2079,21 @@ impl Config {
model_providers.entry(key).or_insert(provider);
}
let custom_models = cfg
.custom_models
.into_iter()
.map(|(alias, custom)| {
(
alias,
CustomModelConfig {
model: custom.model,
model_context_window: custom.model_context_window,
model_auto_compact_token_limit: custom.model_auto_compact_token_limit,
},
)
})
.collect();
let model_provider_id = model_provider
.or(config_profile.model_provider)
.or(cfg.model_provider)
@@ -2378,6 +2425,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
+162 -3
View File
@@ -4,6 +4,7 @@ use crate::api_bridge::map_api_error;
use crate::auth::AuthManager;
use crate::auth::AuthMode;
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;
@@ -18,6 +19,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::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -55,6 +58,7 @@ enum CatalogMode {
#[derive(Debug)]
pub struct ModelsManager {
remote_models: RwLock<Vec<ModelInfo>>,
custom_models: HashMap<String, CustomModelConfig>,
catalog_mode: CatalogMode,
collaboration_modes_config: CollaborationModesConfig,
auth_manager: Arc<AuthManager>,
@@ -73,6 +77,7 @@ impl ModelsManager {
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
model_catalog: Option<ModelsResponse>,
custom_models: HashMap<String, CustomModelConfig>,
collaboration_modes_config: CollaborationModesConfig,
) -> Self {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
@@ -90,6 +95,7 @@ impl ModelsManager {
});
Self {
remote_models: RwLock::new(remote_models),
custom_models,
catalog_mode,
collaboration_modes_config,
auth_manager,
@@ -162,7 +168,7 @@ impl ModelsManager {
/// Look up model metadata, applying remote overrides and config adjustments.
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<ModelInfo> {
@@ -202,10 +208,35 @@ impl ModelsManager {
}
fn construct_model_info_from_candidates(
&self,
model: &str,
candidates: &[ModelInfo],
config: &Config,
) -> ModelInfo {
let custom_model = self
.custom_models
.get(model)
.or_else(|| config.custom_model_alias(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 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)
@@ -222,6 +253,37 @@ 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));
let mut model_info = 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
};
if let Some(model_context_window) = custom_model.model_context_window {
model_info.context_window = Some(model_context_window);
}
if let Some(model_auto_compact_token_limit) = custom_model.model_auto_compact_token_limit {
model_info.auto_compact_token_limit = Some(model_auto_compact_token_limit);
}
model_info
}
/// Refresh models if the provided ETag differs from the cached ETag.
///
/// Uses `Online` strategy to fetch latest models when ETags differ.
@@ -358,7 +420,25 @@ impl ModelsManager {
fn build_available_models(&self, mut remote_models: Vec<ModelInfo>) -> Vec<ModelPreset> {
remote_models.sort_by(|a, b| a.priority.cmp(&b.priority));
let mut presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
let mut presets: Vec<ModelPreset> = remote_models.iter().cloned().map(Into::into).collect();
let mut existing_models: HashSet<String> =
presets.iter().map(|preset| preset.model.clone()).collect();
let mut custom_models = self.custom_models.iter().collect::<Vec<_>>();
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;
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);
@@ -388,6 +468,7 @@ impl ModelsManager {
Self::load_remote_models_from_file()
.unwrap_or_else(|err| panic!("failed to load bundled models.json: {err}")),
),
custom_models: HashMap::new(),
catalog_mode: CatalogMode::Default,
collaboration_modes_config: CollaborationModesConfig::default(),
auth_manager,
@@ -423,7 +504,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),
)
}
}
@@ -521,6 +607,7 @@ mod tests {
codex_home.path().to_path_buf(),
auth_manager,
None,
HashMap::new(),
CollaborationModesConfig::default(),
);
let known_slug = manager
@@ -561,6 +648,7 @@ mod tests {
Some(ModelsResponse {
models: vec![overlay],
}),
HashMap::new(),
CollaborationModesConfig::default(),
);
@@ -594,6 +682,7 @@ mod tests {
Some(ModelsResponse {
models: vec![remote],
}),
HashMap::new(),
CollaborationModesConfig::default(),
);
let namespaced_model = "custom/gpt-image".to_string();
@@ -619,6 +708,7 @@ mod tests {
codex_home.path().to_path_buf(),
auth_manager,
None,
HashMap::new(),
CollaborationModesConfig::default(),
);
let known_slug = manager
@@ -988,6 +1078,75 @@ mod tests {
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", 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));
}
#[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", 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 bundled_models_json_roundtrips() {
let file_contents = include_str!("../../models.json");
@@ -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,
+3
View File
@@ -7,6 +7,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;
@@ -149,6 +150,7 @@ impl ThreadManager {
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
model_catalog: Option<ModelsResponse>,
custom_models: HashMap<String, CustomModelConfig>,
collaboration_modes_config: CollaborationModesConfig,
) -> Self {
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
@@ -167,6 +169,7 @@ impl ThreadManager {
codex_home,
auth_manager.clone(),
model_catalog,
custom_models,
collaboration_modes_config,
)),
skills_manager,
+1
View File
@@ -185,6 +185,7 @@ impl TestCodexBuilder {
codex_core::test_support::auth_manager_from_auth(auth.clone()),
SessionSource::Exec,
Some(model_catalog),
config.custom_models.clone(),
CollaborationModesConfig::default(),
)
} else {
+1
View File
@@ -819,6 +819,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
auth_manager,
SessionSource::Exec,
config.model_catalog.clone(),
config.custom_models.clone(),
CollaborationModesConfig {
default_mode_request_user_input: config
.features
@@ -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,
None,
HashMap::new(),
CollaborationModesConfig::default(),
);
@@ -40,6 +44,7 @@ async fn offline_model_info_with_tool_output_override() {
config.codex_home.clone(),
auth_manager,
None,
HashMap::new(),
CollaborationModesConfig::default(),
);
@@ -50,3 +55,68 @@ async fn offline_model_info_with_tool_output_override() {
TruncationPolicyConfig::tokens(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,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(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,
prefer_websockets: false,
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));
}
@@ -42,6 +42,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),
@@ -662,6 +663,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),
@@ -316,6 +316,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),
+2
View File
@@ -620,6 +620,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),
@@ -735,6 +736,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),
@@ -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;
@@ -266,6 +267,74 @@ 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, 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(),
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(()));
@@ -278,6 +347,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),
@@ -520,6 +590,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),
@@ -986,6 +1057,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),
+1
View File
@@ -387,6 +387,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,
+1
View File
@@ -980,6 +980,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),
+1
View File
@@ -479,6 +479,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
auth_manager.clone(),
SessionSource::Exec,
config.model_catalog.clone(),
config.custom_models.clone(),
CollaborationModesConfig {
default_mode_request_user_input: config
.features
@@ -63,6 +63,7 @@ impl MessageProcessor {
auth_manager,
SessionSource::Mcp,
config.model_catalog.clone(),
config.custom_models.clone(),
CollaborationModesConfig {
default_mode_request_user_input: config
.features
+10
View File
@@ -233,6 +233,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<String>,
pub display_name: String,
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -285,6 +290,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<i64> {
let context_limit = self
.context_window
@@ -510,6 +519,7 @@ mod tests {
fn test_model(spec: Option<ModelMessages>) -> ModelInfo {
ModelInfo {
slug: "test-model".to_string(),
request_model: None,
display_name: "Test Model".to_string(),
description: None,
default_reasoning_level: None,
+1
View File
@@ -1580,6 +1580,7 @@ impl App {
auth_manager.clone(),
SessionSource::Cli,
config.model_catalog.clone(),
config.custom_models.clone(),
CollaborationModesConfig {
default_mode_request_user_input: config
.features
+2
View File
@@ -1753,6 +1753,7 @@ async fn make_chatwidget_manual(
codex_home,
auth_manager.clone(),
None,
HashMap::new(),
CollaborationModesConfig::default(),
));
let reasoning_effort = None;
@@ -1883,6 +1884,7 @@ fn set_chatgpt_auth(chat: &mut ChatWidget) {
chat.config.codex_home.clone(),
chat.auth_manager.clone(),
None,
HashMap::new(),
CollaborationModesConfig::default(),
));
}
+15
View File
@@ -49,4 +49,19 @@ Plan preset. The string value `none` means "no reasoning" (an explicit Plan
override), not "inherit the global default". There is currently no separate
config value for "follow the global default in Plan mode".
## Custom model aliases
You can add aliases to the model picker via `custom_models` in `~/.codex/config.toml`.
Each alias maps to a provider-facing model slug and can override context settings:
```toml
[custom_models."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`).