Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 44 additions & 16 deletions code-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion code-rs/core/src/codex/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
43 changes: 32 additions & 11 deletions code-rs/core/src/codex/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2183,7 +2198,10 @@ async fn maybe_run_auto_context_compaction(
let mut raw_decision: Option<String> = 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),
Expand Down Expand Up @@ -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
Expand Down
77 changes: 76 additions & 1 deletion code-rs/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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()?;
Expand Down
5 changes: 3 additions & 2 deletions code-rs/core/src/memories/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion code-rs/core/src/model_family.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 11 additions & 3 deletions code-rs/core/src/remote_models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteModelsState>,
Expand Down Expand Up @@ -174,9 +178,13 @@ impl RemoteModelsManager {
identity: Option<String>,
stale_etag: Option<String>,
) {
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;
}

Expand Down