From d6f8e3aeeb4b84e9ebb62e954889e5aa03b1072d Mon Sep 17 00:00:00 2001 From: Friel Date: Sat, 14 Mar 2026 13:31:40 -0700 Subject: [PATCH 1/8] feat(core): support custom model aliases in config.toml Preserve custom model alias support on the current origin/main base and collapse the branch back to a single commit for easier future restacks. --- codex-rs/app-server/src/message_processor.rs | 2 + .../app-server/tests/common/models_cache.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/config.schema.json | 36 +++ codex-rs/core/src/client.rs | 6 +- codex-rs/core/src/codex.rs | 9 +- codex-rs/core/src/codex_tests.rs | 159 +++++------- codex-rs/core/src/codex_tests_guardian.rs | 8 +- codex-rs/core/src/config/config_tests.rs | 46 ++++ codex-rs/core/src/config/mod.rs | 55 ++++ codex-rs/core/src/models_manager/manager.rs | 102 +++++++- .../core/src/models_manager/manager_tests.rs | 244 ++++++++++++++++-- .../core/src/models_manager/model_info.rs | 1 + codex-rs/core/src/thread_manager.rs | 7 +- codex-rs/core/src/thread_manager_tests.rs | 3 + codex-rs/core/tests/common/test_codex.rs | 2 + codex-rs/core/tests/suite/client.rs | 2 + .../core/tests/suite/model_info_overrides.rs | 79 +++++- codex-rs/core/tests/suite/model_switching.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 72 ++++++ codex-rs/core/tests/suite/rmcp_client.rs | 1 + .../tests/suite/spawn_agent_description.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 2 + codex-rs/protocol/src/openai_models.rs | 10 + docs/config.md | 16 ++ 28 files changed, 732 insertions(+), 139 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ff0402e903..7d17d0a1a6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -217,6 +217,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 78113b126d..a2d2ac93ea 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -606,6 +606,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": { @@ -1930,6 +1958,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 e38ff3a562..0cee09853e 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 9400af7219..d07c13db62 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3361,7 +3361,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 { @@ -3369,7 +3370,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}" @@ -3378,7 +3381,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 f1a6652770..1b034d2998 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -96,6 +96,9 @@ use core_test_support::tracing::install_test_tracing; use core_test_support::wait_for_event; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::trace::SdkTracerProvider; +use std::collections::HashMap; use std::path::Path; use std::time::Duration; use tokio::time::sleep; @@ -247,17 +250,17 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted fn test_model_client_session() -> crate::client::ModelClientSession { crate::client::ModelClient::new( - /*auth_manager*/ None, + None, 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, - /*enable_request_compression*/ false, - /*include_timing_metrics*/ false, - /*beta_features_header*/ None, + None, + false, + false, + None, ) .new_session() } @@ -313,7 +316,7 @@ fn make_connector(id: &str, name: &str) -> AppInfo { #[test] fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() { - let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false); + let mut parsers = AssistantMessageStreamParsers::new(false); let item_id = "msg-1"; let seeded = parsers.seed_item_text(item_id, "hello doc"); @@ -330,7 +333,7 @@ fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() #[test] fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() { - let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false); + let mut parsers = AssistantMessageStreamParsers::new(false); let item_id = "msg-1"; let seeded = parsers.seed_item_text(item_id, "hello anyhow::Result<()> { let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), - /*requirements*/ None, + None, &SandboxPolicy::new_workspace_write_policy(), )?; let mut exec_policy = Policy::empty(); @@ -457,16 +460,16 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho "example.com", NetworkRuleProtocol::Https, Decision::Allow, - /*justification*/ None, + None, )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, &SandboxPolicy::new_workspace_write_policy(), - /*network_policy_decider*/ None, - /*blocked_request_observer*/ None, - /*managed_network_requirements_enabled*/ false, + None, + None, + false, crate::config::NetworkProxyAuditMetadata::default(), ) .await?; @@ -501,16 +504,16 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() "example.com", NetworkRuleProtocol::Https, Decision::Allow, - /*justification*/ None, + None, )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, &SandboxPolicy::new_workspace_write_policy(), - /*network_policy_decider*/ None, - /*blocked_request_observer*/ None, - /*managed_network_requirements_enabled*/ false, + None, + None, + false, crate::config::NetworkProxyAuditMetadata::default(), ) .await?; @@ -737,9 +740,7 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool( - "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, - ), + make_mcp_tool("rmcp", "echo", None, None), ), ]); @@ -787,9 +788,7 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool( - "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, - ), + make_mcp_tool("rmcp", "echo", None, None), ), ]); @@ -839,9 +838,7 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool( - "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, - ), + make_mcp_tool("rmcp", "echo", None, None), ), ]); @@ -1185,13 +1182,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted); let forked = initial .thread_manager - .fork_thread( - usize::MAX, - fork_config, - rollout_path, - /*persist_extended_history*/ false, - /*parent_trace*/ None, - ) + .fork_thread(usize::MAX, fork_config, rollout_path, false, None) .await?; let collaboration_mode = CollaborationMode { @@ -1363,7 +1354,7 @@ async fn thread_rollback_drops_last_turn_from_history() { state.set_reference_context_item(Some(tc.to_turn_context_item())); } - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1410,7 +1401,7 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() .collect(); sess.persist_rollout_items(&rollout_items).await; - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 99).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 99); @@ -1427,7 +1418,7 @@ async fn thread_rollback_fails_without_persisted_rollout_path() { sess.record_into_history(&initial_context, tc.as_ref()) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!( @@ -1521,7 +1512,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context })) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1629,7 +1620,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio ) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1707,10 +1698,10 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { ]) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let first_rollback = wait_for_thread_rolled_back(&rx).await; assert_eq!(first_rollback.num_turns, 1); - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let second_rollback = wait_for_thread_rolled_back(&rx).await; assert_eq!(second_rollback.num_turns, 1); @@ -1745,7 +1736,7 @@ async fn thread_rollback_fails_when_turn_in_progress() { .await; *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!( @@ -1765,7 +1756,7 @@ async fn thread_rollback_fails_when_num_turns_is_zero() { sess.record_into_history(&initial_context, tc.as_ref()) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 0).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), 0).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!(error_event.message, "num_turns must be >= 1"); @@ -2164,14 +2155,14 @@ async fn attach_rollout_recorder(session: &Arc) -> PathBuf { config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - /*forked_from_id*/ None, + None, SessionSource::Exec, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, ), - /*state_db_ctx*/ None, - /*state_builder*/ None, + None, + None, ) .await .expect("create rollout recorder"); @@ -2210,11 +2201,11 @@ fn session_telemetry( conversation_id, ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(), model_info.slug.as_str(), - /*account_id*/ None, + None, Some("test@test.com".to_string()), Some(TelemetryAuthMode::Chatgpt), "test_originator".to_string(), - /*log_user_prompts*/ false, + false, "test".to_string(), session_source, ) @@ -2337,7 +2328,7 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { .skills_manager .skills_for_cwd( &crate::skills_load_input_from_config(&parent_config, Vec::new()), - /*force_reload*/ true, + true, ) .await; let parent_skill = parent_outcome @@ -2485,7 +2476,8 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), )); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); @@ -2534,10 +2526,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - /*bundled_skills_enabled*/ true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let result = Session::new( session_configuration, Arc::clone(&config), @@ -2578,7 +2567,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); @@ -2641,10 +2631,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - /*bundled_skills_enabled*/ true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(/*exec_server_url*/ None) @@ -2730,7 +2717,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { per_turn_config, model_info, &models_manager, - /*network*/ None, + None, environment, "turn_id".to_string(), Arc::clone(&js_repl), @@ -3418,7 +3405,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); @@ -3481,10 +3469,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - /*bundled_skills_enabled*/ true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(/*exec_server_url*/ None) @@ -3570,7 +3555,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( per_turn_config, model_info, &models_manager, - /*network*/ None, + None, environment, "turn_id".to_string(), Arc::clone(&js_repl), @@ -3680,8 +3665,7 @@ async fn record_model_warning_appends_user_message() { #[tokio::test] async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; - sess.set_previous_turn_settings(/*previous_turn_settings*/ None) - .await; + sess.set_previous_turn_settings(None).await; let input = vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -3734,10 +3718,7 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( )); let layers = config .config_layer_stack - .get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) .into_iter() .cloned() .collect(); @@ -3934,7 +3915,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), }], - /*reference_context_item*/ None, + None, ) .await; @@ -3988,7 +3969,7 @@ async fn handle_output_item_done_records_image_save_history_message() { tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; - handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None) + handle_output_item_done(&mut ctx, item.clone(), None) .await .expect("image generation item should succeed"); @@ -4045,7 +4026,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; - handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None) + handle_output_item_done(&mut ctx, item.clone(), None) .await .expect("image generation item should still complete"); @@ -4137,13 +4118,10 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co .await; { let mut state = session.state.lock().await; - state.set_reference_context_item(/*item*/ None); + state.set_reference_context_item(None); } session - .replace_history( - vec![compacted_summary.clone()], - /*reference_context_item*/ None, - ) + .replace_history(vec![compacted_summary.clone()], None) .await; session @@ -4178,14 +4156,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - /*forked_from_id*/ None, + None, SessionSource::Exec, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, ), - /*state_db_ctx*/ None, - /*state_builder*/ None, + None, + None, ) .await .expect("create rollout recorder"); @@ -4275,14 +4253,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - /*forked_from_id*/ None, + None, SessionSource::Exec, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, ), - /*state_db_ctx*/ None, - /*state_builder*/ None, + None, + None, ) .await .expect("create rollout recorder"); @@ -4304,7 +4282,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei .await; { let mut state = session.state.lock().await; - state.set_reference_context_item(/*item*/ None); + state.set_reference_context_item(None); } session @@ -4343,7 +4321,7 @@ async fn run_user_shell_command_does_not_set_reference_context_item() { let (session, _turn_context, rx) = make_session_and_context_with_rx().await; { let mut state = session.state.lock().await; - state.set_reference_context_item(/*item*/ None); + state.set_reference_context_item(None); } handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) @@ -4496,8 +4474,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() .await .expect("inject pending input into active turn"); - sess.on_task_finished(Arc::clone(&tc), /*last_agent_message*/ None) - .await; + sess.on_task_finished(Arc::clone(&tc), None).await; let history = sess.clone_history().await; let expected = ResponseItem::Message { @@ -4589,7 +4566,7 @@ async fn steer_input_requires_active_turn() { }]; let err = sess - .steer_input(input, /*expected_turn_id*/ None) + .steer_input(input, None) .await .expect_err("steering without active turn should fail"); diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index f5698d58ff..9a3cd7b49d 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -425,14 +425,12 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), )); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - /*bundled_skills_enabled*/ true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); let skills_watcher = Arc::new(SkillsWatcher::noop()); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 00227fe2b3..84b64f8033 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#" @@ -4379,6 +4421,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(), @@ -4521,6 +4564,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(), @@ -4661,6 +4705,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(), @@ -4787,6 +4832,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 1a0722119b..2ab2f95284 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -179,6 +179,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 { @@ -390,6 +396,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, @@ -1195,6 +1204,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, @@ -1473,6 +1486,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 { @@ -2260,6 +2296,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) @@ -2577,6 +2631,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..91244522f7 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 } @@ -546,7 +634,8 @@ impl ModelsManager { Self::new_with_provider( codex_home, auth_manager, - /*model_catalog*/ None, + 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..8283579f16 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -145,7 +145,8 @@ async fn get_model_info_tracks_fallback_usage() { let manager = ModelsManager::new( codex_home.path().to_path_buf(), auth_manager, - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), ); let known_slug = manager @@ -175,7 +176,7 @@ async fn get_model_info_uses_custom_catalog() { .build() .await .expect("load default test config"); - let mut overlay = remote_model("gpt-overlay", "Overlay", /*priority*/ 0); + let mut overlay = remote_model("gpt-overlay", "Overlay", 0); overlay.supports_image_detail_original = true; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); @@ -185,6 +186,7 @@ async fn get_model_info_uses_custom_catalog() { Some(ModelsResponse { models: vec![overlay], }), + HashMap::new(), CollaborationModesConfig::default(), ); @@ -208,7 +210,7 @@ async fn get_model_info_matches_namespaced_suffix() { .build() .await .expect("load default test config"); - let mut remote = remote_model("gpt-image", "Image", /*priority*/ 0); + let mut remote = remote_model("gpt-image", "Image", 0); remote.supports_image_detail_original = true; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let manager = ModelsManager::new( @@ -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(); @@ -240,7 +243,8 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { let manager = ModelsManager::new( codex_home.path().to_path_buf(), auth_manager, - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), ); let known_slug = manager @@ -262,8 +266,8 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; let remote_models = vec![ - remote_model("priority-low", "Low", /*priority*/ 1), - remote_model("priority-high", "High", /*priority*/ 0), + remote_model("priority-low", "Low", 1), + remote_model("priority-high", "High", 0), ]; let models_mock = mount_models_once( &server, @@ -313,7 +317,7 @@ async fn refresh_available_models_sorts_by_priority() { #[tokio::test] async fn refresh_available_models_uses_cache_when_fresh() { let server = MockServer::start().await; - let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)]; + let remote_models = vec![remote_model("cached", "Cached", 5)]; let models_mock = mount_models_once( &server, ModelsResponse { @@ -354,7 +358,7 @@ async fn refresh_available_models_uses_cache_when_fresh() { #[tokio::test] async fn refresh_available_models_refetches_when_cache_stale() { let server = MockServer::start().await; - let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)]; + let initial_models = vec![remote_model("stale", "Stale", 1)]; let initial_mock = mount_models_once( &server, ModelsResponse { @@ -387,7 +391,7 @@ async fn refresh_available_models_refetches_when_cache_stale() { .await .expect("cache manipulation succeeds"); - let updated_models = vec![remote_model("fresh", "Fresh", /*priority*/ 9)]; + let updated_models = vec![remote_model("fresh", "Fresh", 9)]; server.reset().await; let refreshed_mock = mount_models_once( &server, @@ -417,7 +421,7 @@ async fn refresh_available_models_refetches_when_cache_stale() { #[tokio::test] async fn refresh_available_models_refetches_when_version_mismatch() { let server = MockServer::start().await; - let initial_models = vec![remote_model("old", "Old", /*priority*/ 1)]; + let initial_models = vec![remote_model("old", "Old", 1)]; let initial_mock = mount_models_once( &server, ModelsResponse { @@ -450,7 +454,7 @@ async fn refresh_available_models_refetches_when_version_mismatch() { .await .expect("cache mutation succeeds"); - let updated_models = vec![remote_model("new", "New", /*priority*/ 2)]; + let updated_models = vec![remote_model("new", "New", 2)]; server.reset().await; let refreshed_mock = mount_models_once( &server, @@ -480,11 +484,7 @@ async fn refresh_available_models_refetches_when_version_mismatch() { #[tokio::test] async fn refresh_available_models_drops_removed_remote_models() { let server = MockServer::start().await; - let initial_models = vec![remote_model( - "remote-old", - "Remote Old", - /*priority*/ 1, - )]; + let initial_models = vec![remote_model("remote-old", "Remote Old", 1)]; let initial_mock = mount_models_once( &server, ModelsResponse { @@ -510,11 +510,7 @@ async fn refresh_available_models_drops_removed_remote_models() { .expect("initial refresh succeeds"); server.reset().await; - let refreshed_models = vec![remote_model( - "remote-new", - "Remote New", - /*priority*/ 1, - )]; + let refreshed_models = vec![remote_model("remote-new", "Remote New", 1)]; let refreshed_mock = mount_models_once( &server, ModelsResponse { @@ -558,7 +554,7 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() { let models_mock = mount_models_once( &server, ModelsResponse { - models: vec![remote_model(dynamic_slug, "No Auth", /*priority*/ 1)], + models: vec![remote_model(dynamic_slug, "No Auth", 1)], }, ) .await; @@ -566,7 +562,7 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() { let codex_home = tempdir().expect("temp dir"); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, + false, AuthCredentialsStoreMode::File, )); let provider = provider_for(server.uri()); @@ -629,7 +625,7 @@ fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() { .unwrap(), ); telemetry.on_request( - /*attempt*/ 1, + 1, Some(StatusCode::UNAUTHORIZED), Some(&TransportError::Http { status: StatusCode::UNAUTHORIZED, @@ -703,10 +699,8 @@ fn build_available_models_picks_default_after_hiding_hidden_models() { provider, ); - let hidden_model = - remote_model_with_visibility("hidden", "Hidden", /*priority*/ 0, "hide"); - let visible_model = - remote_model_with_visibility("visible", "Visible", /*priority*/ 1, "list"); + let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide"); + let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list"); let expected_hidden = ModelPreset::from(hidden_model.clone()); let mut expected_visible = ModelPreset::from(visible_model.clone()); @@ -717,6 +711,200 @@ 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", 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", 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", 0), + remote_model("gpt-5.4-updated", "GPT 5.4 Updated", 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", 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", 0), + remote_model("gpt-5.3", "GPT 5.3", 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 7f272f9595..730adc66e7 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; @@ -34,6 +35,7 @@ use codex_protocol::config_types::CollaborationModeMask; #[cfg(test)] use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; @@ -216,6 +218,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 { @@ -245,7 +249,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 62a9aa199f..2b1503b5c0 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::UserMessageEvent; use core_test_support::PathExt; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; +use std::collections::HashMap; use std::time::Duration; use tempfile::tempdir; use wiremock::MockServer; @@ -294,6 +295,8 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { &config, auth_manager, SessionSource::Exec, + None, + HashMap::new(), 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 9be02cf97b..d3b1c329ba 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..3984801edd 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)] @@ -16,7 +19,8 @@ async fn offline_model_info_without_tool_output_override() { let manager = ModelsManager::new( config.codex_home.clone(), auth_manager, - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), ); @@ -24,7 +28,7 @@ async fn offline_model_info_without_tool_output_override() { assert_eq!( model_info.truncation_policy, - TruncationPolicyConfig::bytes(/*limit*/ 10_000) + TruncationPolicyConfig::bytes(10_000) ); } @@ -39,7 +43,8 @@ async fn offline_model_info_with_tool_output_override() { let manager = ModelsManager::new( config.codex_home.clone(), auth_manager, - /*model_catalog*/ None, + None, + HashMap::new(), CollaborationModesConfig::default(), ); @@ -47,6 +52,72 @@ async fn offline_model_info_with_tool_output_override() { assert_eq!( model_info.truncation_policy, - TruncationPolicyConfig::tokens(/*limit*/ 123) + 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, + supports_search_tool: false, + 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)); +} 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..549206038b 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,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(())); @@ -280,6 +349,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 +594,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 +1075,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 71f3548deb..909157699c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -88,4 +88,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`). From bc18824d2387050ce94b5f94a4af1adda7ce3a7a Mon Sep 17 00:00:00 2001 From: Friel Date: Fri, 27 Mar 2026 20:45:41 -0700 Subject: [PATCH 2/8] fix(core): adapt custom model tests to refreshed main Drop the removed ModelInfo field and normalize refreshed ThreadManager and UserTurn test callsites. Co-authored-by: Codex --- codex-rs/core/src/codex_tests.rs | 2 -- codex-rs/core/src/thread_manager_tests.rs | 6 ++++++ codex-rs/core/tests/suite/model_info_overrides.rs | 1 - codex-rs/core/tests/suite/remote_models.rs | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 1b034d2998..1b8e37fdd7 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -96,8 +96,6 @@ use core_test_support::tracing::install_test_tracing; use core_test_support::wait_for_event; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; -use opentelemetry::trace::TracerProvider as _; -use opentelemetry_sdk::trace::SdkTracerProvider; use std::collections::HashMap; use std::path::Path; use std::time::Duration; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 2b1503b5c0..fac0b45ce1 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -427,6 +427,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, @@ -527,6 +529,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, @@ -616,6 +620,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/suite/model_info_overrides.rs b/codex-rs/core/tests/suite/model_info_overrides.rs index 3984801edd..c0f1baf3b3 100644 --- a/codex-rs/core/tests/suite/model_info_overrides.rs +++ b/codex-rs/core/tests/suite/model_info_overrides.rs @@ -106,7 +106,6 @@ async fn custom_model_alias_applies_request_model_and_context_overrides() { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), web_search_tool_type: WebSearchToolType::Text, - prefer_websockets: false, used_fallback_model_metadata: false, }], }), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 549206038b..8be7ecfe5b 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -319,6 +319,7 @@ async fn custom_model_alias_sends_base_model_slug() -> Result<()> { 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, From a68b1bd42305fba29130f7a530978a88503af12b Mon Sep 17 00:00:00 2001 From: Friel Date: Sat, 28 Mar 2026 11:27:29 -0700 Subject: [PATCH 3/8] test(core): annotate custom model catalog literal --- codex-rs/core/src/models_manager/manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 91244522f7..dc6a856e3f 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -634,7 +634,7 @@ impl ModelsManager { Self::new_with_provider( codex_home, auth_manager, - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), provider, From 9118d71ec702df7de4d9220dc10652f7e5617065 Mon Sep 17 00:00:00 2001 From: Friel Date: Sat, 28 Mar 2026 11:48:18 -0700 Subject: [PATCH 4/8] test(core): annotate custom model literals --- codex-rs/core/src/codex_tests.rs | 34 ++++++------ codex-rs/core/src/codex_tests_guardian.rs | 7 ++- .../core/src/models_manager/manager_tests.rs | 54 +++++++++++-------- codex-rs/core/src/thread_manager_tests.rs | 2 +- .../core/tests/suite/model_info_overrides.rs | 10 ++-- 5 files changed, 61 insertions(+), 46 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 1b8e37fdd7..03de4e9a31 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -248,17 +248,17 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted fn test_model_client_session() -> crate::client::ModelClientSession { crate::client::ModelClient::new( - None, + /*auth_manager*/ None, 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 */ None, + /*base_url*/ None, ), codex_protocol::protocol::SessionSource::Exec, - None, - false, - false, - None, + /*model_verbosity*/ None, + /*enable_request_compression*/ false, + /*include_timing_metrics*/ false, + /*beta_features_header*/ None, ) .new_session() } @@ -314,7 +314,7 @@ fn make_connector(id: &str, name: &str) -> AppInfo { #[test] fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() { - let mut parsers = AssistantMessageStreamParsers::new(false); + let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false); let item_id = "msg-1"; let seeded = parsers.seed_item_text(item_id, "hello doc"); @@ -331,7 +331,7 @@ fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() #[test] fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() { - let mut parsers = AssistantMessageStreamParsers::new(false); + let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false); let item_id = "msg-1"; let seeded = parsers.seed_item_text(item_id, "hello anyhow::Result<()> { let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), - None, + /*requirements*/ None, &SandboxPolicy::new_workspace_write_policy(), )?; let mut exec_policy = Policy::empty(); @@ -458,16 +458,16 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho "example.com", NetworkRuleProtocol::Https, Decision::Allow, - None, + /*justification*/ None, )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, &SandboxPolicy::new_workspace_write_policy(), - None, - None, - false, + /*network_policy_decider*/ None, + /*blocked_request_observer*/ None, + /*managed_network_requirements_enabled*/ false, crate::config::NetworkProxyAuditMetadata::default(), ) .await?; @@ -509,9 +509,9 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() &spec, &exec_policy, &SandboxPolicy::new_workspace_write_policy(), - None, - None, - false, + /*network_policy_decider*/ None, + /*blocked_request_observer*/ None, + /*managed_network_requirements_enabled*/ false, crate::config::NetworkProxyAuditMetadata::default(), ) .await?; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 9a3cd7b49d..3f709a8ab2 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -425,12 +425,15 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), )); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); let skills_watcher = Arc::new(SkillsWatcher::noop()); diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 8283579f16..71878330c2 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -176,7 +176,7 @@ async fn get_model_info_uses_custom_catalog() { .build() .await .expect("load default test config"); - let mut overlay = remote_model("gpt-overlay", "Overlay", 0); + let mut overlay = remote_model("gpt-overlay", "Overlay", /*priority*/ 0); overlay.supports_image_detail_original = true; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); @@ -210,7 +210,7 @@ async fn get_model_info_matches_namespaced_suffix() { .build() .await .expect("load default test config"); - let mut remote = remote_model("gpt-image", "Image", 0); + let mut remote = remote_model("gpt-image", "Image", /*priority*/ 0); remote.supports_image_detail_original = true; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let manager = ModelsManager::new( @@ -266,8 +266,8 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; let remote_models = vec![ - remote_model("priority-low", "Low", 1), - remote_model("priority-high", "High", 0), + remote_model("priority-low", "Low", /*priority*/ 1), + remote_model("priority-high", "High", /*priority*/ 0), ]; let models_mock = mount_models_once( &server, @@ -317,7 +317,7 @@ async fn refresh_available_models_sorts_by_priority() { #[tokio::test] async fn refresh_available_models_uses_cache_when_fresh() { let server = MockServer::start().await; - let remote_models = vec![remote_model("cached", "Cached", 5)]; + let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)]; let models_mock = mount_models_once( &server, ModelsResponse { @@ -358,7 +358,7 @@ async fn refresh_available_models_uses_cache_when_fresh() { #[tokio::test] async fn refresh_available_models_refetches_when_cache_stale() { let server = MockServer::start().await; - let initial_models = vec![remote_model("stale", "Stale", 1)]; + let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)]; let initial_mock = mount_models_once( &server, ModelsResponse { @@ -391,7 +391,7 @@ async fn refresh_available_models_refetches_when_cache_stale() { .await .expect("cache manipulation succeeds"); - let updated_models = vec![remote_model("fresh", "Fresh", 9)]; + let updated_models = vec![remote_model("fresh", "Fresh", /*priority*/ 9)]; server.reset().await; let refreshed_mock = mount_models_once( &server, @@ -421,7 +421,7 @@ async fn refresh_available_models_refetches_when_cache_stale() { #[tokio::test] async fn refresh_available_models_refetches_when_version_mismatch() { let server = MockServer::start().await; - let initial_models = vec![remote_model("old", "Old", 1)]; + let initial_models = vec![remote_model("old", "Old", /*priority*/ 1)]; let initial_mock = mount_models_once( &server, ModelsResponse { @@ -454,7 +454,7 @@ async fn refresh_available_models_refetches_when_version_mismatch() { .await .expect("cache mutation succeeds"); - let updated_models = vec![remote_model("new", "New", 2)]; + let updated_models = vec![remote_model("new", "New", /*priority*/ 2)]; server.reset().await; let refreshed_mock = mount_models_once( &server, @@ -484,7 +484,11 @@ async fn refresh_available_models_refetches_when_version_mismatch() { #[tokio::test] async fn refresh_available_models_drops_removed_remote_models() { let server = MockServer::start().await; - let initial_models = vec![remote_model("remote-old", "Remote Old", 1)]; + let initial_models = vec![remote_model( + "remote-old", + "Remote Old", + /*priority*/ 1, + )]; let initial_mock = mount_models_once( &server, ModelsResponse { @@ -510,7 +514,11 @@ async fn refresh_available_models_drops_removed_remote_models() { .expect("initial refresh succeeds"); server.reset().await; - let refreshed_models = vec![remote_model("remote-new", "Remote New", 1)]; + let refreshed_models = vec![remote_model( + "remote-new", + "Remote New", + /*priority*/ 1, + )]; let refreshed_mock = mount_models_once( &server, ModelsResponse { @@ -554,7 +562,7 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() { let models_mock = mount_models_once( &server, ModelsResponse { - models: vec![remote_model(dynamic_slug, "No Auth", 1)], + models: vec![remote_model(dynamic_slug, "No Auth", /*priority*/ 1)], }, ) .await; @@ -699,8 +707,10 @@ fn build_available_models_picks_default_after_hiding_hidden_models() { provider, ); - let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide"); - let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list"); + let hidden_model = + remote_model_with_visibility("hidden", "Hidden", /*priority*/ 0, "hide"); + let visible_model = + remote_model_with_visibility("visible", "Visible", /*priority*/ 1, "list"); let expected_hidden = ModelPreset::from(hidden_model.clone()); let mut expected_visible = ModelPreset::from(visible_model.clone()); @@ -735,7 +745,7 @@ async fn get_model_info_uses_custom_alias_metadata_and_request_model() { codex_home.path().to_path_buf(), auth_manager, Some(ModelsResponse { - models: vec![remote_model("gpt-5.4", "GPT 5.4", 0)], + models: vec![remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0)], }), HashMap::from([(alias.clone(), custom_model)]), CollaborationModesConfig::default(), @@ -776,7 +786,7 @@ async fn get_model_info_prefers_custom_alias_context_over_global_config() { codex_home.path().to_path_buf(), auth_manager, Some(ModelsResponse { - models: vec![remote_model("gpt-5.4", "GPT 5.4", 0)], + models: vec![remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0)], }), HashMap::from([(alias.clone(), custom_model)]), CollaborationModesConfig::default(), @@ -813,8 +823,8 @@ async fn get_model_info_prefers_active_config_alias_over_startup_snapshot() { auth_manager, Some(ModelsResponse { models: vec![ - remote_model("gpt-5.4", "GPT 5.4", 0), - remote_model("gpt-5.4-updated", "GPT 5.4 Updated", 1), + remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0), + remote_model("gpt-5.4-updated", "GPT 5.4 Updated", /*priority*/ 1), ], }), HashMap::from([( @@ -855,7 +865,9 @@ fn build_available_models_includes_custom_aliases() { }, )]); - let available = manager.build_available_models(vec![remote_model("gpt-5.4", "GPT 5.4", 0)]); + 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") @@ -885,8 +897,8 @@ fn build_available_models_lists_custom_aliases_before_remote_models() { )]); let available = manager.build_available_models(vec![ - remote_model("gpt-5.4", "GPT 5.4", 0), - remote_model("gpt-5.3", "GPT 5.3", 1), + remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0), + remote_model("gpt-5.3", "GPT 5.3", /*priority*/ 1), ]); assert_eq!( diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index fac0b45ce1..9b7720f782 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -295,7 +295,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { &config, auth_manager, SessionSource::Exec, - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( diff --git a/codex-rs/core/tests/suite/model_info_overrides.rs b/codex-rs/core/tests/suite/model_info_overrides.rs index c0f1baf3b3..479901cf01 100644 --- a/codex-rs/core/tests/suite/model_info_overrides.rs +++ b/codex-rs/core/tests/suite/model_info_overrides.rs @@ -19,7 +19,7 @@ async fn offline_model_info_without_tool_output_override() { let manager = ModelsManager::new( config.codex_home.clone(), auth_manager, - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), ); @@ -28,7 +28,7 @@ async fn offline_model_info_without_tool_output_override() { assert_eq!( model_info.truncation_policy, - TruncationPolicyConfig::bytes(10_000) + TruncationPolicyConfig::bytes(/*limit*/ 10_000) ); } @@ -43,7 +43,7 @@ async fn offline_model_info_with_tool_output_override() { let manager = ModelsManager::new( config.codex_home.clone(), auth_manager, - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), ); @@ -52,7 +52,7 @@ async fn offline_model_info_with_tool_output_override() { assert_eq!( model_info.truncation_policy, - TruncationPolicyConfig::tokens(123) + TruncationPolicyConfig::tokens(/*limit*/ 123) ); } @@ -97,7 +97,7 @@ async fn custom_model_alias_applies_request_model_and_context_overrides() { default_verbosity: None, supports_search_tool: false, apply_patch_tool_type: None, - truncation_policy: TruncationPolicyConfig::bytes(10_000), + truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), From d581363a827539ebf7e2ce547f5fa29985b4e5b6 Mon Sep 17 00:00:00 2001 From: Friel Date: Sat, 28 Mar 2026 12:05:31 -0700 Subject: [PATCH 5/8] test(core): annotate refreshed custom model literals --- codex-rs/core/src/codex_tests.rs | 66 ++++++++++++------- .../core/src/models_manager/manager_tests.rs | 4 +- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 03de4e9a31..8172b06c37 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -738,7 +738,9 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), + make_mcp_tool( + "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, + ), ), ]); @@ -786,7 +788,9 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), + make_mcp_tool( + "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, + ), ), ]); @@ -836,7 +840,9 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), + make_mcp_tool( + "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, + ), ), ]); @@ -1180,7 +1186,13 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted); let forked = initial .thread_manager - .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .fork_thread( + usize::MAX, + fork_config, + rollout_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await?; let collaboration_mode = CollaborationMode { @@ -1352,7 +1364,7 @@ async fn thread_rollback_drops_last_turn_from_history() { state.set_reference_context_item(Some(tc.to_turn_context_item())); } - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1399,7 +1411,7 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() .collect(); sess.persist_rollout_items(&rollout_items).await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 99).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 99); @@ -1416,7 +1428,7 @@ async fn thread_rollback_fails_without_persisted_rollout_path() { sess.record_into_history(&initial_context, tc.as_ref()) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!( @@ -1510,7 +1522,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context })) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1618,7 +1630,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio ) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1696,10 +1708,10 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { ]) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let first_rollback = wait_for_thread_rolled_back(&rx).await; assert_eq!(first_rollback.num_turns, 1); - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let second_rollback = wait_for_thread_rolled_back(&rx).await; assert_eq!(second_rollback.num_turns, 1); @@ -1734,7 +1746,7 @@ async fn thread_rollback_fails_when_turn_in_progress() { .await; *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!( @@ -1754,7 +1766,7 @@ async fn thread_rollback_fails_when_num_turns_is_zero() { sess.record_into_history(&initial_context, tc.as_ref()) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 0).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 0).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!(error_event.message, "num_turns must be >= 1"); @@ -3663,7 +3675,8 @@ async fn record_model_warning_appends_user_message() { #[tokio::test] async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; - sess.set_previous_turn_settings(None).await; + sess.set_previous_turn_settings(/*previous_turn_settings*/ None) + .await; let input = vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -3716,7 +3729,10 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( )); let layers = config .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) .into_iter() .cloned() .collect(); @@ -3967,7 +3983,7 @@ async fn handle_output_item_done_records_image_save_history_message() { tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; - handle_output_item_done(&mut ctx, item.clone(), None) + handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None) .await .expect("image generation item should succeed"); @@ -4024,7 +4040,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; - handle_output_item_done(&mut ctx, item.clone(), None) + handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None) .await .expect("image generation item should still complete"); @@ -4116,10 +4132,13 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co .await; { let mut state = session.state.lock().await; - state.set_reference_context_item(None); + state.set_reference_context_item(/*item*/ None); } session - .replace_history(vec![compacted_summary.clone()], None) + .replace_history( + vec![compacted_summary.clone()], + /*reference_context_item*/ None, + ) .await; session @@ -4280,7 +4299,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei .await; { let mut state = session.state.lock().await; - state.set_reference_context_item(None); + state.set_reference_context_item(/*item*/ None); } session @@ -4319,7 +4338,7 @@ async fn run_user_shell_command_does_not_set_reference_context_item() { let (session, _turn_context, rx) = make_session_and_context_with_rx().await; { let mut state = session.state.lock().await; - state.set_reference_context_item(None); + state.set_reference_context_item(/*item*/ None); } handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) @@ -4472,7 +4491,8 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() .await .expect("inject pending input into active turn"); - sess.on_task_finished(Arc::clone(&tc), None).await; + sess.on_task_finished(Arc::clone(&tc), /*last_agent_message*/ None) + .await; let history = sess.clone_history().await; let expected = ResponseItem::Message { @@ -4564,7 +4584,7 @@ async fn steer_input_requires_active_turn() { }]; let err = sess - .steer_input(input, None) + .steer_input(input, /*expected_turn_id*/ None) .await .expect_err("steering without active turn should fail"); diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 71878330c2..52123c74bd 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -145,7 +145,7 @@ async fn get_model_info_tracks_fallback_usage() { let manager = ModelsManager::new( codex_home.path().to_path_buf(), auth_manager, - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), ); @@ -243,7 +243,7 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { let manager = ModelsManager::new( codex_home.path().to_path_buf(), auth_manager, - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), ); From 8fab3fb7dae9a2a528ef3da11f40c980e6d66860 Mon Sep 17 00:00:00 2001 From: Friel Date: Sat, 28 Mar 2026 16:22:01 -0700 Subject: [PATCH 6/8] test(core): annotate remaining custom model literals --- codex-rs/core/src/codex_tests.rs | 43 +++++++++++++--------- codex-rs/core/tests/suite/remote_models.rs | 6 ++- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 8172b06c37..107a0fb834 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -502,7 +502,7 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() "example.com", NetworkRuleProtocol::Https, Decision::Allow, - None, + /*justification*/ None, )?; let (started_proxy, _) = Session::start_managed_network_proxy( @@ -2165,14 +2165,14 @@ async fn attach_rollout_recorder(session: &Arc) -> PathBuf { config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - None, + /*forked_from_id*/ None, SessionSource::Exec, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, ), - None, - None, + /*state_db_ctx*/ None, + /*state_builder*/ None, ) .await .expect("create rollout recorder"); @@ -2486,7 +2486,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), )); @@ -2536,7 +2536,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let result = Session::new( session_configuration, Arc::clone(&config), @@ -2577,7 +2580,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), )); @@ -2641,7 +2644,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(/*exec_server_url*/ None) @@ -3415,7 +3421,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, HashMap::new(), CollaborationModesConfig::default(), )); @@ -3479,7 +3485,10 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(/*exec_server_url*/ None) @@ -3929,7 +3938,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), }], - None, + /*reference_context_item*/ None, ) .await; @@ -4173,14 +4182,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - None, + /*forked_from_id*/ None, SessionSource::Exec, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, ), - None, - None, + /*state_db_ctx*/ None, + /*state_builder*/ None, ) .await .expect("create rollout recorder"); @@ -4270,14 +4279,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - None, + /*forked_from_id*/ None, SessionSource::Exec, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, ), - None, - None, + /*state_db_ctx*/ None, + /*state_builder*/ None, ) .await .expect("create rollout recorder"); diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 8be7ecfe5b..5a15087dc6 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -296,7 +296,11 @@ async fn custom_model_alias_sends_base_model_slug() -> Result<()> { }, ); config.model_catalog = Some(ModelsResponse { - models: vec![test_remote_model(base_model, ModelVisibility::List, 1)], + models: vec![test_remote_model( + base_model, + ModelVisibility::List, + /*priority*/ 1, + )], }); }); From d1196258a1ced4145d30e87ac19a90708b8a6af9 Mon Sep 17 00:00:00 2001 From: Friel Date: Sat, 28 Mar 2026 16:34:48 -0700 Subject: [PATCH 7/8] test(core): annotate custom model helper literals --- codex-rs/core/src/codex_tests.rs | 4 ++-- codex-rs/core/src/models_manager/manager_tests.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 107a0fb834..f2c199905e 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2211,11 +2211,11 @@ fn session_telemetry( conversation_id, ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(), model_info.slug.as_str(), - None, + /*account_id*/ None, Some("test@test.com".to_string()), Some(TelemetryAuthMode::Chatgpt), "test_originator".to_string(), - false, + /*log_user_prompts*/ false, "test".to_string(), session_source, ) diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 52123c74bd..d7ff0040e2 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -570,7 +570,7 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() { let codex_home = tempdir().expect("temp dir"); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), - false, + /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, )); let provider = provider_for(server.uri()); From 3c84d41945b7489c2708d532eaceb0565d861a40 Mon Sep 17 00:00:00 2001 From: Friel Date: Sat, 28 Mar 2026 16:50:22 -0700 Subject: [PATCH 8/8] test(core): annotate final custom model literals --- codex-rs/core/src/codex_tests.rs | 6 +++--- codex-rs/core/src/models_manager/manager_tests.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f2c199905e..8cccca35f1 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2338,7 +2338,7 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { .skills_manager .skills_for_cwd( &crate::skills_load_input_from_config(&parent_config, Vec::new()), - true, + /*force_reload*/ true, ) .await; let parent_skill = parent_outcome @@ -2733,7 +2733,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { per_turn_config, model_info, &models_manager, - None, + /*network*/ None, environment, "turn_id".to_string(), Arc::clone(&js_repl), @@ -3574,7 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( per_turn_config, model_info, &models_manager, - None, + /*network*/ None, environment, "turn_id".to_string(), Arc::clone(&js_repl), diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index d7ff0040e2..68ff719f1a 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -633,7 +633,7 @@ fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() { .unwrap(), ); telemetry.on_request( - 1, + /*attempt*/ 1, Some(StatusCode::UNAUTHORIZED), Some(&TransportError::Http { status: StatusCode::UNAUTHORIZED,