client side modelinfo overrides (#12101)

TL;DR
Add top-level `model_catalog_json` config support so users can supply a
local model catalog override from a JSON file path (including adding new
models) without backend changes.

### Problem
Codex previously had no clean client-side way to replace/overlay model
catalog data for local testing of model metadata and new model entries.

### Fix
- Add top-level `model_catalog_json` config field (JSON file path).
- Apply catalog entries when resolving `ModelInfo`:
  1. Base resolved model metadata (remote/fallback)
  2. Catalog overlay from `model_catalog_json`
3. Existing global top-level overrides (`model_context_window`,
`model_supports_reasoning_summaries`, etc.)

### Note
Will revisit per-field overrides in a follow-up

### Tests
Added tests
This commit is contained in:
sayan-oai
2026-02-19 10:38:57 -08:00
committed by GitHub
parent 3a951f8096
commit d54999d006
13 changed files with 178 additions and 30 deletions

View File

@@ -47,6 +47,7 @@ pub enum RefreshStrategy {
pub struct ModelsManager {
local_models: Vec<ModelPreset>,
remote_models: RwLock<Vec<ModelInfo>>,
has_custom_model_catalog: bool,
auth_manager: Arc<AuthManager>,
etag: RwLock<Option<String>>,
cache_manager: ModelsCacheManager,
@@ -57,12 +58,23 @@ impl ModelsManager {
/// Construct a manager scoped to the provided `AuthManager`.
///
/// Uses `codex_home` to store cached model metadata and initializes with built-in presets.
pub fn new(codex_home: PathBuf, auth_manager: Arc<AuthManager>) -> Self {
/// When `model_catalog` is provided, it becomes the authoritative remote model list and
/// background refreshes from `/models` are disabled.
pub fn new(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
model_catalog: Option<ModelsResponse>,
) -> Self {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
let has_custom_model_catalog = model_catalog.is_some();
let remote_models = model_catalog
.map(|catalog| catalog.models)
.unwrap_or_else(|| Self::load_remote_models_from_file().unwrap_or_default());
Self {
local_models: builtin_model_presets(auth_manager.auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
remote_models: RwLock::new(remote_models),
has_custom_model_catalog,
auth_manager,
etag: RwLock::new(None),
cache_manager,
@@ -125,7 +137,34 @@ impl ModelsManager {
// todo(aibrahim): look if we can tighten it to pub(crate)
/// Look up model metadata, applying remote overrides and config adjustments.
pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo {
let remote = self.find_remote_model_by_longest_prefix(model).await;
let remote_models = self.get_remote_models().await;
Self::construct_model_info_from_candidates(model, &remote_models, config)
}
fn find_model_by_longest_prefix(model: &str, candidates: &[ModelInfo]) -> Option<ModelInfo> {
let mut best: Option<ModelInfo> = None;
for candidate in candidates {
if !model.starts_with(&candidate.slug) {
continue;
}
let is_better_match = if let Some(current) = best.as_ref() {
candidate.slug.len() > current.slug.len()
} else {
true
};
if is_better_match {
best = Some(candidate.clone());
}
}
best
}
fn construct_model_info_from_candidates(
model: &str,
candidates: &[ModelInfo],
config: &Config,
) -> ModelInfo {
let remote = Self::find_model_by_longest_prefix(model, candidates);
let model_info = if let Some(remote) = remote {
ModelInfo {
slug: model.to_string(),
@@ -138,24 +177,6 @@ impl ModelsManager {
model_info::with_config_overrides(model_info, config)
}
async fn find_remote_model_by_longest_prefix(&self, model: &str) -> Option<ModelInfo> {
let mut best: Option<ModelInfo> = None;
for candidate in self.get_remote_models().await {
if !model.starts_with(&candidate.slug) {
continue;
}
let is_better_match = if let Some(current) = best.as_ref() {
candidate.slug.len() > current.slug.len()
} else {
true
};
if is_better_match {
best = Some(candidate);
}
}
best
}
/// Refresh models if the provided ETag differs from the cached ETag.
///
/// Uses `Online` strategy to fetch latest models when ETags differ.
@@ -174,6 +195,11 @@ impl ModelsManager {
/// Refresh available models according to the specified strategy.
async fn refresh_available_models(&self, refresh_strategy: RefreshStrategy) -> CoreResult<()> {
// don't override the custom model catalog if one was provided by the user
if self.has_custom_model_catalog {
return Ok(());
}
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) {
if matches!(
refresh_strategy,
@@ -327,6 +353,7 @@ impl ModelsManager {
Self {
local_models: builtin_model_presets(auth_manager.auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
has_custom_model_catalog: false,
auth_manager,
etag: RwLock::new(None),
cache_manager,
@@ -353,7 +380,12 @@ impl ModelsManager {
model: &str,
config: &Config,
) -> ModelInfo {
model_info::with_config_overrides(model_info::model_info_from_slug(model), config)
let candidates: &[ModelInfo] = if let Some(model_catalog) = config.model_catalog.as_ref() {
&model_catalog.models
} else {
&[]
};
Self::construct_model_info_from_candidates(model, candidates, config)
}
}
@@ -446,7 +478,7 @@ mod tests {
.expect("load default test config");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let manager = ModelsManager::new(codex_home.path().to_path_buf(), auth_manager);
let manager = ModelsManager::new(codex_home.path().to_path_buf(), auth_manager, None);
let known_slug = manager
.get_remote_models()
.await
@@ -466,6 +498,36 @@ mod tests {
assert_eq!(unknown.slug, "model-that-does-not-exist");
}
#[tokio::test]
async fn get_model_info_uses_custom_catalog() {
let codex_home = tempdir().expect("temp dir");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("load default test config");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let manager = ModelsManager::new(
codex_home.path().to_path_buf(),
auth_manager,
Some(ModelsResponse {
models: vec![remote_model("gpt-overlay", "Overlay", 0)],
}),
);
let model_info = manager
.get_model_info("gpt-overlay-experiment", &config)
.await;
assert_eq!(model_info.slug, "gpt-overlay-experiment");
assert_eq!(model_info.display_name, "Overlay");
assert_eq!(model_info.context_window, Some(272_000));
assert!(!model_info.supports_parallel_tool_calls);
assert!(!model_info.used_fallback_model_metadata);
}
#[tokio::test]
async fn refresh_available_models_sorts_by_priority() {
let server = MockServer::start().await;