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,