diff --git a/code-rs/core/src/client.rs b/code-rs/core/src/client.rs index be241a4423c9..adbb781a79f2 100644 --- a/code-rs/core/src/client.rs +++ b/code-rs/core/src/client.rs @@ -68,7 +68,7 @@ use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use crate::error::UsageLimitReachedError; use crate::flags::CODEX_RS_SSE_FIXTURE; -use crate::model_family::{find_family_for_model, ModelFamily}; +use crate::model_family::{derive_default_model_family, find_family_for_model, ModelFamily}; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::openai_tools::create_tools_json_for_responses_api; @@ -372,6 +372,46 @@ impl ModelClient { } } + /// Resolve the model family used to build a request. + /// + /// The newer codex-rs path passes resolved `ModelInfo` into the request + /// builder. This older code-rs path still reconstructs families from the + /// slug, so keep that reconstruction provider-aware at the final boundary. + pub(crate) fn normalize_model_family_for_provider( + &self, + mut family: ModelFamily, + ) -> ModelFamily { + if !self.uses_first_party_openai_model_metadata() { + family.use_responses_lite = false; + family.prefer_websockets = false; + } + family + } + + fn uses_first_party_openai_model_metadata(&self) -> bool { + // Provider-owned ModelInfo is the codex-rs source of truth. This + // compatibility check identifies the one built-in first-party path; + // proxy/custom endpoints must not inherit embedded OpenAI capabilities. + self.config.model_provider_id == "openai" + && self.provider.is_public_openai_responses_endpoint() + } + + fn model_family_for_request(&self, prompt: &Prompt, model: &str) -> ModelFamily { + let family = prompt + .model_family_override + .clone() + .unwrap_or_else(|| { + if self.uses_first_party_openai_model_metadata() { + find_family_for_model(model) + .unwrap_or_else(|| self.config.model_family.clone()) + } else { + derive_default_model_family(model) + } + }); + + self.normalize_model_family_for_provider(family) + } + /// Get the reasoning effort configuration pub fn get_reasoning_effort(&self) -> ReasoningEffortConfig { self.effort @@ -778,11 +818,7 @@ impl ModelClient { .as_deref() .unwrap_or(self.config.model.as_str()); let effective_effort = clamp_reasoning_effort_for_model(request_model, self.effort); - let request_family = prompt - .model_family_override - .clone() - .or_else(|| find_family_for_model(request_model)) - .unwrap_or_else(|| self.config.model_family.clone()); + let request_family = self.model_family_for_request(prompt, request_model); let store = should_store_responses(prompt, &self.provider, &request_family); let full_instructions = prompt.get_full_instructions(&request_family); @@ -1265,11 +1301,7 @@ impl ModelClient { .as_deref() .unwrap_or(self.config.model.as_str()); let effective_effort = clamp_reasoning_effort_for_model(request_model, self.effort); - let request_family = prompt - .model_family_override - .clone() - .or_else(|| find_family_for_model(request_model)) - .unwrap_or_else(|| self.config.model_family.clone()); + let request_family = self.model_family_for_request(prompt, request_model); let store = should_store_responses(prompt, &self.provider, &request_family); let full_instructions = prompt.get_full_instructions(&request_family); @@ -2059,11 +2091,7 @@ impl ModelClient { .model_override .as_deref() .unwrap_or(self.config.model.as_str()); - let family = prompt - .model_family_override - .clone() - .or_else(|| find_family_for_model(model_slug)) - .unwrap_or_else(|| self.config.model_family.clone()); + let family = self.model_family_for_request(prompt, model_slug); let session_id = prompt.session_id_override.unwrap_or(self.session_id); let session_id_str = session_id.to_string(); let instructions = prompt.get_full_instructions(&family).into_owned(); diff --git a/code-rs/core/src/codex/session.rs b/code-rs/core/src/codex/session.rs index b578bd3b2802..36568aedf2ca 100644 --- a/code-rs/core/src/codex/session.rs +++ b/code-rs/core/src/codex/session.rs @@ -668,6 +668,9 @@ impl Session { .model_override .as_deref() .unwrap_or(configured_model.as_str()); + // The legacy path may start from embedded OpenAI metadata and then + // apply a remote override. Normalize after both steps so custom + // providers match codex-rs fallback ModelInfo behavior. let base_family = if let Some(family) = find_family_for_model(model_slug) { family } else { @@ -690,7 +693,9 @@ impl Session { } else { base_family }; - prompt.model_family_override = Some(family); + prompt.model_family_override = Some( + self.client.normalize_model_family_for_provider(family), + ); } used_fallback_model_metadata } diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index 22e03ae4fd71..585a302aec60 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -764,13 +764,26 @@ pub(super) async fn submission_loop( tools_config.set_agent_models(agent_models); let model_descriptions = model_guide_markdown_with_custom(&config.agents); - let remote_models_manager = auth_manager.as_ref().map(|mgr| { - Arc::new(RemoteModelsManager::new( - Arc::clone(mgr), - provider.clone(), - config.code_home.clone(), - )) - }); + // codex-rs gives each provider ownership of its model manager. + // code-rs still has one legacy RemoteModelsManager, so only + // wire it for the equivalent first-party/command-auth cases; + // ordinary OpenAI-compatible providers must not be treated as + // if their /models endpoint returned Codex ModelInfo records. + let should_create_remote_models_manager = + (config.model_provider_id == "openai" + && provider.is_public_openai_responses_endpoint()) + || provider.has_command_auth(); + let remote_models_manager = should_create_remote_models_manager + .then(|| { + auth_manager.as_ref().map(|mgr| { + Arc::new(RemoteModelsManager::new( + Arc::clone(mgr), + provider.clone(), + config.code_home.clone(), + )) + }) + }) + .flatten(); if let Some(remote) = remote_models_manager.as_ref() { let remote = Arc::clone(remote); tokio::spawn(async move { @@ -1481,8 +1494,10 @@ async fn spawn_review_thread( // Determine model + family for review mode. let review_model = config.review_model.clone(); - let review_family = find_family_for_model(&review_model) - .unwrap_or_else(|| derive_default_model_family(&review_model)); + let review_family = sess.client().normalize_model_family_for_provider( + find_family_for_model(&review_model) + .unwrap_or_else(|| derive_default_model_family(&review_model)), + ); // Prepare a per-review configuration that favors deterministic feedback. let mut review_config = (*config).clone(); @@ -2183,7 +2198,10 @@ async fn maybe_run_auto_context_compaction( let mut raw_decision: Option = None; for model in auto_context_judge_models() { prompt.model_override = Some(model.to_string()); - prompt.model_family_override = Some(derive_default_model_family(model)); + prompt.model_family_override = Some( + sess.client() + .normalize_model_family_for_provider(derive_default_model_family(model)), + ); match tokio::time::timeout( std::time::Duration::from_secs(12), @@ -3145,7 +3163,10 @@ async fn run_turn( .unwrap_or_else(|| derive_default_model_family(&override_model)) }; prompt.model_override = Some(override_model); - prompt.model_family_override = Some(override_family); + prompt.model_family_override = Some( + tc.client + .normalize_model_family_for_provider(override_family), + ); } if used_fallback_model_metadata diff --git a/code-rs/core/src/config.rs b/code-rs/core/src/config.rs index 7614d0669acb..d25c648c0b3f 100644 --- a/code-rs/core/src/config.rs +++ b/code-rs/core/src/config.rs @@ -1353,8 +1353,17 @@ impl Config { .or(cfg.context_mode) .or(Some(ContextMode::Auto)); - let model_family = + let mut model_family = find_family_for_model(&model).unwrap_or_else(|| derive_default_model_family(&model)); + if model_provider_id != "openai" + || !model_provider.is_public_openai_responses_endpoint() + { + // codex-rs resolves custom providers through their provider-owned + // ModelInfo manager; unknown custom-provider models use fallback + // metadata. Legacy code-rs starts from its embedded OpenAI catalog, + // so remove OpenAI-only Lite semantics at this compatibility seam. + model_family.use_responses_lite = false; + } let default_tool_output_max_bytes = model_family.tool_output_max_bytes(); // Chat model reasoning effort (used when other flows follow the chat model). @@ -2718,6 +2727,72 @@ model_verbosity = "high" Ok(()) } + #[test] + fn model_metadata_uses_provider_appropriate_catalog_boundary() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let mut custom_cfg = fixture.cfg.clone(); + custom_cfg + .profiles + .get_mut("gpt3") + .expect("gpt3 profile should exist") + .model = Some("gpt-5.6-luna".to_string()); + let custom_config = Config::load_from_base_config_with_overrides( + custom_cfg, + ConfigOverrides { + config_profile: Some("gpt3".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }, + fixture.code_home(), + )?; + + assert!(!custom_config.model_family.use_responses_lite); + assert_eq!( + Some(crate::model_family::EXTENDED_CONTEXT_WINDOW_1M), + custom_config.model_context_window + ); + + let mut openai_cfg = fixture.cfg.clone(); + openai_cfg + .profiles + .get_mut("o3") + .expect("o3 profile should exist") + .model = Some("gpt-5.6-luna".to_string()); + let openai_config = Config::load_from_base_config_with_overrides( + openai_cfg, + ConfigOverrides { + config_profile: Some("o3".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }, + fixture.code_home(), + )?; + + assert!(openai_config.model_family.use_responses_lite); + + let mut proxied_cfg = fixture.cfg.clone(); + proxied_cfg.openai_base_url = Some("https://proxy.example.test/v1".to_string()); + proxied_cfg + .profiles + .get_mut("o3") + .expect("o3 profile should exist") + .model = Some("gpt-5.6-luna".to_string()); + let proxied_config = Config::load_from_base_config_with_overrides( + proxied_cfg, + ConfigOverrides { + config_profile: Some("o3".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }, + fixture.code_home(), + )?; + + assert!(!proxied_config.model_family.use_responses_lite); + + Ok(()) + } + #[test] fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { let fixture = create_test_fixture()?; diff --git a/code-rs/core/src/memories/mod.rs b/code-rs/core/src/memories/mod.rs index 043d106dfc2e..7914de0e05f0 100644 --- a/code-rs/core/src/memories/mod.rs +++ b/code-rs/core/src/memories/mod.rs @@ -557,11 +557,12 @@ async fn collect_json_response(session: &Session, prompt: &Prompt) -> anyhow::Re async fn resolve_model_family(session: &Session, model: &str) -> crate::model_family::ModelFamily { let base = find_family_for_model(model).unwrap_or_else(|| derive_default_model_family(model)); - if let Some(remote) = session.remote_models_manager() { + let family = if let Some(remote) = session.remote_models_manager() { remote.apply_remote_overrides(model, base).await } else { base - } + }; + session.client().normalize_model_family_for_provider(family) } fn build_stage1_user_prompt( diff --git a/code-rs/core/src/model_family.rs b/code-rs/core/src/model_family.rs index 9802ab003894..44a77b992c5c 100644 --- a/code-rs/core/src/model_family.rs +++ b/code-rs/core/src/model_family.rs @@ -552,6 +552,7 @@ mod tests { use super::find_family_for_model; use super::parse_upstream_models; + use super::supports_extended_context; #[test] fn image_generation_support_tracks_image_input_modality() { @@ -591,6 +592,16 @@ mod tests { assert!(!models.is_empty()); } + #[test] + fn extended_context_support_includes_supported_gpt_5_6_variants() { + for model in ["gpt-5.4", "gpt-5.6-luna", "gpt-5.6-sol"] { + assert!(supports_extended_context(model), "{model} should support 1M context"); + } + + assert!(!supports_extended_context("gpt-5.6-terra")); + assert!(!supports_extended_context("gpt-5.5")); + } + #[test] fn qwen3_coder_30b_quantized_slug_has_explicit_local_metadata() { let family = find_family_for_model("Qwen3-Coder-30B-A3B-Instruct-Q4_K_M") @@ -636,7 +647,9 @@ pub const fn default_auto_compact_limit_for_context_window(context_window: u64) } pub fn supports_extended_context(model: &str) -> bool { - model.eq_ignore_ascii_case("gpt-5.4") + ["gpt-5.4", "gpt-5.6-luna", "gpt-5.6-sol"] + .iter() + .any(|supported| model.eq_ignore_ascii_case(supported)) } pub fn resolve_context_mode_limits( diff --git a/code-rs/core/src/remote_models/mod.rs b/code-rs/core/src/remote_models/mod.rs index 50ed3207ef93..c012998ae46c 100644 --- a/code-rs/core/src/remote_models/mod.rs +++ b/code-rs/core/src/remote_models/mod.rs @@ -47,6 +47,10 @@ struct RemoteModelsState { /// /// Any error (disk, auth, network, parse) results in an empty remote model list /// so callers can safely fall back to built-in behaviour. +/// +/// This is a compatibility layer for the older code-rs architecture. In +/// codex-rs, the selected provider owns the model manager and decides whether +/// `/models` is authoritative; the refresh gate below preserves that policy. #[derive(Debug)] pub struct RemoteModelsManager { state: RwLock, @@ -174,9 +178,13 @@ impl RemoteModelsManager { identity: Option, stale_etag: Option, ) { - let auth_mode = auth.as_ref().map(|a| a.mode); - if !auth_mode.is_some_and(AuthMode::is_chatgpt) { - // Only the ChatGPT backend exposes the Codex `/models` schema. + let should_refresh = auth + .as_ref() + .is_some_and(CodexAuth::uses_codex_backend) + || self.provider.has_command_auth(); + if !should_refresh { + // Match codex-rs: refresh provider-owned model catalogs only for + // Codex-backend auth or command-backed provider auth. return; }