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
Unverified
parent 3a951f8096
commit d54999d006
13 changed files with 178 additions and 30 deletions
@@ -173,6 +173,7 @@ impl MessageProcessor {
config.codex_home.clone(),
auth_manager.clone(),
SessionSource::VSCode,
config.model_catalog.clone(),
));
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
+8
View File
@@ -1594,6 +1594,14 @@
"format": "int64",
"type": "integer"
},
"model_catalog_json": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional path to a JSON file containing a complete model catalog. When set, this replaces the bundled catalog for this process."
},
"model_context_window": {
"description": "Size of the context window for the model, in tokens.",
"format": "int64",
+3
View File
@@ -7231,6 +7231,7 @@ mod tests {
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
None,
));
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info =
@@ -7305,6 +7306,7 @@ mod tests {
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
None,
));
let agent_control = AgentControl::default();
let exec_policy = ExecPolicyManager::default();
@@ -7456,6 +7458,7 @@ mod tests {
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
None,
));
let agent_control = AgentControl::default();
let exec_policy = ExecPolicyManager::default();
+63
View File
@@ -66,6 +66,7 @@ use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -359,6 +360,10 @@ pub struct Config {
/// Optional override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
/// Optional full model catalog loaded from `model_catalog_json`.
/// When set, this replaces the bundled catalog for the current process.
pub model_catalog: Option<ModelsResponse>,
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
pub model_verbosity: Option<Verbosity>,
@@ -618,6 +623,27 @@ pub(crate) fn deserialize_config_toml_with_base(
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result<ModelsResponse> {
let file_contents = std::fs::read_to_string(path)?;
serde_json::from_str::<ModelsResponse>(&file_contents).map_err(|err| {
std::io::Error::new(
ErrorKind::InvalidData,
format!(
"failed to parse model_catalog_json path `{}` as JSON: {err}",
path.display()
),
)
})
}
fn load_model_catalog(
model_catalog_json: Option<AbsolutePathBuf>,
) -> std::io::Result<Option<ModelsResponse>> {
model_catalog_json
.map(|path| load_catalog_json(&path))
.transpose()
}
fn filter_mcp_servers_by_requirements(
mcp_servers: &mut HashMap<String, McpServerConfig>,
mcp_requirements: Option<&Sourced<BTreeMap<String, McpServerRequirement>>>,
@@ -1039,6 +1065,10 @@ pub struct ConfigToml {
/// Override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
/// Optional path to a JSON file containing a complete model catalog.
/// When set, this replaces the bundled catalog for this process.
pub model_catalog_json: Option<AbsolutePathBuf>,
/// Optionally specify a personality for the model
pub personality: Option<Personality>,
@@ -1793,6 +1823,7 @@ impl Config {
let review_model = override_review_model.or(cfg.review_model);
let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true);
let model_catalog = load_model_catalog(cfg.model_catalog_json.clone())?;
let log_dir = cfg
.log_dir
@@ -1929,6 +1960,7 @@ impl Config {
.or(cfg.model_reasoning_summary)
.unwrap_or_default(),
model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries,
model_catalog,
model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity),
chatgpt_base_url: config_profile
.chatgpt_base_url
@@ -4177,6 +4209,33 @@ config_file = "./agents/researcher.toml"
Ok(())
}
#[test]
fn model_catalog_json_loads_from_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let catalog_path = codex_home.path().join("catalog.json");
let mut catalog: ModelsResponse =
serde_json::from_str(include_str!("../../models.json")).expect("valid models.json");
catalog.models = catalog.models.into_iter().take(1).collect();
std::fs::write(
&catalog_path,
serde_json::to_string(&catalog).expect("serialize catalog"),
)?;
let cfg = ConfigToml {
model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(config.model_catalog, Some(catalog));
Ok(())
}
fn create_test_fixture() -> std::io::Result<PrecedenceTestFixture> {
let toml = r#"
model = "o3"
@@ -4349,6 +4408,7 @@ model_verbosity = "high"
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: ReasoningSummary::Detailed,
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
@@ -4464,6 +4524,7 @@ model_verbosity = "high"
model_reasoning_effort: None,
model_reasoning_summary: ReasoningSummary::default(),
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
@@ -4577,6 +4638,7 @@ model_verbosity = "high"
model_reasoning_effort: None,
model_reasoning_summary: ReasoningSummary::default(),
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
@@ -4676,6 +4738,7 @@ model_verbosity = "high"
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: ReasoningSummary::Detailed,
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: Some(Verbosity::High),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
+85 -23
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;
+7 -1
View File
@@ -21,6 +21,7 @@ use crate::skills::SkillsManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
@@ -141,6 +142,7 @@ impl ThreadManager {
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
model_catalog: Option<ModelsResponse>,
) -> Self {
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
let skills_manager = Arc::new(SkillsManager::new(codex_home.clone()));
@@ -149,7 +151,11 @@ impl ThreadManager {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
thread_created_tx,
models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager.clone())),
models_manager: Arc::new(ModelsManager::new(
codex_home,
auth_manager.clone(),
model_catalog,
)),
skills_manager,
file_watcher,
auth_manager,
+1
View File
@@ -582,6 +582,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
codex_home.path().to_path_buf(),
auth_manager,
SessionSource::Exec,
config.model_catalog.clone(),
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config)
@@ -12,7 +12,7 @@ async fn offline_model_info_without_tool_output_override() {
let auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
);
let manager = ModelsManager::new(config.codex_home.clone(), auth_manager);
let manager = ModelsManager::new(config.codex_home.clone(), auth_manager, None);
let model_info = manager.get_model_info("gpt-5.1", &config).await;
@@ -30,7 +30,7 @@ async fn offline_model_info_with_tool_output_override() {
let auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
);
let manager = ModelsManager::new(config.codex_home.clone(), auth_manager);
let manager = ModelsManager::new(config.codex_home.clone(), auth_manager, None);
let model_info = manager.get_model_info("gpt-5.1-codex", &config).await;
+1
View File
@@ -373,6 +373,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
config.codex_home.clone(),
auth_manager.clone(),
SessionSource::Exec,
config.model_catalog.clone(),
));
let default_model = thread_manager
.get_models_manager()
@@ -61,6 +61,7 @@ impl MessageProcessor {
config.codex_home.clone(),
auth_manager,
SessionSource::Mcp,
config.model_catalog.clone(),
));
Self {
outgoing,
+3 -3
View File
@@ -1,8 +1,8 @@
[package]
name = "codex-protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
name = "codex-protocol"
version.workspace = true
[lib]
name = "codex_protocol"
@@ -12,8 +12,8 @@ path = "src/lib.rs"
workspace = true
[dependencies]
codex-git = { workspace = true }
codex-execpolicy = { workspace = true }
codex-git = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-image = { workspace = true }
icu_decimal = { workspace = true }
+1
View File
@@ -1026,6 +1026,7 @@ impl App {
config.codex_home.clone(),
auth_manager.clone(),
SessionSource::Cli,
config.model_catalog.clone(),
));
let mut model = thread_manager
.get_models_manager()
+2 -1
View File
@@ -1580,7 +1580,7 @@ async fn make_chatwidget_manual(
let auth_manager =
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("test"));
let codex_home = cfg.codex_home.clone();
let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone()));
let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone(), None));
let reasoning_effort = None;
let base_mode = CollaborationMode {
mode: ModeKind::Default,
@@ -1699,6 +1699,7 @@ fn set_chatgpt_auth(chat: &mut ChatWidget) {
chat.models_manager = Arc::new(ModelsManager::new(
chat.config.codex_home.clone(),
chat.auth_manager.clone(),
None,
));
}