Skip to content

Commit 90ab67e

Browse files
authored
Merge pull request #621 from voarsh2/pr/model-proxy-20260924
Normalize model metadata for OpenAI-compatible providers
2 parents ba4dc76 + b77317a commit 90ab67e

7 files changed

Lines changed: 186 additions & 35 deletions

File tree

‎code-rs/core/src/client.rs‎

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ use crate::error::RetryLimitReachedError;
6868
use crate::error::UnexpectedResponseError;
6969
use crate::error::UsageLimitReachedError;
7070
use crate::flags::CODEX_RS_SSE_FIXTURE;
71-
use crate::model_family::{find_family_for_model, ModelFamily};
71+
use crate::model_family::{derive_default_model_family, find_family_for_model, ModelFamily};
7272
use crate::model_provider_info::ModelProviderInfo;
7373
use crate::model_provider_info::WireApi;
7474
use crate::openai_tools::create_tools_json_for_responses_api;
@@ -372,6 +372,46 @@ impl ModelClient {
372372
}
373373
}
374374

375+
/// Resolve the model family used to build a request.
376+
///
377+
/// The newer codex-rs path passes resolved `ModelInfo` into the request
378+
/// builder. This older code-rs path still reconstructs families from the
379+
/// slug, so keep that reconstruction provider-aware at the final boundary.
380+
pub(crate) fn normalize_model_family_for_provider(
381+
&self,
382+
mut family: ModelFamily,
383+
) -> ModelFamily {
384+
if !self.uses_first_party_openai_model_metadata() {
385+
family.use_responses_lite = false;
386+
family.prefer_websockets = false;
387+
}
388+
family
389+
}
390+
391+
fn uses_first_party_openai_model_metadata(&self) -> bool {
392+
// Provider-owned ModelInfo is the codex-rs source of truth. This
393+
// compatibility check identifies the one built-in first-party path;
394+
// proxy/custom endpoints must not inherit embedded OpenAI capabilities.
395+
self.config.model_provider_id == "openai"
396+
&& self.provider.is_public_openai_responses_endpoint()
397+
}
398+
399+
fn model_family_for_request(&self, prompt: &Prompt, model: &str) -> ModelFamily {
400+
let family = prompt
401+
.model_family_override
402+
.clone()
403+
.unwrap_or_else(|| {
404+
if self.uses_first_party_openai_model_metadata() {
405+
find_family_for_model(model)
406+
.unwrap_or_else(|| self.config.model_family.clone())
407+
} else {
408+
derive_default_model_family(model)
409+
}
410+
});
411+
412+
self.normalize_model_family_for_provider(family)
413+
}
414+
375415
/// Get the reasoning effort configuration
376416
pub fn get_reasoning_effort(&self) -> ReasoningEffortConfig {
377417
self.effort
@@ -778,11 +818,7 @@ impl ModelClient {
778818
.as_deref()
779819
.unwrap_or(self.config.model.as_str());
780820
let effective_effort = clamp_reasoning_effort_for_model(request_model, self.effort);
781-
let request_family = prompt
782-
.model_family_override
783-
.clone()
784-
.or_else(|| find_family_for_model(request_model))
785-
.unwrap_or_else(|| self.config.model_family.clone());
821+
let request_family = self.model_family_for_request(prompt, request_model);
786822
let store = should_store_responses(prompt, &self.provider, &request_family);
787823

788824
let full_instructions = prompt.get_full_instructions(&request_family);
@@ -1265,11 +1301,7 @@ impl ModelClient {
12651301
.as_deref()
12661302
.unwrap_or(self.config.model.as_str());
12671303
let effective_effort = clamp_reasoning_effort_for_model(request_model, self.effort);
1268-
let request_family = prompt
1269-
.model_family_override
1270-
.clone()
1271-
.or_else(|| find_family_for_model(request_model))
1272-
.unwrap_or_else(|| self.config.model_family.clone());
1304+
let request_family = self.model_family_for_request(prompt, request_model);
12731305
let store = should_store_responses(prompt, &self.provider, &request_family);
12741306

12751307
let full_instructions = prompt.get_full_instructions(&request_family);
@@ -2059,11 +2091,7 @@ impl ModelClient {
20592091
.model_override
20602092
.as_deref()
20612093
.unwrap_or(self.config.model.as_str());
2062-
let family = prompt
2063-
.model_family_override
2064-
.clone()
2065-
.or_else(|| find_family_for_model(model_slug))
2066-
.unwrap_or_else(|| self.config.model_family.clone());
2094+
let family = self.model_family_for_request(prompt, model_slug);
20672095
let session_id = prompt.session_id_override.unwrap_or(self.session_id);
20682096
let session_id_str = session_id.to_string();
20692097
let instructions = prompt.get_full_instructions(&family).into_owned();

‎code-rs/core/src/codex/session.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,9 @@ impl Session {
668668
.model_override
669669
.as_deref()
670670
.unwrap_or(configured_model.as_str());
671+
// The legacy path may start from embedded OpenAI metadata and then
672+
// apply a remote override. Normalize after both steps so custom
673+
// providers match codex-rs fallback ModelInfo behavior.
671674
let base_family = if let Some(family) = find_family_for_model(model_slug) {
672675
family
673676
} else {
@@ -690,7 +693,9 @@ impl Session {
690693
} else {
691694
base_family
692695
};
693-
prompt.model_family_override = Some(family);
696+
prompt.model_family_override = Some(
697+
self.client.normalize_model_family_for_provider(family),
698+
);
694699
}
695700
used_fallback_model_metadata
696701
}

‎code-rs/core/src/codex/streaming.rs‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -764,13 +764,26 @@ pub(super) async fn submission_loop(
764764
tools_config.set_agent_models(agent_models);
765765

766766
let model_descriptions = model_guide_markdown_with_custom(&config.agents);
767-
let remote_models_manager = auth_manager.as_ref().map(|mgr| {
768-
Arc::new(RemoteModelsManager::new(
769-
Arc::clone(mgr),
770-
provider.clone(),
771-
config.code_home.clone(),
772-
))
773-
});
767+
// codex-rs gives each provider ownership of its model manager.
768+
// code-rs still has one legacy RemoteModelsManager, so only
769+
// wire it for the equivalent first-party/command-auth cases;
770+
// ordinary OpenAI-compatible providers must not be treated as
771+
// if their /models endpoint returned Codex ModelInfo records.
772+
let should_create_remote_models_manager =
773+
(config.model_provider_id == "openai"
774+
&& provider.is_public_openai_responses_endpoint())
775+
|| provider.has_command_auth();
776+
let remote_models_manager = should_create_remote_models_manager
777+
.then(|| {
778+
auth_manager.as_ref().map(|mgr| {
779+
Arc::new(RemoteModelsManager::new(
780+
Arc::clone(mgr),
781+
provider.clone(),
782+
config.code_home.clone(),
783+
))
784+
})
785+
})
786+
.flatten();
774787
if let Some(remote) = remote_models_manager.as_ref() {
775788
let remote = Arc::clone(remote);
776789
tokio::spawn(async move {
@@ -1481,8 +1494,10 @@ async fn spawn_review_thread(
14811494

14821495
// Determine model + family for review mode.
14831496
let review_model = config.review_model.clone();
1484-
let review_family = find_family_for_model(&review_model)
1485-
.unwrap_or_else(|| derive_default_model_family(&review_model));
1497+
let review_family = sess.client().normalize_model_family_for_provider(
1498+
find_family_for_model(&review_model)
1499+
.unwrap_or_else(|| derive_default_model_family(&review_model)),
1500+
);
14861501

14871502
// Prepare a per-review configuration that favors deterministic feedback.
14881503
let mut review_config = (*config).clone();
@@ -2183,7 +2198,10 @@ async fn maybe_run_auto_context_compaction(
21832198
let mut raw_decision: Option<String> = None;
21842199
for model in auto_context_judge_models() {
21852200
prompt.model_override = Some(model.to_string());
2186-
prompt.model_family_override = Some(derive_default_model_family(model));
2201+
prompt.model_family_override = Some(
2202+
sess.client()
2203+
.normalize_model_family_for_provider(derive_default_model_family(model)),
2204+
);
21872205

21882206
match tokio::time::timeout(
21892207
std::time::Duration::from_secs(12),
@@ -3145,7 +3163,10 @@ async fn run_turn(
31453163
.unwrap_or_else(|| derive_default_model_family(&override_model))
31463164
};
31473165
prompt.model_override = Some(override_model);
3148-
prompt.model_family_override = Some(override_family);
3166+
prompt.model_family_override = Some(
3167+
tc.client
3168+
.normalize_model_family_for_provider(override_family),
3169+
);
31493170
}
31503171

31513172
if used_fallback_model_metadata

‎code-rs/core/src/config.rs‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1353,8 +1353,17 @@ impl Config {
13531353
.or(cfg.context_mode)
13541354
.or(Some(ContextMode::Auto));
13551355

1356-
let model_family =
1356+
let mut model_family =
13571357
find_family_for_model(&model).unwrap_or_else(|| derive_default_model_family(&model));
1358+
if model_provider_id != "openai"
1359+
|| !model_provider.is_public_openai_responses_endpoint()
1360+
{
1361+
// codex-rs resolves custom providers through their provider-owned
1362+
// ModelInfo manager; unknown custom-provider models use fallback
1363+
// metadata. Legacy code-rs starts from its embedded OpenAI catalog,
1364+
// so remove OpenAI-only Lite semantics at this compatibility seam.
1365+
model_family.use_responses_lite = false;
1366+
}
13581367
let default_tool_output_max_bytes = model_family.tool_output_max_bytes();
13591368

13601369
// Chat model reasoning effort (used when other flows follow the chat model).
@@ -2718,6 +2727,72 @@ model_verbosity = "high"
27182727
Ok(())
27192728
}
27202729

2730+
#[test]
2731+
fn model_metadata_uses_provider_appropriate_catalog_boundary() -> std::io::Result<()> {
2732+
let fixture = create_test_fixture()?;
2733+
2734+
let mut custom_cfg = fixture.cfg.clone();
2735+
custom_cfg
2736+
.profiles
2737+
.get_mut("gpt3")
2738+
.expect("gpt3 profile should exist")
2739+
.model = Some("gpt-5.6-luna".to_string());
2740+
let custom_config = Config::load_from_base_config_with_overrides(
2741+
custom_cfg,
2742+
ConfigOverrides {
2743+
config_profile: Some("gpt3".to_string()),
2744+
cwd: Some(fixture.cwd()),
2745+
..Default::default()
2746+
},
2747+
fixture.code_home(),
2748+
)?;
2749+
2750+
assert!(!custom_config.model_family.use_responses_lite);
2751+
assert_eq!(
2752+
Some(crate::model_family::EXTENDED_CONTEXT_WINDOW_1M),
2753+
custom_config.model_context_window
2754+
);
2755+
2756+
let mut openai_cfg = fixture.cfg.clone();
2757+
openai_cfg
2758+
.profiles
2759+
.get_mut("o3")
2760+
.expect("o3 profile should exist")
2761+
.model = Some("gpt-5.6-luna".to_string());
2762+
let openai_config = Config::load_from_base_config_with_overrides(
2763+
openai_cfg,
2764+
ConfigOverrides {
2765+
config_profile: Some("o3".to_string()),
2766+
cwd: Some(fixture.cwd()),
2767+
..Default::default()
2768+
},
2769+
fixture.code_home(),
2770+
)?;
2771+
2772+
assert!(openai_config.model_family.use_responses_lite);
2773+
2774+
let mut proxied_cfg = fixture.cfg.clone();
2775+
proxied_cfg.openai_base_url = Some("https://proxy.example.test/v1".to_string());
2776+
proxied_cfg
2777+
.profiles
2778+
.get_mut("o3")
2779+
.expect("o3 profile should exist")
2780+
.model = Some("gpt-5.6-luna".to_string());
2781+
let proxied_config = Config::load_from_base_config_with_overrides(
2782+
proxied_cfg,
2783+
ConfigOverrides {
2784+
config_profile: Some("o3".to_string()),
2785+
cwd: Some(fixture.cwd()),
2786+
..Default::default()
2787+
},
2788+
fixture.code_home(),
2789+
)?;
2790+
2791+
assert!(!proxied_config.model_family.use_responses_lite);
2792+
2793+
Ok(())
2794+
}
2795+
27212796
#[test]
27222797
fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
27232798
let fixture = create_test_fixture()?;

‎code-rs/core/src/memories/mod.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,11 +557,12 @@ async fn collect_json_response(session: &Session, prompt: &Prompt) -> anyhow::Re
557557

558558
async fn resolve_model_family(session: &Session, model: &str) -> crate::model_family::ModelFamily {
559559
let base = find_family_for_model(model).unwrap_or_else(|| derive_default_model_family(model));
560-
if let Some(remote) = session.remote_models_manager() {
560+
let family = if let Some(remote) = session.remote_models_manager() {
561561
remote.apply_remote_overrides(model, base).await
562562
} else {
563563
base
564-
}
564+
};
565+
session.client().normalize_model_family_for_provider(family)
565566
}
566567

567568
fn build_stage1_user_prompt(

‎code-rs/core/src/model_family.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,7 @@ mod tests {
552552

553553
use super::find_family_for_model;
554554
use super::parse_upstream_models;
555+
use super::supports_extended_context;
555556

556557
#[test]
557558
fn image_generation_support_tracks_image_input_modality() {
@@ -591,6 +592,16 @@ mod tests {
591592
assert!(!models.is_empty());
592593
}
593594

595+
#[test]
596+
fn extended_context_support_includes_supported_gpt_5_6_variants() {
597+
for model in ["gpt-5.4", "gpt-5.6-luna", "gpt-5.6-sol"] {
598+
assert!(supports_extended_context(model), "{model} should support 1M context");
599+
}
600+
601+
assert!(!supports_extended_context("gpt-5.6-terra"));
602+
assert!(!supports_extended_context("gpt-5.5"));
603+
}
604+
594605
#[test]
595606
fn qwen3_coder_30b_quantized_slug_has_explicit_local_metadata() {
596607
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)
636647
}
637648

638649
pub fn supports_extended_context(model: &str) -> bool {
639-
model.eq_ignore_ascii_case("gpt-5.4")
650+
["gpt-5.4", "gpt-5.6-luna", "gpt-5.6-sol"]
651+
.iter()
652+
.any(|supported| model.eq_ignore_ascii_case(supported))
640653
}
641654

642655
pub fn resolve_context_mode_limits(

‎code-rs/core/src/remote_models/mod.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ struct RemoteModelsState {
4747
///
4848
/// Any error (disk, auth, network, parse) results in an empty remote model list
4949
/// so callers can safely fall back to built-in behaviour.
50+
///
51+
/// This is a compatibility layer for the older code-rs architecture. In
52+
/// codex-rs, the selected provider owns the model manager and decides whether
53+
/// `/models` is authoritative; the refresh gate below preserves that policy.
5054
#[derive(Debug)]
5155
pub struct RemoteModelsManager {
5256
state: RwLock<RemoteModelsState>,
@@ -174,9 +178,13 @@ impl RemoteModelsManager {
174178
identity: Option<String>,
175179
stale_etag: Option<String>,
176180
) {
177-
let auth_mode = auth.as_ref().map(|a| a.mode);
178-
if !auth_mode.is_some_and(AuthMode::is_chatgpt) {
179-
// Only the ChatGPT backend exposes the Codex `/models` schema.
181+
let should_refresh = auth
182+
.as_ref()
183+
.is_some_and(CodexAuth::uses_codex_backend)
184+
|| self.provider.has_command_auth();
185+
if !should_refresh {
186+
// Match codex-rs: refresh provider-owned model catalogs only for
187+
// Codex-backend auth or command-backed provider auth.
180188
return;
181189
}
182190

0 commit comments

Comments
 (0)