diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index cb645ff3f..f1c6029a2 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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 { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5408d37d7..aef21f5d4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0e1e49ba7..d67318d12 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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(); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ec4e37894..0d4ee60f0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + /// Optional full model catalog loaded from `model_catalog_json`. + /// When set, this replaces the bundled catalog for the current process. + pub model_catalog: Option, + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). pub model_verbosity: Option, @@ -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 { + let file_contents = std::fs::read_to_string(path)?; + serde_json::from_str::(&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, +) -> std::io::Result> { + model_catalog_json + .map(|path| load_catalog_json(&path)) + .transpose() +} + fn filter_mcp_servers_by_requirements( mcp_servers: &mut HashMap, mcp_requirements: Option<&Sourced>>, @@ -1039,6 +1065,10 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, + /// 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, + /// Optionally specify a personality for the model pub personality: Option, @@ -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 { 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(), diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 457351995..080532714 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -47,6 +47,7 @@ pub enum RefreshStrategy { pub struct ModelsManager { local_models: Vec, remote_models: RwLock>, + has_custom_model_catalog: bool, auth_manager: Arc, etag: RwLock>, 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) -> 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, + model_catalog: Option, + ) -> 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 { + let mut best: Option = 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 { - let mut best: Option = 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; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 28cb8e478..cb7c46436 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -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, session_source: SessionSource, + model_catalog: Option, ) -> 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, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index bbdbc7323..cc5fee306 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -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) diff --git a/codex-rs/core/tests/suite/model_info_overrides.rs b/codex-rs/core/tests/suite/model_info_overrides.rs index 2b085a004..7a92e99f1 100644 --- a/codex-rs/core/tests/suite/model_info_overrides.rs +++ b/codex-rs/core/tests/suite/model_info_overrides.rs @@ -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; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c4964cc80..70ddb70f1 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -373,6 +373,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config.codex_home.clone(), auth_manager.clone(), SessionSource::Exec, + config.model_catalog.clone(), )); let default_model = thread_manager .get_models_manager() diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 612582dae..3b18f2fd4 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -61,6 +61,7 @@ impl MessageProcessor { config.codex_home.clone(), auth_manager, SessionSource::Mcp, + config.model_catalog.clone(), )); Self { outgoing, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index b58393936..02db92d09 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -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 } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 323769dab..a46a927fb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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() diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 15f1057cf..829c39d0a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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, )); }