From dc40446064cc0896851a5f2ef69f10f62d2206a1 Mon Sep 17 00:00:00 2001 From: ioanbudea-OAI Date: Fri, 30 Oct 2026 15:01:47 +0000 Subject: [PATCH] Preserve model metadata origin through runtime resolution (#58361) ## What changed Return `CatalogModel` from model lookups, keeping metadata together with its `Bundled`, `Provider`, `ExplicitConfig`, or `Synthetic` origin through config overrides and runtime snapshots. Replace `used_fallback_model_metadata` checks with explicit `Synthetic` origin checks. Share the underlying `Arc` with extension stores and use copy-on-write for metadata changes. Keep origin tracking outside serialized model metadata. ## Testing Extend coverage for origin retention across cache reloads, namespaced model lookups, catalog fallback, and config overrides. Verify that step snapshots and extension stores share the same model metadata allocation. GitOrigin-RevId: bb6130c94a7a795aa168ee24664a1dd453579fba --- .../app-server/tests/common/models_cache.rs | 1 - .../codex-api/tests/models_integration.rs | 1 - codex-rs/core/src/agent/child_config.rs | 5 +- .../core/src/compact_remote_v2_attempt.rs | 4 +- codex-rs/core/src/guardian/mod.rs | 4 +- codex-rs/core/src/guardian/review_request.rs | 2 +- .../src/guardian/review_session_prompt.rs | 6 +- codex-rs/core/src/guardian/reviewer_config.rs | 19 +++-- codex-rs/core/src/guardian/tests.rs | 6 +- .../core/src/session/code_mode_warning.rs | 7 +- .../src/session/code_mode_warning_tests.rs | 25 ++++--- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/review.rs | 3 +- codex-rs/core/src/session/session.rs | 5 +- codex-rs/core/src/session/step_activation.rs | 15 ++-- .../core/src/session/step_activation_tests.rs | 42 ++++++++--- codex-rs/core/src/session/step_settings.rs | 8 +-- codex-rs/core/src/session/turn_context.rs | 11 +-- codex-rs/core/src/test_support.rs | 4 +- .../core/src/unified_exec/async_watcher.rs | 6 +- codex-rs/core/tests/suite/auto_review.rs | 1 - codex-rs/core/tests/suite/guardian_review.rs | 11 +-- .../core/tests/suite/injected_models_cache.rs | 1 - .../tests/suite/model_runtime_selectors.rs | 1 - codex-rs/core/tests/suite/model_switching.rs | 2 - codex-rs/core/tests/suite/models_cache_ttl.rs | 1 - codex-rs/core/tests/suite/remote_models.rs | 7 +- 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/core/tests/suite/wake_reservation.rs | 2 +- .../src/async_scorer/classification.rs | 5 +- .../src/async_scorer/extension_tests.rs | 12 ++-- .../guardian-v2/src/async_scorer/startup.rs | 2 +- codex-rs/guardian-context/src/budget.rs | 9 ++- codex-rs/memories/write/src/runtime.rs | 2 +- codex-rs/model-provider/src/gcp/gcp_tests.rs | 5 +- .../model-provider/src/models_endpoint.rs | 12 ++-- .../src/models_originator_tests.rs | 4 +- .../src/api_key_discovery_tests.rs | 17 +++-- codex-rs/models-manager/src/catalog.rs | 46 ++++++------ .../src/catalog_origin_tests.rs | 37 +++++++++- codex-rs/models-manager/src/manager.rs | 72 ++++++++++--------- codex-rs/models-manager/src/manager_tests.rs | 42 +++++++---- codex-rs/models-manager/src/model_info.rs | 1 - .../models-manager/src/model_info_tests.rs | 1 - codex-rs/models-manager/src/test_support.rs | 8 ++- .../prompts/src/model_instructions_tests.rs | 1 - codex-rs/protocol/src/openai_models.rs | 40 +++++++++-- 49 files changed, 309 insertions(+), 212 deletions(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 9aec5a1bca..1d30253303 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -63,7 +63,6 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index b284a8db6f..05d1806c1f 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -157,7 +157,6 @@ async fn models_client_hits_models_endpoint() { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/core/src/agent/child_config.rs b/codex-rs/core/src/agent/child_config.rs index 3d56d7e646..96d9675f63 100644 --- a/codex-rs/core/src/agent/child_config.rs +++ b/codex-rs/core/src/agent/child_config.rs @@ -13,6 +13,7 @@ use codex_features::Feature; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::models::BaseInstructions; +use codex_protocol::openai_models::ModelOrigin; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; @@ -242,7 +243,7 @@ async fn apply_requested_spawn_agent_model_overrides( )?; config.model_reasoning_effort = Some(reasoning_effort); } else { - config.model_reasoning_effort = selected_model_info.default_reasoning_level; + config.model_reasoning_effort = selected_model_info.default_reasoning_level.clone(); } return Ok(()); @@ -312,7 +313,7 @@ async fn apply_spawn_agent_role( .models_manager .get_model_info(&model, &config.to_models_manager_config()) .await; - if model_info.used_fallback_model_metadata { + if model_info.origin == ModelOrigin::Synthetic { return Ok(()); } diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index 93f09af970..2c77c045af 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -2,6 +2,7 @@ use crate::context::UserGoalUpdate; use codex_protocol::ResponseItemId; +use codex_protocol::openai_models::CatalogModel; use std::collections::HashSet; use std::sync::Arc; @@ -28,7 +29,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::TokenUsage; use codex_rollout_trace::CompactionTraceContext; use tokio_util::sync::CancellationToken; @@ -53,7 +53,7 @@ pub(super) enum RemoteCompactV2AttemptInput<'a> { pub(crate) struct BackgroundCompactionCandidate { pub(crate) prefix: CompactionPrefix, - pub(super) model_info: Arc, + pub(super) model_info: Arc, pub(super) cutoff_world_state: Arc, pub(super) attempt: RemoteCompactV2Attempt, } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index afa6e4a949..93d612267b 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -34,12 +34,12 @@ pub(crate) use score_authorization::ServingAssessment; pub(crate) mod test_host; use codex_protocol::items::ModelInvocationContext; +use codex_protocol::openai_models::CatalogModel; use std::sync::Arc; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GuardianAssessmentOutcome; @@ -99,7 +99,7 @@ pub(crate) struct GuardianReviewContext { environments: TurnEnvironmentSnapshot, project_instructions: Option>, // Model and reasoning inputs are carried for the follow-up Guardian and V2 migrations. - pub(crate) model_info: Arc, + pub(crate) model_info: Arc, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummary, pub(crate) personality: Option, diff --git a/codex-rs/core/src/guardian/review_request.rs b/codex-rs/core/src/guardian/review_request.rs index 6d5466dce4..48c5012517 100644 --- a/codex-rs/core/src/guardian/review_request.rs +++ b/codex-rs/core/src/guardian/review_request.rs @@ -113,7 +113,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime { ) -> Option<(String, Arc)> { let active = self.session.active_turn.lock().await; let turn = &active.as_ref()?.task.as_ref()?.turn_context; - Some((turn.sub_id.clone(), Arc::clone(turn.model_info()))) + Some((turn.sub_id.clone(), Arc::clone(&turn.model_info().model))) } async fn prepare( diff --git a/codex-rs/core/src/guardian/review_session_prompt.rs b/codex-rs/core/src/guardian/review_session_prompt.rs index 6b1585a9ea..21ea0481ae 100644 --- a/codex-rs/core/src/guardian/review_session_prompt.rs +++ b/codex-rs/core/src/guardian/review_session_prompt.rs @@ -4,8 +4,10 @@ use codex_guardian_reviewer::GuardianPromptItems; use codex_protocol::models::ImageDetail; use codex_protocol::models::ImageReference; +use codex_protocol::openai_models::CatalogModel; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelOrigin; use codex_tools::normalize_output_image_detail; use std::borrow::Cow; use tracing::warn; @@ -23,7 +25,7 @@ use crate::image_preparation::unified_image_budget_enabled; pub(super) async fn build_guardian_prompt_items( params: &GuardianReviewSessionParams, reviewer: &GuardianReviewSession, - model_info: &ModelInfo, + model_info: &CatalogModel, reviewer_history: &ContextManager, history: GuardianTranscriptHistory<'_>, mode: GuardianPromptMode, @@ -49,7 +51,7 @@ pub(super) async fn build_guardian_prompt_items( / 100 }); let context_window = - context_window.filter(|limit| *limit > 0 && !model_info.used_fallback_model_metadata); + context_window.filter(|limit| *limit > 0 && model_info.origin != ModelOrigin::Synthetic); let image_mode = if unified_image_budget_enabled(¶ms.spawn_config.features, model_info) { ImagePreparationMode::UnifiedBudget } else { diff --git a/codex-rs/core/src/guardian/reviewer_config.rs b/codex-rs/core/src/guardian/reviewer_config.rs index e6f0d5a0a8..66a5ed5f4a 100644 --- a/codex-rs/core/src/guardian/reviewer_config.rs +++ b/codex-rs/core/src/guardian/reviewer_config.rs @@ -3,6 +3,7 @@ //! A missing default reviewer gets one shared catalog refresh per parent session. //! Refresh work survives cancelled waiters and precedes checkpoint compatibility decisions. +use codex_protocol::openai_models::CatalogModel; use std::sync::Arc; use codex_features::Feature; @@ -82,10 +83,7 @@ pub fn build_guardian_review_session_config( pub(crate) async fn resolve_review_model( session: &crate::session::session::Session, context: &super::GuardianReviewContext, -) -> ( - codex_guardian_reviewer::ReviewModel, - Arc, -) { +) -> (codex_guardian_reviewer::ReviewModel, Arc) { let turn = context.turn(); // Check before resolving: a refresh that finishes during the lookup still // requires a reread, but later calls only need one catalog resolution. @@ -131,10 +129,7 @@ pub(crate) async fn resolve_review_model( async fn resolve_review_model_from_catalog( session: &crate::session::session::Session, context: &super::GuardianReviewContext, -) -> ( - codex_guardian_reviewer::ReviewModel, - Arc, -) { +) -> (codex_guardian_reviewer::ReviewModel, Arc) { let turn = context.turn(); // Keep a successful refresh even if writing it to the disk cache failed. let models_manager = &session.services.models_manager; @@ -146,9 +141,13 @@ async fn resolve_review_model_from_catalog( .into_iter() .filter(|entry| { context.model_info.auto_review_model_override.is_some() - || entry.origin != codex_models_manager::ModelOrigin::Bundled + || matches!( + entry.origin, + codex_models_manager::ModelOrigin::Provider + | codex_models_manager::ModelOrigin::ExplicitConfig + ) }) - .map(|entry| entry.model) + .map(|entry| Arc::unwrap_or_clone(entry.model)) .collect(), ); let default_review_model_id = turn.provider.approval_review_preferred_model(); diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 14c95bcb1d..239a965fcb 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1999,7 +1999,7 @@ async fn guardian_request_model_for_auto_review( crate::guardian::test_host::install(&session, &turn.config); } GuardianTestCatalog::ParentOnly => { - let parent_model = turn.model_info().as_ref().clone(); + let parent_model = turn.model_info().model.as_ref().clone(); let auth_manager = Arc::clone(&session.services.auth_manager); let models_manager = StaticModelsManager::new( Some(auth_manager), @@ -2510,7 +2510,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: .models_manager = Arc::new(StaticModelsManager::new( Some(auth_manager), ModelsResponse { - models: vec![reviewer_model], + models: vec![Arc::unwrap_or_clone(reviewer_model.model)], }, codex_models_manager::ModelOrigin::ExplicitConfig, )); @@ -3992,7 +3992,7 @@ async fn guardian_review_session_config_clears_context_overrides_for_distinct_ef async fn guardian_review_session_config_preserves_context_overrides_for_same_effective_model() { let server = start_mock_server().await; let (mut session, mut turn) = guardian_test_session_and_turn(&server).await; - let parent_model = turn.model_info().as_ref().clone(); + let parent_model = turn.model_info().model.as_ref().clone(); let auth_manager = Arc::clone(&session.services.auth_manager); Arc::get_mut(&mut session) .expect("session should be unique") diff --git a/codex-rs/core/src/session/code_mode_warning.rs b/codex-rs/core/src/session/code_mode_warning.rs index 87f122b065..fb386c42d5 100644 --- a/codex-rs/core/src/session/code_mode_warning.rs +++ b/codex-rs/core/src/session/code_mode_warning.rs @@ -1,16 +1,17 @@ use codex_features::Feature; use codex_features::Features; -use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::CatalogModel; +use codex_protocol::openai_models::ModelOrigin; pub(super) fn unsupported_code_mode_warning( - model_info: &ModelInfo, + model_info: &CatalogModel, features: &Features, ) -> Option { let code_mode_enabled = features.enabled(Feature::CodeMode) || features.enabled(Feature::CodeModeOnly); if !code_mode_enabled || model_info.tool_mode.is_some() - || model_info.used_fallback_model_metadata + || model_info.origin == ModelOrigin::Synthetic { return None; } diff --git a/codex-rs/core/src/session/code_mode_warning_tests.rs b/codex-rs/core/src/session/code_mode_warning_tests.rs index ed393d870e..ef1e9718a0 100644 --- a/codex-rs/core/src/session/code_mode_warning_tests.rs +++ b/codex-rs/core/src/session/code_mode_warning_tests.rs @@ -2,16 +2,17 @@ use super::unsupported_code_mode_warning; use codex_features::Feature; use codex_features::Features; use codex_models_manager::model_info::model_info_from_slug; -use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::CatalogModel; +use codex_protocol::openai_models::ModelOrigin; use codex_protocol::openai_models::ToolMode; use pretty_assertions::assert_eq; const MODEL_SLUG: &str = "test-model"; -fn known_model_info() -> ModelInfo { - ModelInfo { - used_fallback_model_metadata: false, - ..model_info_from_slug(MODEL_SLUG) +fn known_model_info() -> CatalogModel { + CatalogModel { + model: model_info_from_slug(MODEL_SLUG).into(), + origin: ModelOrigin::Provider, } } @@ -50,10 +51,8 @@ fn does_not_warn_when_model_has_tool_mode_selector() { features.enable(Feature::CodeModeOnly); for tool_mode in [ToolMode::Direct, ToolMode::CodeMode, ToolMode::CodeModeOnly] { - let model_info = ModelInfo { - tool_mode: Some(tool_mode), - ..known_model_info() - }; + let mut model_info = known_model_info(); + model_info.tool_mode = Some(tool_mode); assert_eq!(unsupported_code_mode_warning(&model_info, &features), None); } } @@ -64,7 +63,13 @@ fn fallback_metadata_only_uses_existing_warning() { features.enable(Feature::CodeMode); assert_eq!( - unsupported_code_mode_warning(&model_info_from_slug(MODEL_SLUG), &features), + unsupported_code_mode_warning( + &CatalogModel { + model: model_info_from_slug(MODEL_SLUG).into(), + origin: ModelOrigin::Synthetic + }, + &features + ), None ); } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3c1889b25a..0b08875395 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -4199,7 +4199,7 @@ impl Session { } let extension_data = codex_extension_api::ExtensionData::new(turn_context.sub_id.clone()); - extension_data.insert_arc(Arc::clone(&settings.model_info)); + extension_data.insert_arc(Arc::clone(&settings.model_info.model)); // Standalone and hosted search must use the same turn-scoped permission mode. extension_data.insert(turn_context.config.web_search_mode.value()); if let Some(messages) = settings diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index e93643f1d5..f73f895efd 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,5 +1,6 @@ use super::step_settings::ResolvedStepSettings; use super::*; +use codex_protocol::openai_models::ModelOrigin; /// Spawn a review thread using the given prompt. pub(super) async fn spawn_review_thread( @@ -45,7 +46,7 @@ pub(super) async fn spawn_review_thread( per_turn_config.features = review_features.clone(); if let Some(current_effort) = reasoning_effort.as_ref() && review_model_info.slug != parent_turn_context.model_info().slug - && !review_model_info.used_fallback_model_metadata + && review_model_info.origin != ModelOrigin::Synthetic && !review_model_info .supported_reasoning_levels .iter() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index b3e6fd82cf..52325c5d20 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -35,6 +35,7 @@ use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::ProfileWorkspaceRoot; +use codex_protocol::openai_models::CatalogModel; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::EnvironmentConfig; @@ -829,7 +830,7 @@ impl Session { auth_manager: Arc, models_manager: SharedModelsManager, git_root_discovery: Arc, - model_info: ModelInfo, + model_info: CatalogModel, exec_policy: Arc, tx_event: Sender, agent_status: watch::Sender, @@ -1057,7 +1058,7 @@ impl Session { )); // Publish the already resolved model before extensions make startup decisions. // Turn construction refreshes this attachment when the selected model changes. - thread_extension_init.insert(model_info); + thread_extension_init.insert(Arc::unwrap_or_clone(model_info.model)); let tool_policy = thread_extension_init .get::() .unwrap_or_else(|| { diff --git a/codex-rs/core/src/session/step_activation.rs b/codex-rs/core/src/session/step_activation.rs index ad20372483..d89a1f8362 100644 --- a/codex-rs/core/src/session/step_activation.rs +++ b/codex-rs/core/src/session/step_activation.rs @@ -14,10 +14,12 @@ use crate::environment_selection::validate_environment_ids_and_cwds; use crate::exec_policy::AllowPrefixRules; use codex_features::Feature; use codex_prompts::ResolvedModelMessages; +use codex_protocol::openai_models::CatalogModel; use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::GuardianV2TranscriptModelConfig; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelOrigin; use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnSettingsUpdate; @@ -86,16 +88,16 @@ fn check_legacy_turn_safety( /// Model-owned portion of the temporary legacy-turn safety check. Ordinary /// model metadata may differ so diagnostics can expose unmigrated consumers. fn check_legacy_model_safety( - admitted: &ModelInfo, - current: &ModelInfo, - destination: &ModelInfo, + admitted: &CatalogModel, + current: &CatalogModel, + destination: &CatalogModel, admitted_config: &Config, live_config: &Config, ) -> Result<(), String> { - if admitted.used_fallback_model_metadata || current.used_fallback_model_metadata { + if admitted.origin == ModelOrigin::Synthetic || current.origin == ModelOrigin::Synthetic { return Err("the active model has only fallback metadata".to_string()); } - if destination.used_fallback_model_metadata { + if destination.origin == ModelOrigin::Synthetic { return Err("the destination model has only fallback metadata".to_string()); } let retained_models = [admitted, current]; @@ -183,7 +185,8 @@ fn check_legacy_model_safety( if destination.auto_review_model_override.is_none() { let destination_model_messages = ResolvedModelMessages::from_model(destination); let destination_auto_review = destination_model_messages.auto_review(); - let retained_model_messages = retained_models.map(ResolvedModelMessages::from_model); + let retained_model_messages = + retained_models.map(|model| ResolvedModelMessages::from_model(model)); let retained_auto_review = retained_model_messages .each_ref() .map(ResolvedModelMessages::auto_review); diff --git a/codex-rs/core/src/session/step_activation_tests.rs b/codex-rs/core/src/session/step_activation_tests.rs index 094b59de58..71923d1a15 100644 --- a/codex-rs/core/src/session/step_activation_tests.rs +++ b/codex-rs/core/src/session/step_activation_tests.rs @@ -38,8 +38,10 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::AsyncClassifierMode; use codex_protocol::openai_models::AutoReviewMessages; +use codex_protocol::openai_models::CatalogModel; use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::GuardianV2TranscriptModelConfig; +use codex_protocol::openai_models::ModelOrigin; use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; @@ -394,7 +396,7 @@ impl ModelsManager for GatedModelsManager { &'a self, model: &'a str, config: &'a ModelsManagerConfig, - ) -> ModelsManagerFuture<'a, ModelInfo> { + ) -> ModelsManagerFuture<'a, CatalogModel> { Box::pin(async move { let pause = if model == MODEL_B { self.first_b_lookup.lock().expect("lookup gate").take() @@ -685,7 +687,13 @@ async fn submitted_sparse_updates_preserve_captured_steps_and_ordering() { ] ); assert!(Arc::ptr_eq(&before.turn, &after.turn)); - assert_eq!(after.settings.model_info.as_ref(), &expected_destination); + assert_eq!( + after.settings.model_info.as_ref(), + &CatalogModel { + model: expected_destination.into(), + origin: ModelOrigin::ExplicitConfig + } + ); let initial_budget = turn .config .token_budget @@ -743,6 +751,16 @@ async fn submitted_sparse_updates_preserve_captured_steps_and_ordering() { assert_eq!(step_values(&retained), step_values(&after)); assert_eq!(before.settings.model_info.slug, MODEL_A); assert_eq!(turn.model_info().slug, MODEL_A); + for step in [&before, &during, &after, &retained] { + let extension_model = step + .extension_data + .get::() + .expect("step metadata"); + assert!(Arc::ptr_eq( + &step.settings.model_info.model, + &extension_model + )); + } drop(submissions); loop_task.await.expect("submission loop teardown"); } @@ -1044,7 +1062,7 @@ async fn activation_must_match_the_retained_turn_authority(change: ManagedPolicy }); let model_info = Arc::make_mut(&mut settings.model_info); model_info.model_specialty = None; - model_info.used_fallback_model_metadata = false; + model_info.origin = ModelOrigin::Provider; }); assert!( !turn @@ -1148,7 +1166,7 @@ async fn activation_must_match_the_retained_turn_authority(change: ManagedPolicy ); } -fn safety_models() -> (ModelInfo, ModelInfo) { +fn safety_models() -> (CatalogModel, CatalogModel) { let [mut admitted, mut destination]: [ModelInfo; 2] = activation_models() .try_into() .expect("two activation models"); @@ -1157,12 +1175,20 @@ fn safety_models() -> (ModelInfo, ModelInfo) { model.node_repl_auto_review_required = false; model.node_repl_disabled = false; model.auto_review_model_override = None; - model.used_fallback_model_metadata = false; let messages = model.model_messages.as_mut().expect("model messages"); messages.auto_review = None; messages.guardian_v2 = None; } - (admitted, destination) + ( + CatalogModel { + model: admitted.into(), + origin: ModelOrigin::Provider, + }, + CatalogModel { + model: destination.into(), + origin: ModelOrigin::Provider, + }, + ) } fn parent_review_messages(model: &mut ModelInfo) -> &mut AutoReviewMessages { @@ -1222,7 +1248,7 @@ async fn model_safety_rejects_fallback_metadata() { let (_, turn) = make_session_and_context().await; let (admitted, destination) = safety_models(); let mut fallback = admitted.clone(); - fallback.used_fallback_model_metadata = true; + fallback.origin = ModelOrigin::Synthetic; for (old, current, next, reason) in [ ( &fallback, @@ -1291,7 +1317,7 @@ async fn parent_fallback_preserves_explicit_empty_and_bundled_defaults() { let mut config = turn.config.as_ref().clone(); config.guardian_policy_config = None; let (admitted, mut destination) = safety_models(); - let check = |destination: &ModelInfo| { + let check = |destination: &CatalogModel| { check_legacy_model_safety(&admitted, &admitted, destination, &config, &config) }; parent_review_messages(&mut destination).policy = Some(defaults.policy.to_string()); diff --git a/codex-rs/core/src/session/step_settings.rs b/codex-rs/core/src/session/step_settings.rs index b66fb1931d..3a75062ab5 100644 --- a/codex-rs/core/src/session/step_settings.rs +++ b/codex-rs/core/src/session/step_settings.rs @@ -14,7 +14,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; -use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::CatalogModel; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use std::sync::Arc; @@ -42,7 +42,7 @@ pub(crate) struct ResolvedStepSettings { /// Inputs for later sparse patches. Unset defaults and unsupported requested /// tiers must not be reconstructed from the effective values below. selected: Arc, - pub(crate) model_info: Arc, + pub(crate) model_info: Arc, /// Effective request summary: the configured value or the pinned model's default. pub(crate) reasoning_summary: ReasoningSummary, /// Tier sent on requests, filtered by feature and model support. @@ -56,7 +56,7 @@ impl ResolvedStepSettings { /// Derives request values from selected settings and pinned metadata. pub(super) fn new( selected: Arc, - model_info: Arc, + model_info: Arc, features: &Features, ) -> Self { let reasoning_summary = selected @@ -241,7 +241,7 @@ impl StepSettings { &self, models_manager: &dyn ModelsManager, overrides: &ModelInfoOverrides, - ) -> ModelInfo { + ) -> CatalogModel { let config = overrides.models_manager_config(self.personality); models_manager .get_model_info(self.collaboration_mode.model(), &config) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8bab50c7ec..c08333d350 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -26,8 +26,9 @@ use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::openai_models::CatalogModel; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; -use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelOrigin; use codex_protocol::permissions::RawFileSystemSandboxPolicy; use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::EnvironmentConfigState; @@ -538,13 +539,13 @@ impl TurnContext { } /// Captures current model metadata without preparing a step. - pub(crate) fn capture_current_model_info(&self) -> Arc { + pub(crate) fn capture_current_model_info(&self) -> Arc { Arc::clone(&self.next_step_settings.load().model_info) } /// Legacy: returns the frozen initial-turn model metadata. /// Step-scoped consumers should use their captured `StepContext::settings`. - pub(crate) fn model_info(&self) -> &Arc { + pub(crate) fn model_info(&self) -> &Arc { &self.initial_settings.model_info } @@ -1295,7 +1296,7 @@ impl Session { // Only execution and initial context creation publish model metadata. self.services .thread_extension_data - .insert(model_info.clone()); + .insert_arc(Arc::clone(&model_info.model)); self.resolve_multi_agent_version_for_model(&model_info, &per_turn_config) } TurnContextBuildMode::StartupPrewarm | TurnContextBuildMode::InjectItems => { @@ -1407,7 +1408,7 @@ impl Session { } pub(crate) async fn maybe_emit_model_warnings_for_turn(&self, tc: &TurnContext) { - if tc.model_info().used_fallback_model_metadata { + if tc.model_info().origin == ModelOrigin::Synthetic { self.send_event( tc, EventMsg::Warning(WarningEvent { diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index c14f568d00..2c33f0c78e 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -242,7 +242,9 @@ pub fn get_model_offline(model: Option<&str>) -> String { } pub fn construct_model_info_offline(model: &str, config: &Config) -> ModelInfo { - construct_model_info_offline_for_tests(model, &config.to_models_manager_config()) + Arc::unwrap_or_clone( + construct_model_info_offline_for_tests(model, &config.to_models_manager_config()).model, + ) } #[derive(Clone, Copy)] diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index f30c750cbb..49ae8b2cf2 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -1,3 +1,4 @@ +use codex_protocol::openai_models::CatalogModel; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -24,7 +25,6 @@ use crate::tools::events::ToolEventStage; use codex_core_plugins::PluginCommandAttribution; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; -use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecCommandSource; @@ -332,7 +332,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( sandbox_type: Option, session_ref: Arc, turn_ref: Arc, - model_info: Arc, + model_info: Arc, call_id: String, command: Vec, cwd: PathUri, @@ -384,7 +384,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( sandbox_type: Option, session_ref: Arc, turn_ref: Arc, - model_info: Arc, + model_info: Arc, call_id: String, command: Vec, cwd: PathUri, diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index bd970ce712..2eae76fcc9 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -645,7 +645,6 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index 924048f7ab..6c593c946c 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -359,14 +359,9 @@ async fn guardian_custom_gateway_selects_reviewer_by_catalog_origin( .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| { config.model = Some("gateway-main".to_string()); - config.model_catalog = match origin { - ModelOrigin::ExplicitConfig => Some(catalog), - ModelOrigin::Bundled | ModelOrigin::Provider => None, - }; - config.model_provider.model_catalog_url = match origin { - ModelOrigin::Provider => Some(catalog_url.into()), - ModelOrigin::Bundled | ModelOrigin::ExplicitConfig => None, - }; + config.model_catalog = (origin == ModelOrigin::ExplicitConfig).then_some(catalog); + config.model_provider.model_catalog_url = + (origin == ModelOrigin::Provider).then(|| catalog_url.into()); config.model_provider.name = "Gateway".to_string(); config.model_provider.supports_websockets = false; config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); diff --git a/codex-rs/core/tests/suite/injected_models_cache.rs b/codex-rs/core/tests/suite/injected_models_cache.rs index 8d2ab86bd8..e206437c90 100644 --- a/codex-rs/core/tests/suite/injected_models_cache.rs +++ b/codex-rs/core/tests/suite/injected_models_cache.rs @@ -172,7 +172,6 @@ impl ModelsEndpointClient for TestModelsEndpoint { fn remote_model(slug: &str) -> ModelInfo { ModelInfo { visibility: ModelVisibility::List, - used_fallback_model_metadata: false, ..model_info_from_slug(slug) } } diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 2c3de12486..f3b8eaed31 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -48,7 +48,6 @@ struct RemoteModelResponse { fn remote_model(slug: &str) -> ModelInfo { ModelInfo { visibility: ModelVisibility::List, - used_fallback_model_metadata: false, ..model_info_from_slug(slug) } } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 2fe59dd372..a45d7d46c9 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -122,7 +122,6 @@ fn test_model_info( visibility: ModelVisibility::List, supported_in_api: true, input_modalities, - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, @@ -1255,7 +1254,6 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index e2e4a17a1a..842e923427 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -675,7 +675,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index b0f39afc77..6edb1cb3ad 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -579,7 +579,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, @@ -854,7 +853,6 @@ async fn remote_models_apply_legacy_instructions(auth: CodexAuth) -> Result<()> visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, @@ -1443,7 +1441,6 @@ fn test_remote_model_with_policy( visibility, supported_in_api: true, input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, @@ -1538,7 +1535,7 @@ async fn model_catalog_url_supplies_conversation_model_and_instructions() -> Res model.visibility = ModelVisibility::List; model.supported_in_api = true; model.support_verbosity = false; - model.used_fallback_model_metadata = false; + model .model_messages .as_mut() @@ -1597,7 +1594,7 @@ async fn failed_catalog_refresh_preserves_tool_search_on_the_next_turn() -> Resu let mut model = codex_models_manager::model_info::model_info_from_slug("gateway-model"); model.visibility = ModelVisibility::List; model.supports_search_tool = true; - model.used_fallback_model_metadata = false; + let catalog = ModelsResponse { models: vec![model], }; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 54e8a4b461..bf4a27aade 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -3594,7 +3594,6 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: vec![InputModality::Text], - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index d536a9cf8e..e4f3a20703 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -90,7 +90,6 @@ fn test_model_info( visibility, supported_in_api: true, input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 6a04080fdc..1b0eeb2fdc 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1621,7 +1621,6 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an visibility: ModelVisibility::List, supported_in_api: true, input_modalities: vec![InputModality::Text], - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/core/tests/suite/wake_reservation.rs b/codex-rs/core/tests/suite/wake_reservation.rs index 6ede021843..9bfcbfa98a 100644 --- a/codex-rs/core/tests/suite/wake_reservation.rs +++ b/codex-rs/core/tests/suite/wake_reservation.rs @@ -85,7 +85,7 @@ impl ModelsManager for GatedModelsManager { &'a self, model: &'a str, config: &'a ModelsManagerConfig, - ) -> ModelsManagerFuture<'a, ModelInfo> { + ) -> ModelsManagerFuture<'a, codex_models_manager::CatalogModel> { Box::pin(async move { if self.gate_next_lookup.swap(/*val*/ false, Ordering::SeqCst) { self.context_build.entered.notify_one(); diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs b/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs index 74b81d9780..1a4776bd01 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs @@ -3,6 +3,7 @@ //! Requests outside every active action's lag allowance stop as superseded, not failed. //! Expiry also cancels metadata waits before transcript preparation, releasing reservations. +use codex_protocol::openai_models::ModelOrigin; use std::collections::BTreeMap; use std::sync::Arc; use std::time::Instant; @@ -302,10 +303,10 @@ impl Classification { .get_models_manager() .get_model_info(review_model_id, &config.to_models_manager_config()) .await; - if review_model.used_fallback_model_metadata && review_model_override.is_none() { + if review_model.origin == ModelOrigin::Synthetic && review_model_override.is_none() { parent_model.clone() } else { - Some(Arc::new(review_model)) + Some(review_model.model) } } else { None diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 59ab64ae9e..bcb74a512e 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -310,7 +310,7 @@ async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Resul .get_model_info("gpt-5.5", &config.to_models_manager_config()) .await; model.node_repl_auto_review_required = true; - thread_store.insert(model); + thread_store.insert_arc(model.model); registry.thread_lifecycle_contributors()[0] .on_thread_start(ThreadStartInput { config: &config, @@ -801,7 +801,7 @@ async fn computer_use_only_scores_cannot_approve_other_actions() -> Result<()> { computer_use: Some(codex_protocol::openai_models::GuardianReviewMode::Adaptive), ..Default::default() }); - thread_store.insert(changed_model); + thread_store.insert_arc(changed_model.model); assert_eq!( cached_approval( &fixture.registry, @@ -1049,7 +1049,7 @@ async fn sample_configured_conversation_history_with_delivery( .get_models_manager() .get_model_info("gpt-5.5", &config.to_models_manager_config()) .await; - thread_store.insert(parent_model); + thread_store.insert_arc(parent_model.model); } assert_eq!( cached_approval( @@ -1316,7 +1316,7 @@ async fn contributor_fails_closed_when_model_configuration_is_invalid() -> Resul .test .codex .thread_extension_data() - .insert(parent_model); + .insert_arc(parent_model.model); fixture.score_tool(ToolName::plain("read_file")).await; fixture.assert_fails_closed("elevated_risk").await @@ -2472,7 +2472,7 @@ async fn contributor_skips_required_models_in_standard_scope() -> Result<()> { .get_model_info("gpt-5.5", &config.to_models_manager_config()) .await; model_info.slug = "protected-model".to_owned(); - thread_store.insert(model_info); + thread_store.insert_arc(model_info.model); // A late prewarm preview must leave the active model's review requirements intact. let _ = codex_core::guardian_review::prepare_review_prewarm(&test.codex).await?; let authorization = ScoreAuthorization::current(&test.codex, &Default::default()).await; @@ -3348,7 +3348,7 @@ async fn assert_parent_compaction_reuse(parent_context_for_review: bool) -> Resu retained_context: parent_context_for_review.then(codex_history::RetainedContext::default), review_context_revision, }; - thread_store.insert(parent_model); + thread_store.insert_arc(parent_model.model); registry.tool_lifecycle_contributors()[0] .on_tool_start(ToolStartInput { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/startup.rs b/codex-rs/ext/guardian-v2/src/async_scorer/startup.rs index 790ca5bc4e..c7521b050e 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/startup.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/startup.rs @@ -101,7 +101,7 @@ pub(super) async fn sampler_config( model, /*configured_window*/ None, ) }); - let luna_compaction_hash = luna_model.and_then(|model| model.comp_hash); + let luna_compaction_hash = luna_model.and_then(|model| model.comp_hash.clone()); LunaSamplerConfig { api_key_cyber_access_programs: ApiKeyCyberAccessPrograms::from_config(input.config), workspace_routing: input diff --git a/codex-rs/guardian-context/src/budget.rs b/codex-rs/guardian-context/src/budget.rs index 60b735fe74..a3ab6e69a6 100644 --- a/codex-rs/guardian-context/src/budget.rs +++ b/codex-rs/guardian-context/src/budget.rs @@ -3,6 +3,8 @@ //! Text and image bytes stay separate. Hosts provide existing-context estimates; //! the shared wire estimate reserves image tokens without counting base64 as text. +use codex_protocol::openai_models::CatalogModel; +use codex_protocol::openai_models::ModelOrigin; use std::io::Write; use codex_protocol::models::ContentItem; @@ -86,14 +88,11 @@ pub const DEFAULT_MAX_INPUT_TOKENS: usize = 128_000; /// Applies the same configured-window cap and effective percentage as sync review. /// Async callers supply no parent-model override. -pub fn effective_input_token_limit( - model: &codex_protocol::openai_models::ModelInfo, - configured_window: Option, -) -> usize { +pub fn effective_input_token_limit(model: &CatalogModel, configured_window: Option) -> usize { let supported = model .resolved_context_window() .unwrap_or(DEFAULT_MAX_INPUT_TOKENS as i64); - let supported = if model.used_fallback_model_metadata { + let supported = if model.origin == ModelOrigin::Synthetic { supported.min(DEFAULT_MAX_INPUT_TOKENS as i64) } else { supported diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 042642df82..7eff8ef926 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -300,7 +300,7 @@ impl MemoryStartupContext { StageOneRequestContext { version: self.version, - model_info, + model_info: Arc::unwrap_or_clone(model_info.model), session_telemetry: build_session_telemetry( &self.auth_manager, self.thread_id, diff --git a/codex-rs/model-provider/src/gcp/gcp_tests.rs b/codex-rs/model-provider/src/gcp/gcp_tests.rs index c45f775802..08a9c2d325 100644 --- a/codex-rs/model-provider/src/gcp/gcp_tests.rs +++ b/codex-rs/model-provider/src/gcp/gcp_tests.rs @@ -1,6 +1,7 @@ use codex_models_manager::ModelsManagerConfig; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::openai_models::ApplyPatchToolType; +use codex_protocol::openai_models::ModelOrigin; use codex_protocol::protocol::MultiAgentVersion; use pretty_assertions::assert_eq; @@ -329,7 +330,7 @@ async fn unknown_vertex_models_use_static_catalog_fallback() { .await; assert_eq!( - (model.slug, model.used_fallback_model_metadata), - ("vertex-test-model".to_string(), true) + (model.slug.as_str(), model.origin), + ("vertex-test-model", ModelOrigin::Synthetic) ); } diff --git a/codex-rs/model-provider/src/models_endpoint.rs b/codex-rs/model-provider/src/models_endpoint.rs index 5a0f3652de..655613be34 100644 --- a/codex-rs/model-provider/src/models_endpoint.rs +++ b/codex-rs/model-provider/src/models_endpoint.rs @@ -905,10 +905,7 @@ mod tests { )))) .await .unwrap(); - let model = codex_protocol::openai_models::ModelInfo { - used_fallback_model_metadata: false, - ..model_info_from_slug("command-auth-model") - }; + let model = model_info_from_slug("command-auth-model"); Mock::given(method("GET")) .and(path("/models")) .respond_with(ResponseTemplate::new(200).set_body_json(ModelsResponse { @@ -959,7 +956,10 @@ mod tests { manager .get_model_info(&model.slug, &Default::default()) .await, - model_info_from_slug(&model.slug) + codex_models_manager::CatalogModel { + model: model_info_from_slug(&model.slug).into(), + origin: codex_models_manager::ModelOrigin::Synthetic, + } ); assert_eq!( manager @@ -1001,7 +1001,7 @@ mod tests { let mut model = model_info_from_slug("provider-model"); model.visibility = ModelVisibility::List; model.supported_in_api = true; - model.used_fallback_model_metadata = false; + let expected = ModelsResponse { models: vec![model], }; diff --git a/codex-rs/model-provider/src/models_originator_tests.rs b/codex-rs/model-provider/src/models_originator_tests.rs index 392d3c4ebe..3979446742 100644 --- a/codex-rs/model-provider/src/models_originator_tests.rs +++ b/codex-rs/model-provider/src/models_originator_tests.rs @@ -28,7 +28,7 @@ async fn originator_managers_share_api_key_discovery_policy() { )])); let mut model = model_info_from_slug("gpt-5.5"); model.visibility = codex_protocol::openai_models::ModelVisibility::List; - model.used_fallback_model_metadata = false; + let catalog = ModelsResponse { models: vec![model], }; @@ -118,7 +118,7 @@ async fn originator_catalogs_are_isolated_and_reused_from_disk() { ] { let mut model = model_info_from_slug("gpt-5.5"); model.visibility = codex_protocol::openai_models::ModelVisibility::List; - model.used_fallback_model_metadata = false; + model.description = Some(originator.to_string()); let catalog = ModelsResponse { models: vec![model], diff --git a/codex-rs/models-manager/src/api_key_discovery_tests.rs b/codex-rs/models-manager/src/api_key_discovery_tests.rs index 6c3695c555..b843ced59f 100644 --- a/codex-rs/models-manager/src/api_key_discovery_tests.rs +++ b/codex-rs/models-manager/src/api_key_discovery_tests.rs @@ -1,6 +1,7 @@ //! API-key discovery opt-outs must gate network requests and cached catalog authority. use super::*; +use codex_protocol::openai_models::ModelOrigin; use pretty_assertions::assert_eq; #[tokio::test] @@ -70,7 +71,13 @@ async fn api_key_discovery_flag_controls_fetches_and_cached_catalogs() { models ); assert_eq!(endpoint.fetch_count(), 1); - assert_eq!(manager.get_model_info("dynamic", &config).await, models[0]); + assert_eq!( + manager.get_model_info("dynamic", &config).await, + CatalogModel { + model: models[0].clone().into(), + origin: ModelOrigin::Provider + } + ); // A runtime rollback must also suppress the already-loaded catalog. manager.set_api_key_model_discovery_enabled(/*enabled*/ false); @@ -82,11 +89,9 @@ async fn api_key_discovery_flag_controls_fetches_and_cached_catalogs() { bundled ); assert_eq!(manager.try_get_remote_models().unwrap(), bundled); - assert!( - manager - .get_model_info("dynamic", &config) - .await - .used_fallback_model_metadata + assert_eq!( + manager.get_model_info("dynamic", &config).await.origin, + ModelOrigin::Synthetic ); assert_eq!(endpoint.fetch_count(), 1); diff --git a/codex-rs/models-manager/src/catalog.rs b/codex-rs/models-manager/src/catalog.rs index 65873678db..831ed78df6 100644 --- a/codex-rs/models-manager/src/catalog.rs +++ b/codex-rs/models-manager/src/catalog.rs @@ -7,20 +7,8 @@ use codex_protocol::openai_models::ModelInfo; use crate::cache::ModelsCacheEntry; -/// Where an effective catalog entry was obtained. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModelOrigin { - Bundled, - Provider, - ExplicitConfig, -} - -/// Model metadata and its source captured from the same catalog snapshot. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CatalogModel { - pub model: ModelInfo, - pub origin: ModelOrigin, -} +pub use codex_protocol::openai_models::CatalogModel; +pub use codex_protocol::openai_models::ModelOrigin; /// Catalog authority is independent of whether its model metadata is borrowed or owned. pub(crate) enum SelectedCatalog<'a> { @@ -64,19 +52,25 @@ impl CatalogState { } } + pub fn model_origin(&self, selected: &SelectedCatalog<'_>, slug: &str) -> ModelOrigin { + if matches!(selected, SelectedCatalog::CurrentSnapshot(_)) + && self.provider_model_ids.contains(slug) + { + ModelOrigin::Provider + } else { + ModelOrigin::Bundled + } + } + pub fn model_catalog(&self, selected: SelectedCatalog<'_>) -> Vec { - let uses_snapshot_ids = matches!(selected, SelectedCatalog::CurrentSnapshot(_)); - selected - .into_models() - .into_owned() - .into_iter() - .map(|model| { - let origin = if uses_snapshot_ids && self.provider_model_ids.contains(&model.slug) { - ModelOrigin::Provider - } else { - ModelOrigin::Bundled - }; - CatalogModel { model, origin } + let models = match &selected { + SelectedCatalog::CurrentSnapshot(models) | SelectedCatalog::Fallback(models) => models, + }; + models + .iter() + .map(|model| CatalogModel { + origin: self.model_origin(&selected, &model.slug), + model: model.clone().into(), }) .collect() } diff --git a/codex-rs/models-manager/src/catalog_origin_tests.rs b/codex-rs/models-manager/src/catalog_origin_tests.rs index eb7f2fa3f9..3f40572d3f 100644 --- a/codex-rs/models-manager/src/catalog_origin_tests.rs +++ b/codex-rs/models-manager/src/catalog_origin_tests.rs @@ -26,7 +26,7 @@ async fn catalog_origins_survive_reload_and_auth_change() { .model_catalog_with_origins(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY) .await; assert!(catalog.contains(&CatalogModel { - model, + model: model.into(), origin: ModelOrigin::Provider })); assert_eq!( @@ -45,6 +45,16 @@ async fn catalog_origins_survive_reload_and_auth_change() { catalog ); assert_eq!(endpoint.fetch_count(), 1); + assert_eq!( + restarted + .get_model_info( + &format!("gateway/{slug}-variant"), + &ModelsManagerConfig::default() + ) + .await + .origin, + ModelOrigin::Provider + ); // An empty subsequent response restores bundled origins instead of retaining old IDs. assert_eq!( @@ -65,6 +75,13 @@ async fn catalog_origins_survive_reload_and_auth_change() { bundled ); assert_eq!(endpoint.fetch_count(), 2); + assert_eq!( + manager + .get_model_info("codex-auto-review", &ModelsManagerConfig::default()) + .await + .origin, + ModelOrigin::Bundled + ); } } @@ -83,12 +100,28 @@ async fn static_catalog_records_its_supplied_origin() { }, origin, ); + assert_eq!( + manager + .get_model_info( + "namespace/reviewer-variant", + &ModelsManagerConfig::default() + ) + .await, + CatalogModel { + model: ModelInfo { + slug: "namespace/reviewer-variant".to_string(), + ..model.clone() + } + .into(), + origin + } + ); assert_eq!( manager .model_catalog_with_origins(RefreshStrategy::Offline, DEFAULT_HTTP_CLIENT_FACTORY) .await, vec![CatalogModel { - model: model.clone(), + model: model.clone().into(), origin }] ); diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index 94e8a33ac5..4cfef20232 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -177,7 +177,7 @@ pub trait ModelsManager: fmt::Debug + Send + Sync { .model_catalog_with_origins(refresh_strategy, http_client_factory) .await .into_iter() - .map(|entry| entry.model) + .map(|entry| Arc::unwrap_or_clone(entry.model)) .collect(), } }) @@ -272,20 +272,12 @@ pub trait ModelsManager: fmt::Debug + Send + Sync { } // todo(aibrahim): look if we can tighten it to pub(crate) - /// Look up model metadata, applying remote overrides and config adjustments. + /// Resolve model metadata and its source, preserving provenance through config adjustments. fn get_model_info<'a>( &'a self, model: &'a str, config: &'a ModelsManagerConfig, - ) -> ModelsManagerFuture<'a, ModelInfo> { - Box::pin( - async move { - let remote_models = self.get_remote_models().await; - construct_model_info_from_candidates(model, &remote_models, config) - } - .instrument(tracing::info_span!("get_model_info", model = model)), - ) - } + ) -> ModelsManagerFuture<'a, CatalogModel>; /// Refresh models if the provided ETag differs from the cached ETag. /// @@ -535,18 +527,26 @@ impl ModelsManager for OpenAiModelsManager { &'a self, model: &'a str, config: &'a ModelsManagerConfig, - ) -> ModelsManagerFuture<'a, ModelInfo> { + ) -> ModelsManagerFuture<'a, CatalogModel> { Box::pin(async move { let remote = { let entry = self.remote_models.read().await; - let models = self.models_for_entry(&entry.entry).into_models(); - match &self.catalog_source { - CatalogSource::Default => find_model_from_candidates(model, &models), + let selected = self.models_for_entry(&entry.entry); + let models = match &selected { + SelectedCatalog::CurrentSnapshot(models) + | SelectedCatalog::Fallback(models) => models, + }; + let model = match &self.catalog_source { + CatalogSource::Default => find_model_from_candidates(model, models), CatalogSource::ExplicitProvider(_) => models .iter() .find(|candidate| candidate.slug == model) .cloned(), - } + }; + model.map(|model| CatalogModel { + origin: entry.model_origin(&selected, &model.slug), + model: model.into(), + }) }; construct_model_info(model, remote, config) }) @@ -866,7 +866,7 @@ impl ModelsManager for StaticModelsManager { .iter() .cloned() .map(|model| CatalogModel { - model, + model: model.into(), origin: self.origin, }) .collect() @@ -877,10 +877,10 @@ impl ModelsManager for StaticModelsManager { &'a self, model: &'a str, config: &'a ModelsManagerConfig, - ) -> ModelsManagerFuture<'a, ModelInfo> { - Box::pin( - async move { construct_model_info_from_candidates(model, &self.remote_models, config) }, - ) + ) -> ModelsManagerFuture<'a, CatalogModel> { + Box::pin(async move { + construct_model_info_from_candidates(model, &self.remote_models, self.origin, config) + }) } fn get_default_model<'a>( @@ -1016,27 +1016,29 @@ fn find_model_from_candidates(model: &str, candidates: &[ModelInfo]) -> Option ModelInfo { - let remote = find_model_from_candidates(model, candidates); +) -> CatalogModel { + let remote = find_model_from_candidates(model, candidates).map(|model| CatalogModel { + model: model.into(), + origin, + }); construct_model_info(model, remote, config) } fn construct_model_info( model: &str, - remote: Option, + remote: Option, config: &ModelsManagerConfig, -) -> ModelInfo { - let model_info = if let Some(remote) = remote { - ModelInfo { - slug: model.to_string(), - used_fallback_model_metadata: false, - ..remote - } - } else { - model_info::model_info_from_slug(model) - }; - model_info::with_config_overrides(model_info, config) +) -> CatalogModel { + let mut resolved = remote.unwrap_or_else(|| CatalogModel { + model: model_info::model_info_from_slug(model).into(), + origin: ModelOrigin::Synthetic, + }); + let mut metadata = Arc::unwrap_or_clone(resolved.model); + metadata.slug = model.to_owned(); + resolved.model = model_info::with_config_overrides(metadata, config).into(); + resolved } #[cfg(test)] diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index 468aee2176..8c642771f1 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -18,6 +18,7 @@ use codex_login::TokenData; use codex_protocol::auth::AuthMode; use codex_protocol::error::CodexErr; use codex_protocol::openai_models::ModelAccessPrograms; +use codex_protocol::openai_models::ModelOrigin; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::turn_input::CyberAccessProgram; use pretty_assertions::assert_eq; @@ -572,7 +573,7 @@ async fn injected_cache_write_error_does_not_fail_remote_refresh() { remote_models .into_iter() .map(|model| CatalogModel { - model, + model: model.into(), origin: ModelOrigin::Provider, }) .collect::>() @@ -768,9 +769,12 @@ async fn dynamic_manager_preserves_requested_model_when_fallback_is_allowed() { } #[tokio::test] -async fn get_model_info_tracks_fallback_usage() { +async fn get_model_info_tracks_metadata_origin() { let codex_home = tempdir().expect("temp dir"); - let config = ModelsManagerConfig::default(); + let config = ModelsManagerConfig { + base_instructions: Some("Custom instructions".to_string()), + ..Default::default() + }; let manager = openai_manager_for_tests( codex_home.path().to_path_buf(), TestModelsEndpoint::new(Vec::new()), @@ -784,14 +788,21 @@ async fn get_model_info_tracks_fallback_usage() { .clone(); let known = manager.get_model_info(known_slug.as_str(), &config).await; - assert!(!known.used_fallback_model_metadata); + assert_eq!(known.origin, ModelOrigin::Bundled); assert_eq!(known.slug, known_slug); let unknown = manager .get_model_info("model-that-does-not-exist", &config) .await; - assert!(unknown.used_fallback_model_metadata); + assert_eq!(unknown.origin, ModelOrigin::Synthetic); assert_eq!(unknown.slug, "model-that-does-not-exist"); + assert_eq!( + unknown + .model_messages + .as_ref() + .and_then(|messages| messages.instructions_template.as_deref()), + config.base_instructions.as_deref() + ); } #[tokio::test] @@ -835,16 +846,14 @@ async fn get_model_info_uses_custom_catalog() { assert_eq!(model_info.display_name, "Overlay"); assert_eq!(model_info.context_window, Some(272_000)); assert!(model_info.supports_image_detail_original); - assert!(!model_info.used_fallback_model_metadata); + assert_eq!(model_info.origin, ModelOrigin::ExplicitConfig); let config = ModelsManagerConfig { model_context_window: Some(128_000), ..Default::default() }; - let expected = ModelInfo { - context_window: Some(128_000), - ..model_info - }; + let mut expected = model_info; + expected.context_window = Some(128_000); assert_eq!( manager .get_model_info("gpt-overlay-experiment", &config) @@ -867,7 +876,7 @@ async fn get_model_info_matches_namespaced_suffix() { assert_eq!(model_info.slug, namespaced_model); assert!(model_info.supports_image_detail_original); - assert!(!model_info.used_fallback_model_metadata); + assert_eq!(model_info.origin, ModelOrigin::ExplicitConfig); } #[tokio::test] @@ -882,7 +891,7 @@ async fn get_model_info_matches_hyphenated_provider_namespace_suffix() { let model_info = manager.get_model_info(&namespaced_model, &config).await; assert_eq!(model_info.slug, namespaced_model); - assert!(!model_info.used_fallback_model_metadata); + assert_eq!(model_info.origin, ModelOrigin::ExplicitConfig); } #[tokio::test] @@ -905,7 +914,7 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { let model_info = manager.get_model_info(&namespaced_model, &config).await; assert_eq!(model_info.slug, namespaced_model); - assert!(model_info.used_fallback_model_metadata); + assert_eq!(model_info.origin, ModelOrigin::Synthetic); } #[tokio::test] @@ -1037,7 +1046,7 @@ async fn get_model_info_uses_fallback_for_bundled_models_when_chatgpt_remote_is_ .await; assert_eq!(model_info.slug, bundled_slug); - assert!(model_info.used_fallback_model_metadata); + assert_eq!(model_info.origin, ModelOrigin::Synthetic); } #[tokio::test] @@ -1137,7 +1146,10 @@ async fn authoritative_catalog_preserves_hidden_models_and_matches_exact_ids() { manager .get_model_info(unknown, &ModelsManagerConfig::default()) .await, - model_info::model_info_from_slug(unknown) + CatalogModel { + model: model_info::model_info_from_slug(unknown).into(), + origin: ModelOrigin::Synthetic + } ); } } diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 20847e1ebb..f7e6f5b943 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -132,7 +132,6 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 658a0d41bc..bec7bf88fd 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -244,7 +244,6 @@ fn unknown_model_uses_builtin_instruction_template() { let model = model_info_from_slug("unknown-model"); assert_eq!(render_model_instructions(&model), BASE_INSTRUCTIONS); - assert!(model.used_fallback_model_metadata); } #[test] diff --git a/codex-rs/models-manager/src/test_support.rs b/codex-rs/models-manager/src/test_support.rs index 7397231689..4acc551174 100644 --- a/codex-rs/models-manager/src/test_support.rs +++ b/codex-rs/models-manager/src/test_support.rs @@ -2,6 +2,8 @@ //! //! Production code should not depend on this module. +use crate::CatalogModel; +use crate::ModelOrigin; use crate::ModelsManagerConfig; use crate::bundled_models_response; use crate::manager::construct_model_info_from_candidates; @@ -24,15 +26,15 @@ pub fn get_model_offline_for_tests(model: Option<&str>) -> String { .unwrap_or_default() } -/// Build `ModelInfo` without consulting remote state or cache. +/// Resolve metadata and its source without consulting remote state or cache. pub fn construct_model_info_offline_for_tests( model: &str, config: &ModelsManagerConfig, -) -> ModelInfo { +) -> CatalogModel { let candidates: &[ModelInfo] = if let Some(model_catalog) = config.model_catalog.as_ref() { &model_catalog.models } else { &[] }; - construct_model_info_from_candidates(model, candidates, config) + construct_model_info_from_candidates(model, candidates, ModelOrigin::ExplicitConfig, config) } diff --git a/codex-rs/prompts/src/model_instructions_tests.rs b/codex-rs/prompts/src/model_instructions_tests.rs index df70ae8012..2360c5516b 100644 --- a/codex-rs/prompts/src/model_instructions_tests.rs +++ b/codex-rs/prompts/src/model_instructions_tests.rs @@ -43,7 +43,6 @@ fn test_model(model_messages: Option) -> ModelInfo { effective_context_window_percent: 95, experimental_supported_tools: vec![], input_modalities: vec![], - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 704359ff4c..4e36332bdc 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -5,6 +5,7 @@ use std::fmt; use std::str::FromStr; +use std::sync::Arc; use chrono::DateTime; use chrono::Utc; @@ -478,11 +479,6 @@ pub struct ModelInfo { /// Input modalities accepted by the backend for this model. #[serde(default = "default_input_modalities")] pub input_modalities: Vec, - /// Internal-only marker set by core when a model slug resolved to fallback metadata. - #[serde(default, skip_serializing, skip_deserializing)] - #[schemars(skip)] - #[ts(skip)] - pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, /// Whether experimental context management may be activated at session startup. @@ -519,6 +515,39 @@ pub struct ModelInfo { pub multi_agent_reasoning_effort: Option, } +/// Where model metadata was obtained during local catalog resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelOrigin { + Bundled, + Provider, + ExplicitConfig, + /// Generic metadata synthesized when no catalog entry matches the requested model. + Synthetic, +} + +/// Model metadata and its locally determined source, retained together through resolution. +/// Metadata is shared with runtime snapshots and extension stores; mutation uses copy-on-write. +/// This runtime wrapper is not serialized; managers assign its source from local catalog state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogModel { + pub model: Arc, + pub origin: ModelOrigin, +} + +impl std::ops::Deref for CatalogModel { + type Target = ModelInfo; + + fn deref(&self) -> &Self::Target { + &self.model + } +} + +impl std::ops::DerefMut for CatalogModel { + fn deref_mut(&mut self) -> &mut Self::Target { + Arc::make_mut(&mut self.model) + } +} + impl ModelInfo { pub fn resolved_context_window(&self) -> Option { self.context_window.or(self.max_context_window) @@ -1173,7 +1202,6 @@ mod tests { effective_context_window_percent: 95, experimental_supported_tools: vec![], input_modalities: default_input_modalities(), - used_fallback_model_metadata: false, supports_search_tool: false, supports_experimental_context: false, use_responses_lite: false,