diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2aa45fafdd82..bc407863f035 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2223,7 +2223,7 @@ "$ref": "#/definitions/ModelProviderInfo" }, "default": {}, - "description": "User-defined provider entries that extend/override the built-in list.", + "description": "User-defined provider entries that extend the built-in list. Built-in IDs cannot be overridden.", "type": "object" }, "model_reasoning_effort": { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 18f9e24d148e..a0d541a15d41 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,6 +47,7 @@ use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; +use crate::model_provider_info::OPENAI_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; use crate::path_utils::normalize_for_native_workdir; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; @@ -139,6 +140,11 @@ pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; pub const CONFIG_TOML_FILE: &str = "config.toml"; const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL"; +const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ + OPENAI_PROVIDER_ID, + OLLAMA_OSS_PROVIDER_ID, + LMSTUDIO_OSS_PROVIDER_ID, +]; fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; @@ -367,7 +373,7 @@ pub struct Config { /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). pub mcp_oauth_callback_url: Option, - /// Combined provider map (defaults merged with user-defined overrides). + /// Combined provider map (defaults plus user-defined providers). pub model_providers: HashMap, /// Maximum number of bytes to include from an AGENTS.md project doc file. @@ -1262,8 +1268,9 @@ pub struct ConfigToml { /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). pub mcp_oauth_callback_url: Option, - /// User-defined provider entries that extend/override the built-in list. - #[serde(default)] + /// User-defined provider entries that extend the built-in list. Built-in + /// IDs cannot be overridden. + #[serde(default, deserialize_with = "deserialize_model_providers")] pub model_providers: HashMap, /// Maximum number of bytes to include from an AGENTS.md project doc file. @@ -1890,6 +1897,37 @@ pub struct ConfigOverrides { pub additional_writable_roots: Vec, } +fn validate_reserved_model_provider_ids( + model_providers: &HashMap, +) -> Result<(), String> { + let mut conflicts = model_providers + .keys() + .filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())) + .map(|key| format!("`{key}`")) + .collect::>(); + conflicts.sort_unstable(); + if conflicts.is_empty() { + Ok(()) + } else { + Err(format!( + "model_providers contains reserved built-in provider IDs: {}. \ +Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).", + conflicts.join(", ") + )) + } +} + +fn deserialize_model_providers<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let model_providers = HashMap::::deserialize(deserializer)?; + validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?; + Ok(model_providers) +} + /// Resolves the OSS provider from CLI override, profile config, or global config. /// Returns `None` if no provider is configured at any level. pub fn resolve_oss_provider( @@ -2011,6 +2049,8 @@ impl Config { codex_home: PathBuf, config_layer_stack: ConfigLayerStack, ) -> std::io::Result { + validate_reserved_model_provider_ids(&cfg.model_providers) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; // Ensure that every field of ConfigRequirements is applied to the final // Config. let ConfigRequirements { diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index a23537e13998..bc3006a9a9c5 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -386,6 +386,34 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { assert_eq!(contents.trim(), "model = \"user\""); } +#[tokio::test] +async fn reserved_builtin_provider_override_rejected() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap(); + + let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model_providers.openai.name".to_string(), + value: serde_json::json!("OpenAI Override"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("should reject reserved provider override"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!(error.to_string().contains("reserved built-in provider IDs")); + assert!(error.to_string().contains("`openai`")); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "model = \"user\"\n"); +} + #[tokio::test] async fn write_value_rejects_feature_requirement_conflict() { let tmp = tempdir().expect("tempdir");