diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index e757c2149c..b071eb3ead 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -887,9 +887,34 @@ pub struct ConfigRequirementsToml { #[serde(rename = "experimental_network")] pub network: Option, pub permissions: Option, + pub models: Option, pub guardian_policy_config: Option, } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ModelsRequirementsToml { + pub new_thread: Option, +} + +impl ModelsRequirementsToml { + fn is_empty(&self) -> bool { + self.new_thread + .as_ref() + .is_none_or(NewThreadModelDefaultsToml::is_empty) + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NewThreadModelDefaultsToml { + pub model: Option, +} + +impl NewThreadModelDefaultsToml { + fn is_empty(&self) -> bool { + self.model.is_none() + } +} + #[derive(Deserialize, Debug, Clone, PartialEq)] pub struct RemoteSandboxConfigToml { pub hostname_patterns: Vec, @@ -941,6 +966,7 @@ pub struct ConfigRequirementsWithSources { pub enforce_residency: Option>, pub network: Option>, pub permissions: Option>, + pub models: Option>, pub guardian_policy_config: Option>, } @@ -985,6 +1011,7 @@ impl ConfigRequirementsWithSources { enforce_residency: _, network: _, permissions: _, + models: _, guardian_policy_config: _, } = &other; @@ -1021,6 +1048,7 @@ impl ConfigRequirementsWithSources { enforce_residency, network, permissions, + models, guardian_policy_config, } ); @@ -1057,6 +1085,7 @@ impl ConfigRequirementsWithSources { enforce_residency, network, permissions, + models, guardian_policy_config, } = self; ConfigRequirementsToml { @@ -1082,6 +1111,7 @@ impl ConfigRequirementsWithSources { enforce_residency: enforce_residency.map(|sourced| sourced.value), network: network.map(|sourced| sourced.value), permissions: permissions.map(|sourced| sourced.value), + models: models.map(|sourced| sourced.value), guardian_policy_config: guardian_policy_config.map(|sourced| sourced.value), } } @@ -1194,6 +1224,10 @@ impl ConfigRequirementsToml { && self.enforce_residency.is_none() && self.network.is_none() && self.permissions.is_none() + && self + .models + .as_ref() + .is_none_or(ModelsRequirementsToml::is_empty) && self .guardian_policy_config .as_deref() @@ -1206,8 +1240,9 @@ impl TryFrom for ConfigRequirements { fn try_from(toml: ConfigRequirementsWithSources) -> Result { // Profile catalog selection remains on ConfigRequirementsToml for - // config loading and requirements API projection. The normalized - // constraints below only need the compiled PermissionProfile envelope. + // config loading and requirements API projection. Managed new-thread + // defaults also remain there because they are initialization values, + // not runtime constraints. let ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, @@ -1230,6 +1265,7 @@ impl TryFrom for ConfigRequirements { enforce_residency, network, permissions, + models: _, guardian_policy_config, } = toml; @@ -1605,6 +1641,7 @@ mod tests { enforce_residency, network, permissions, + models, guardian_policy_config, } = toml; ConfigRequirementsWithSources { @@ -1640,6 +1677,7 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), network: network.map(|value| Sourced::new(value, RequirementSource::Unknown)), permissions: permissions.map(|value| Sourced::new(value, RequirementSource::Unknown)), + models: models.map(|value| Sourced::new(value, RequirementSource::Unknown)), guardian_policy_config: guardian_policy_config .map(|value| Sourced::new(value, RequirementSource::Unknown)), } @@ -1792,6 +1830,27 @@ mod tests { Ok(()) } + #[test] + fn deserialize_new_thread_model_default() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + [models.new_thread] + model = "managed-model" + "#, + )?; + + assert_eq!( + requirements.models, + Some(ModelsRequirementsToml { + new_thread: Some(NewThreadModelDefaultsToml { + model: Some("managed-model".to_string()), + }), + }) + ); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn merge_unset_fields_copies_every_field_and_sets_sources() { let mut target = ConfigRequirementsWithSources::default(); @@ -1814,6 +1873,11 @@ mod tests { let computer_use = ComputerUseRequirementsToml { allow_locked_computer_use: Some(false), }; + let models = ModelsRequirementsToml { + new_thread: Some(NewThreadModelDefaultsToml { + model: Some("managed-model".to_string()), + }), + }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); @@ -1843,6 +1907,7 @@ mod tests { enforce_residency: Some(enforce_residency), network: None, permissions: None, + models: Some(models.clone()), guardian_policy_config: Some(guardian_policy_config.clone()), }; @@ -1893,6 +1958,7 @@ mod tests { enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), network: None, permissions: None, + models: Some(Sourced::new(models, source.clone())), guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)), } ); @@ -1940,6 +2006,7 @@ mod tests { enforce_residency: None, network: None, permissions: None, + models: None, guardian_policy_config: None, } ); @@ -1995,6 +2062,7 @@ mod tests { enforce_residency: None, network: None, permissions: None, + models: None, guardian_policy_config: None, } ); diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 1cbbbaf0a9..0ef9f9e0d9 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -68,12 +68,14 @@ pub use config_requirements::MarketplaceAllowedSourceToml; pub use config_requirements::MarketplaceRequirementsToml; pub use config_requirements::McpServerIdentity; pub use config_requirements::McpServerRequirement; +pub use config_requirements::ModelsRequirementsToml; pub use config_requirements::NetworkConstraints; pub use config_requirements::NetworkDomainPermissionToml; pub use config_requirements::NetworkDomainPermissionsToml; pub use config_requirements::NetworkRequirementsToml; pub use config_requirements::NetworkUnixSocketPermissionToml; pub use config_requirements::NetworkUnixSocketPermissionsToml; +pub use config_requirements::NewThreadModelDefaultsToml; pub use config_requirements::PluginRequirementsToml; pub use config_requirements::RemoteSandboxConfigToml; pub use config_requirements::RequirementSource; diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 3be1d11b5c..9a42650bf3 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -228,6 +228,7 @@ fn populate_merged_regular_fields_with_sources( enforce_residency, network, permissions, + models, guardian_policy_config, } = requirements; @@ -256,6 +257,7 @@ fn populate_merged_regular_fields_with_sources( set_sourced!(enforce_residency, &["enforce_residency"]); set_sourced!(network, &["experimental_network"]); set_sourced!(permissions, &["permissions"]); + set_sourced!(models, &["models"]); if let Some(guardian_policy_config) = guardian_policy_config.filter(|value| !value.trim().is_empty()) diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index a775f68a83..bd2cc25f97 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -109,6 +109,40 @@ allow_remote_control = false ); } +#[test] +fn new_thread_model_default_uses_toml_priority() { + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +[models.new_thread] +model = "low-priority-model" +"#, + ), + layer( + "req_high", + "High", + r#" +[models.new_thread] +model = "high-priority-model" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[models.new_thread] +model = "high-priority-model" +"# + ) + ); +} + #[test] fn composition_strategy_applies_to_non_cloud_layers() { let mdm_source = RequirementSource::MdmManagedPreferences { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 62d9f792ef..793ba148ca 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8686,6 +8686,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() enforce_residency: None, network: None, permissions: None, + models: None, guardian_policy_config: None, }; let requirement_source = codex_config::RequirementSource::Unknown; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index daf9812581..538d68c0e5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -473,6 +473,24 @@ pub(crate) fn resolve_multi_agent_version( }) } +fn resolve_initial_model( + configured_model: Option, + managed_new_thread_model: Option, + conversation_history: &InitialHistory, + session_source: &SessionSource, +) -> Option { + if session_source.is_non_root_agent() { + return configured_model; + } + + match conversation_history { + InitialHistory::New | InitialHistory::Cleared => { + managed_new_thread_model.or(configured_model) + } + InitialHistory::Resumed(_) | InitialHistory::Forked(_) => configured_model, + } +} + pub(crate) const INITIAL_SUBMIT_ID: &str = ""; pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512; const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber"; @@ -566,6 +584,20 @@ impl Codex { ) }; + let managed_new_thread_model = config + .config_layer_stack + .requirements_toml() + .models + .as_ref() + .and_then(|models| models.new_thread.as_ref()) + .and_then(|defaults| defaults.model.clone()); + config.model = resolve_initial_model( + config.model.take(), + managed_new_thread_model, + &conversation_history, + &session_source, + ); + let config = Arc::new(config); let refresh_strategy = if session_source.is_non_root_agent() { codex_models_manager::manager::RefreshStrategy::Offline diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 983ed20094..5e5860ec7f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2050,6 +2050,55 @@ fn resolve_multi_agent_version_handles_unset_and_legacy_history() { ); } +#[test] +fn managed_new_thread_model_only_overrides_root_new_threads() { + let cases = [ + ( + InitialHistory::New, + SessionSource::VSCode, + Some("managed-model"), + "managed-model", + ), + ( + InitialHistory::New, + SessionSource::VSCode, + None, + "local-model", + ), + ( + InitialHistory::Cleared, + SessionSource::VSCode, + Some("managed-model"), + "managed-model", + ), + ( + InitialHistory::Forked(Vec::new()), + SessionSource::VSCode, + Some("managed-model"), + "local-model", + ), + ( + InitialHistory::New, + SessionSource::SubAgent(SubAgentSource::Review), + Some("managed-model"), + "local-model", + ), + ]; + + for (history, source, managed_model, expected) in cases { + assert_eq!( + resolve_initial_model( + Some("local-model".to_string()), + managed_model.map(str::to_string), + &history, + &source, + ) + .as_deref(), + Some(expected) + ); + } +} + #[tokio::test] async fn record_initial_history_new_defers_initial_context_until_first_turn() { let (session, _turn_context) = make_session_and_context().await; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index ed9aeffddb..7102c57e4a 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -791,6 +791,7 @@ mod tests { enforce_residency: Some(ResidencyRequirement::Us), network: None, permissions: None, + models: None, }; let user_file = if cfg!(windows) { @@ -1149,6 +1150,7 @@ approval_policy = "never" enforce_residency: None, network: None, permissions: None, + models: None, }; let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)