mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
merge upstream/dev/codex/add-custom-models-support-in-config.toml-r2 into collab stack
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3595,7 +3595,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 {
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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::<ConfigToml>(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(),
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
@@ -1203,6 +1212,10 @@ pub struct ConfigToml {
|
||||
#[serde(default, deserialize_with = "deserialize_model_providers")]
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
/// User-defined model aliases that can override model context settings.
|
||||
#[serde(default)]
|
||||
pub custom_models: Vec<CustomModelToml>,
|
||||
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
pub project_doc_max_bytes: Option<usize>,
|
||||
|
||||
@@ -1484,6 +1497,29 @@ pub struct RealtimeAudioToml {
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
/// 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<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, 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
|
||||
|
||||
@@ -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<Vec<ModelInfo>>,
|
||||
custom_models: HashMap<String, CustomModelConfig>,
|
||||
catalog_mode: CatalogMode,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
@@ -193,12 +197,14 @@ impl ModelsManager {
|
||||
codex_home: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
model_catalog: Option<ModelsResponse>,
|
||||
custom_models: HashMap<String, CustomModelConfig>,
|
||||
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<AuthManager>,
|
||||
model_catalog: Option<ModelsResponse>,
|
||||
custom_models: HashMap<String, CustomModelConfig>,
|
||||
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<ModelInfo> {
|
||||
@@ -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<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_presets = Vec::new();
|
||||
|
||||
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;
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<_>>(),
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AuthManager>,
|
||||
session_source: SessionSource,
|
||||
model_catalog: Option<ModelsResponse>,
|
||||
custom_models: HashMap<String, CustomModelConfig>,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
) -> 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,
|
||||
)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
#[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<i64> {
|
||||
let context_limit = self
|
||||
.context_window
|
||||
@@ -519,6 +528,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,
|
||||
|
||||
Reference in New Issue
Block a user