diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index a581b78ac1..159334b268 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -41,6 +41,7 @@ use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ProfileWorkspaceRoot; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EnvironmentConfig; @@ -1146,6 +1147,11 @@ impl CodexThread { self.session.state_db() } + /// Resolves the model selected for future turns using this thread's catalog and settings. + pub async fn selected_model_info(&self) -> Arc { + self.session.selected_model_info().await + } + pub async fn config_snapshot(&self) -> ThreadConfigSnapshot { self.session.thread_config_snapshot().await } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b9035a1081..d53dd8d9af 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2284,6 +2284,21 @@ impl Session { .await) } + pub(crate) async fn selected_model_info(&self) -> Arc { + let (settings, overrides) = { + let state = self.state.lock().await; + let config = &state.session_configuration; + ( + Arc::clone(&config.step_settings), + config.model_info_overrides.clone(), + ) + }; + settings + .resolve_model_info(self.services.models_manager.as_ref(), &overrides) + .await + .model + } + pub(crate) async fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { self.resume_config_snapshot().await.0 } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index ed4b06ab4e..da5b5a265f 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1077,6 +1077,32 @@ impl ThreadManager { } } + /// Returns the catalog a root thread will use on start or cold resume. + pub async fn models_manager_for_startup( + &self, + config: &Config, + service_name: Option<&str>, + history: &InitialHistory, + ) -> SharedModelsManager { + let originator = self + .state + .effective_originator( + history, + service_name, + &self.state.session_source, + /*parent_thread_id*/ None, + /*forked_from_thread_id*/ None, + /*parent_originator*/ None, + ) + .await; + self.state.models_for_thread( + config, + &self.state.auth_manager, + &self.state.runtime_auth, + &originator, + ) + } + pub fn set_api_key_model_discovery_enabled(&self, enabled: bool) { self.state .model_catalog @@ -1872,6 +1898,28 @@ impl ThreadManager { } impl ThreadManagerState { + fn models_for_thread( + &self, + config: &Config, + auth_manager: &Arc, + runtime_auth: &crate::runtime_auth::RuntimeAuthSlot, + originator: &str, + ) -> SharedModelsManager { + let runtime = crate::runtime_auth::runtime_for_config( + runtime_auth, + config, + Arc::clone(auth_manager), + &self.installation_id, + ); + let manager = self.model_catalog.for_thread( + config, + Arc::clone(auth_manager), + runtime, + Arc::ptr_eq(auth_manager, &self.auth_manager), + ); + manager.for_originator(originator).unwrap_or(manager) + } + pub(crate) fn shared_thread_instructions_provider( &self, root_thread_id: ThreadId, @@ -2706,21 +2754,12 @@ impl ThreadManagerState { } else { Arc::new(mcp_manager.for_plugins_manager(Arc::clone(&plugins_manager))) }; - let runtime = crate::runtime_auth::runtime_for_config( + let models_manager = self.models_for_thread( + &config, + &auth_manager, &runtime_auth, - &config, - Arc::clone(&auth_manager), - &self.installation_id, + &startup_context.originator, ); - let manager = self.model_catalog.for_thread( - &config, - Arc::clone(&auth_manager), - runtime, - Arc::ptr_eq(&auth_manager, &self.auth_manager), - ); - let models_manager = manager - .for_originator(&startup_context.originator) - .unwrap_or(manager); let spawn_result = Session::spawn(SessionSpawnArgs { attached_thread, startup, diff --git a/codex-rs/core/tests/suite/models_cache_auth.rs b/codex-rs/core/tests/suite/models_cache_auth.rs index a6200a8395..8dd6f14bee 100644 --- a/codex-rs/core/tests/suite/models_cache_auth.rs +++ b/codex-rs/core/tests/suite/models_cache_auth.rs @@ -25,6 +25,7 @@ use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_completed; @@ -383,3 +384,75 @@ async fn auth_and_provider_switches_do_not_reuse_chatgpt_catalog() -> Result<()> assert_eq!(other_models.requests().len(), 1); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn host_model_info_uses_the_thread_catalog() -> Result<()> { + let slug = "gpt-5.5"; + let server = MockServer::start().await; + let test = test_codex() + .with_auth(header_auth("Bearer catalog-test")) + .with_model_info_override(slug, |model| { + model.comp_hash = Some("runtime-revision".into()); + }) + .build_with_auto_env(&server) + .await?; + + let mut options = test.start_thread_options().await; + let catalog = options + .config + .model_catalog + .take() + .expect("runtime catalog"); + let mut model = catalog.models.into_iter().find(|m| m.slug == slug).unwrap(); + model.comp_hash = Some("thread-revision".into()); + let mut next_model = model.clone(); + next_model.slug = "after-update".into(); + Mock::given(method("GET")) + .and(path("/provider/models")) + .and(header("authorization", "Bearer catalog-test")) + .and(header("originator", "codex_work_cca")) + .respond_with(ResponseTemplate::new(200).set_body_json(ModelsResponse { + models: vec![model, next_model], + })) + .mount(&server) + .await; + + options.config.model_provider.base_url = Some(format!("{}/provider", server.uri())); + options.config.model_context_window = Some(32_000); + options.metrics_service_name = Some("codex_work_cca".into()); + let overrides = options.config.to_models_manager_config(); + let default = test.thread_manager.get_models_manager(); + let default_model = default.get_model_info(slug, &overrides).await; + assert_eq!(default_model.comp_hash.as_deref(), Some("runtime-revision")); + + let startup = test + .thread_manager + .models_manager_for_startup( + &options.config, + options.metrics_service_name.as_deref(), + &options.initial_history, + ) + .await; + startup + .list_models( + RefreshStrategy::OnlineIfUncached, + options.config.http_client_factory(), + ) + .await; + let selected = startup.get_model_info(slug, &overrides).await; + assert_eq!(selected.comp_hash.as_deref(), Some("thread-revision")); + assert_eq!(selected.context_window, Some(32_000)); + + let thread = test.thread_manager.start_thread(options).await?.thread; + assert_eq!(thread.selected_model_info().await, selected.model); + thread + .update_thread_settings(ThreadSettingsOverrides { + model: Some("after-update".into()), + ..Default::default() + }) + .await?; + let next = thread.selected_model_info().await; + assert_eq!(next.slug, "after-update"); + assert_eq!(next.comp_hash.as_deref(), Some("thread-revision")); + Ok(()) +}