Expose model metadata from each thread's catalog and settings (#59958)

## What changed

- Add `ThreadManager::models_manager_for_startup` to obtain the model catalog used when starting or cold-resuming a root thread, including its effective originator.
- Add `CodexThread::selected_model_info` to resolve the model for future turns using the thread's current settings, catalog, and model overrides.
- Share catalog selection logic between startup lookup and thread creation.

## Testing

Add coverage verifying that startup and thread lookups use the thread-specific catalog, preserve the configured context window, and reflect model selection updates.

GitOrigin-RevId: d7976caebb7459e1e9ca55f3c6f6bb2af92cf3de
This commit is contained in:
Sean Huang
2026-11-06 19:16:53 +00:00
committed by copyberry
parent ebe94e25c5
commit 12dc86beb3
4 changed files with 146 additions and 13 deletions
+6
View File
@@ -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<ModelInfo> {
self.session.selected_model_info().await
}
pub async fn config_snapshot(&self) -> ThreadConfigSnapshot {
self.session.thread_config_snapshot().await
}
+15
View File
@@ -2284,6 +2284,21 @@ impl Session {
.await)
}
pub(crate) async fn selected_model_info(&self) -> Arc<ModelInfo> {
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
}
+52 -13
View File
@@ -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<AuthManager>,
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,
@@ -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(())
}