diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2a45bf48be..30f33cd6cb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -82,6 +82,13 @@ ], "type": "string" }, + "ApplyPatchToolType": { + "enum": [ + "freeform", + "function" + ], + "type": "string" + }, "AppsConfigToml": { "additionalProperties": { "$ref": "#/definitions/AppConfig" @@ -335,6 +342,17 @@ }, "type": "object" }, + "ConfigShellToolType": { + "description": "Shell execution capability for a model.", + "enum": [ + "default", + "local", + "unified_exec", + "disabled", + "shell_command" + ], + "type": "string" + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -414,6 +432,25 @@ } ] }, + "InputModality": { + "description": "Canonical user-input modality tags advertised by a model.", + "oneOf": [ + { + "description": "Plain text turns and tool payloads.", + "enum": [ + "text" + ], + "type": "string" + }, + { + "description": "Image attachments included in user turns.", + "enum": [ + "image" + ], + "type": "string" + } + ] + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -422,6 +459,186 @@ ], "type": "string" }, + "ModelInfoPatch": { + "description": "User-provided patch for overriding model metadata in local config.\n\nEvery field is optional so users can override only the parts they need. The target model slug is provided by the surrounding map key.", + "properties": { + "apply_patch_tool_type": { + "allOf": [ + { + "$ref": "#/definitions/ApplyPatchToolType" + } + ], + "default": null + }, + "auto_compact_token_limit": { + "default": null, + "format": "int64", + "type": "integer" + }, + "base_instructions": { + "default": null, + "type": "string" + }, + "context_window": { + "default": null, + "format": "int64", + "type": "integer" + }, + "default_reasoning_level": { + "allOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + } + ], + "default": null + }, + "default_verbosity": { + "allOf": [ + { + "$ref": "#/definitions/Verbosity" + } + ], + "default": null + }, + "description": { + "default": null, + "type": "string" + }, + "display_name": { + "default": null, + "type": "string" + }, + "effective_context_window_percent": { + "default": null, + "format": "int64", + "type": "integer" + }, + "experimental_supported_tools": { + "default": null, + "items": { + "type": "string" + }, + "type": "array" + }, + "input_modalities": { + "default": null, + "items": { + "$ref": "#/definitions/InputModality" + }, + "type": "array" + }, + "model_messages": { + "allOf": [ + { + "$ref": "#/definitions/ModelMessages" + } + ], + "default": null + }, + "priority": { + "default": null, + "format": "int32", + "type": "integer" + }, + "shell_type": { + "allOf": [ + { + "$ref": "#/definitions/ConfigShellToolType" + } + ], + "default": null + }, + "support_verbosity": { + "default": null, + "type": "boolean" + }, + "supported_in_api": { + "default": null, + "type": "boolean" + }, + "supported_reasoning_levels": { + "default": null, + "items": { + "$ref": "#/definitions/ReasoningEffortPreset" + }, + "type": "array" + }, + "supports_parallel_tool_calls": { + "default": null, + "type": "boolean" + }, + "supports_reasoning_summaries": { + "default": null, + "type": "boolean" + }, + "truncation_policy": { + "allOf": [ + { + "$ref": "#/definitions/TruncationPolicyConfig" + } + ], + "default": null + }, + "upgrade": { + "allOf": [ + { + "$ref": "#/definitions/ModelInfoUpgrade" + } + ], + "default": null + }, + "visibility": { + "allOf": [ + { + "$ref": "#/definitions/ModelVisibility" + } + ], + "default": null + } + }, + "type": "object" + }, + "ModelInfoUpgrade": { + "properties": { + "migration_markdown": { + "type": "string" + }, + "model": { + "type": "string" + } + }, + "required": [ + "migration_markdown", + "model" + ], + "type": "object" + }, + "ModelInstructionsVariables": { + "properties": { + "personality_default": { + "type": "string" + }, + "personality_friendly": { + "type": "string" + }, + "personality_pragmatic": { + "type": "string" + } + }, + "type": "object" + }, + "ModelMessages": { + "description": "A strongly-typed template for assembling model instructions and developer messages. If instructions_* is populated and valid, it will override base_instructions.", + "properties": { + "instructions_template": { + "type": "string" + }, + "instructions_variables": { + "$ref": "#/definitions/ModelInstructionsVariables" + } + }, + "type": "object" + }, "ModelProviderInfo": { "additionalProperties": false, "description": "Serializable representation of a provider definition.", @@ -510,6 +727,15 @@ ], "type": "object" }, + "ModelVisibility": { + "description": "Visibility of a model in the picker or APIs.", + "enum": [ + "list", + "hide", + "none" + ], + "type": "string" + }, "Notice": { "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", "properties": { @@ -881,6 +1107,28 @@ ], "type": "string" }, + "ReasoningEffortPreset": { + "description": "A reasoning effort option that can be surfaced for a model.", + "properties": { + "description": { + "description": "Short human description shown next to the effort in UIs.", + "type": "string" + }, + "effort": { + "allOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + } + ], + "description": "Effort level that the model supports." + } + }, + "required": [ + "description", + "effort" + ], + "type": "object" + }, "ReasoningSummary": { "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", "oneOf": [ @@ -1038,6 +1286,30 @@ }, "type": "object" }, + "TruncationMode": { + "description": "Server-provided truncation policy metadata for a model.", + "enum": [ + "bytes", + "tokens" + ], + "type": "string" + }, + "TruncationPolicyConfig": { + "properties": { + "limit": { + "format": "int64", + "type": "integer" + }, + "mode": { + "$ref": "#/definitions/TruncationMode" + } + }, + "required": [ + "limit", + "mode" + ], + "type": "object" + }, "TrustLevel": { "description": "Represents the trust level for a project directory. This determines the approval policy and sandbox mode applied.", "enum": [ @@ -1442,6 +1714,14 @@ "format": "int64", "type": "integer" }, + "model_info_overrides": { + "additionalProperties": { + "$ref": "#/definitions/ModelInfoPatch" + }, + "default": {}, + "description": "Per-model metadata overrides keyed by model slug.", + "type": "object" + }, "model_instructions_file": { "allOf": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a923b0ef78..f53c2361b8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -57,6 +57,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::ModelInfoPatch; use codex_protocol::openai_models::ReasoningEffort; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; @@ -317,6 +318,9 @@ pub struct Config { /// Optional override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, + /// Per-model metadata overrides keyed by model slug. + pub model_info_overrides: HashMap, + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). pub model_verbosity: Option, @@ -975,6 +979,10 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, + /// Per-model metadata overrides keyed by model slug. + #[serde(default)] + pub model_info_overrides: HashMap, + /// Optionally specify a personality for the model pub personality: Option, @@ -1766,6 +1774,7 @@ impl Config { .or(cfg.model_reasoning_summary) .unwrap_or_default(), model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries, + model_info_overrides: cfg.model_info_overrides, model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), chatgpt_base_url: config_profile .chatgpt_base_url @@ -4054,6 +4063,7 @@ model_verbosity = "high" model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, + model_info_overrides: HashMap::new(), model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), @@ -4160,6 +4170,7 @@ model_verbosity = "high" model_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, + model_info_overrides: HashMap::new(), model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), @@ -4264,6 +4275,7 @@ model_verbosity = "high" model_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, + model_info_overrides: HashMap::new(), model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), @@ -4354,6 +4366,7 @@ model_verbosity = "high" model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, + model_info_overrides: HashMap::new(), 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 5c6a5db7fc..f62fbac446 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -134,17 +134,19 @@ impl ModelsManager { } // todo(aibrahim): look if we can tighten it to pub(crate) - /// Look up model metadata, applying remote overrides and config adjustments. + /// Look up model metadata, applying remote overrides and config adjustments, as well as client overrides. pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo { let remote = self .find_remote_model_by_longest_prefix(model, config) .await; - let model = if let Some(remote) = remote { + let model_info = if let Some(remote) = remote { remote } else { model_info::model_info_from_slug(model) }; - model_info::with_config_overrides(model, config) + + let model_info = model_info::with_model_info_patches(model_info, model, config); + model_info::with_config_overrides(model_info, config) } async fn find_remote_model_by_longest_prefix( @@ -369,11 +371,13 @@ impl ModelsManager { } /// Build `ModelInfo` without consulting remote state or cache. - pub(crate) fn construct_model_info_offline_for_tests( - model: &str, - config: &Config, - ) -> ModelInfo { - model_info::with_config_overrides(model_info::model_info_from_slug(model), config) + /// + /// This follows the same override precedence as `get_model_info`: + /// per-model `model_info_overrides` are applied first, then top-level config overrides. + pub(crate) fn construct_model_info_offline_for_tests(model: &str, config: &Config) -> ModelInfo { + let model_info = model_info::model_info_from_slug(model); + let model_info = model_info::with_model_info_patches(model_info, model, config); + model_info::with_config_overrides(model_info, config) } } diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 6a29cd96f9..17a8b7bcef 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -19,6 +19,102 @@ const LOCAL_FRIENDLY_TEMPLATE: &str = const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; +pub(crate) fn with_model_info_patch( + mut model: ModelInfo, + model_key: &str, + config: &Config, +) -> ModelInfo { + let Some(model_info_patch) = config.model_info_overrides.get(model_key) else { + return model; + }; + + model.slug = model_key.to_string(); + + if let Some(display_name) = &model_info_patch.display_name { + model.display_name = display_name.clone(); + } + if let Some(description) = &model_info_patch.description { + model.description = Some(description.clone()); + } + if let Some(default_reasoning_level) = model_info_patch.default_reasoning_level { + model.default_reasoning_level = Some(default_reasoning_level); + } + if let Some(supported_reasoning_levels) = &model_info_patch.supported_reasoning_levels { + model.supported_reasoning_levels = supported_reasoning_levels.clone(); + } + if let Some(shell_type) = model_info_patch.shell_type { + model.shell_type = shell_type; + } + if let Some(visibility) = model_info_patch.visibility { + model.visibility = visibility; + } + if let Some(supported_in_api) = model_info_patch.supported_in_api { + model.supported_in_api = supported_in_api; + } + if let Some(priority) = model_info_patch.priority { + model.priority = priority; + } + if let Some(upgrade) = &model_info_patch.upgrade { + model.upgrade = Some(upgrade.clone()); + } + if let Some(base_instructions) = &model_info_patch.base_instructions { + model.base_instructions = base_instructions.clone(); + } + if let Some(model_messages) = &model_info_patch.model_messages { + model.model_messages = Some(model_messages.clone()); + } + if let Some(supports_reasoning_summaries) = model_info_patch.supports_reasoning_summaries { + model.supports_reasoning_summaries = supports_reasoning_summaries; + } + if let Some(support_verbosity) = model_info_patch.support_verbosity { + model.support_verbosity = support_verbosity; + } + if let Some(default_verbosity) = model_info_patch.default_verbosity { + model.default_verbosity = Some(default_verbosity); + } + if let Some(apply_patch_tool_type) = &model_info_patch.apply_patch_tool_type { + model.apply_patch_tool_type = Some(apply_patch_tool_type.clone()); + } + if let Some(truncation_policy) = model_info_patch.truncation_policy { + model.truncation_policy = truncation_policy; + } + if let Some(supports_parallel_tool_calls) = model_info_patch.supports_parallel_tool_calls { + model.supports_parallel_tool_calls = supports_parallel_tool_calls; + } + if let Some(context_window) = model_info_patch.context_window { + model.context_window = Some(context_window); + } + if let Some(auto_compact_token_limit) = model_info_patch.auto_compact_token_limit { + model.auto_compact_token_limit = Some(auto_compact_token_limit); + } + if let Some(effective_context_window_percent) = + model_info_patch.effective_context_window_percent + { + model.effective_context_window_percent = effective_context_window_percent; + } + if let Some(experimental_supported_tools) = &model_info_patch.experimental_supported_tools { + model.experimental_supported_tools = experimental_supported_tools.clone(); + } + if let Some(input_modalities) = &model_info_patch.input_modalities { + model.input_modalities = input_modalities.clone(); + } + + model +} + +pub(crate) fn with_model_info_patches( + mut model_info: ModelInfo, + requested_model: &str, + config: &Config, +) -> ModelInfo { + let resolved_slug = model_info.slug.clone(); + model_info = with_model_info_patch(model_info, &resolved_slug, config); + if requested_model != resolved_slug { + model_info = with_model_info_patch(model_info, requested_model, config); + } + model_info +} + pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> ModelInfo { if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries { model.supports_reasoning_summaries = supports_reasoning_summaries; diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index d22e5c66e4..b2a5a820a7 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -13,6 +13,7 @@ use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ModelInfoPatch; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use core_test_support::load_default_config_for_test; @@ -233,8 +234,10 @@ async fn responses_stream_includes_subagent_header_on_other() { ); } +// Request-construction integration test: +// enabling summaries via config should result in reasoning.summary being sent in /responses. #[tokio::test] -async fn responses_respects_model_info_overrides_from_config() { +async fn responses_includes_reasoning_summary_when_enabled_in_config() { core_test_support::skip_if_no_network!(); let server = responses::start_mock_server().await; @@ -352,6 +355,127 @@ async fn responses_respects_model_info_overrides_from_config() { ); } +// Request-construction integration test for model_info_overrides: +// patching supports_reasoning_summaries on the selected model should enable reasoning.summary +// in the actual outbound /responses body. +#[tokio::test] +async fn responses_respects_model_info_overrides_from_config() { + core_test_support::skip_if_no_network!(); + + let server = responses::start_mock_server().await; + let response_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + + let request_recorder = responses::mount_sse_once(&server, response_body).await; + + let provider = ModelProviderInfo { + name: "mock".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + supports_websockets: false, + }; + + let codex_home = TempDir::new().expect("failed to create TempDir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.model = Some("gpt-3.5-turbo".to_string()); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + config.model_reasoning_summary = ReasoningSummary::Detailed; + config.model_info_overrides.insert( + "gpt-3.5-turbo".to_string(), + ModelInfoPatch { + supports_reasoning_summaries: Some(true), + ..Default::default() + }, + ); + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let model = config.model.clone().expect("model configured"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let auth_mode = auth_manager.auth_mode().map(TelemetryAuthMode::from); + let models_manager = ModelsManager::new(config.codex_home.clone(), auth_manager); + let config = Arc::new(config); + let model_info = models_manager.get_model_info(model.as_str(), &config).await; + + let conversation_id = ThreadId::new(); + let session_source = + SessionSource::SubAgent(SubAgentSource::Other("override-check".to_string())); + let otel_manager = OtelManager::new( + conversation_id, + model.as_str(), + model_info.slug.as_str(), + None, + Some("test@test.com".to_string()), + auth_mode, + "test_originator".to_string(), + false, + "test".to_string(), + session_source.clone(), + ); + + let client = ModelClient::new( + None, + conversation_id, + provider, + session_source, + config.model_verbosity, + false, + false, + false, + false, + None, + ); + let mut client_session = client.new_session(); + + let mut prompt = Prompt::default(); + prompt.input = vec![ResponseItem::Message { + id: None, + role: "user".into(), + content: vec![ContentItem::InputText { + text: "hello".into(), + }], + end_turn: None, + phase: None, + }]; + + let mut stream = client_session + .stream(&prompt, &model_info, &otel_manager, effort, summary, None) + .await + .expect("stream failed"); + while let Some(event) = stream.next().await { + if matches!(event, Ok(ResponseEvent::Completed { .. })) { + break; + } + } + + let request = request_recorder.single_request(); + let body = request.body_json(); + let reasoning = body + .get("reasoning") + .and_then(|value| value.as_object()) + .cloned(); + + assert_eq!( + reasoning + .as_ref() + .and_then(|value| value.get("summary")) + .and_then(|value| value.as_str()), + Some("detailed") + ); +} + #[tokio::test] async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() { core_test_support::skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/model_info_overrides.rs b/codex-rs/core/tests/suite/model_info_overrides.rs index a9e18f9605..83af790604 100644 --- a/codex-rs/core/tests/suite/model_info_overrides.rs +++ b/codex-rs/core/tests/suite/model_info_overrides.rs @@ -1,6 +1,8 @@ use codex_core::CodexAuth; use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; +use codex_protocol::openai_models::ModelInfoPatch; +use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::TruncationPolicyConfig; use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; @@ -42,3 +44,141 @@ async fn offline_model_info_with_tool_output_override() { TruncationPolicyConfig::tokens(123) ); } + +// Existing remote model path: +// fetch model metadata for a known slug, then apply per-slug patch values from config. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_info_patch_overrides_remote_model_fields() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + let auth_manager = codex_core::AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + ); + let manager = ModelsManager::new(config.codex_home.clone(), auth_manager); + + let mut baseline_config = config.clone(); + baseline_config.model_info_overrides.clear(); + let baseline = manager.get_model_info("gpt-5.1", &baseline_config).await; + + config.model_info_overrides.insert( + "gpt-5.1".to_string(), + ModelInfoPatch { + display_name: Some("gpt-5.1-dev".to_string()), + context_window: Some(123_456), + visibility: Some(ModelVisibility::Hide), + supported_in_api: Some(false), + ..Default::default() + }, + ); + let model_info = manager.get_model_info("gpt-5.1", &config).await; + + let mut expected = baseline; + expected.display_name = "gpt-5.1-dev".to_string(); + expected.context_window = Some(123_456); + expected.visibility = ModelVisibility::Hide; + expected.supported_in_api = false; + + assert_eq!(model_info, expected); +} + +// Unknown model path: +// when slug is not known remotely, manager falls back to synthetic metadata and still +// applies the patch for that requested slug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_info_patch_can_define_new_model_from_fallback() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + let auth_manager = codex_core::AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + ); + let manager = ModelsManager::new(config.codex_home.clone(), auth_manager); + + let mut baseline_config = config.clone(); + baseline_config.model_info_overrides.clear(); + let baseline = manager.get_model_info("gpt-fake", &baseline_config).await; + + config.model_info_overrides.insert( + "gpt-fake".to_string(), + ModelInfoPatch { + display_name: Some("gpt-fake-dev".to_string()), + context_window: Some(400_000), + supports_parallel_tool_calls: Some(true), + base_instructions: Some("Custom model instructions".to_string()), + ..Default::default() + }, + ); + let model_info = manager.get_model_info("gpt-fake", &config).await; + + let mut expected = baseline; + expected.slug = "gpt-fake".to_string(); + expected.display_name = "gpt-fake-dev".to_string(); + expected.context_window = Some(400_000); + expected.supports_parallel_tool_calls = true; + expected.base_instructions = "Custom model instructions".to_string(); + + assert_eq!(model_info, expected); +} + +// Offline helper parity path: +// construct_model_info_offline should apply model_info_overrides before global +// top-level overrides, matching get_model_info precedence. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn offline_helper_applies_model_info_patch() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + config.model_context_window = Some(111_111); + config.model_info_overrides.insert( + "gpt-fake-offline".to_string(), + ModelInfoPatch { + display_name: Some("gpt-fake-offline-dev".to_string()), + context_window: Some(222_222), + ..Default::default() + }, + ); + + let model_info = + codex_core::test_support::construct_model_info_offline("gpt-fake-offline", &config); + assert_eq!(model_info.display_name, "gpt-fake-offline-dev".to_string()); + assert_eq!(model_info.context_window, Some(111_111)); +} + +// Prefix-resolution path (requested slug differs from resolved slug): +// request a custom slug that resolves to known remote base model via longest-prefix match, +// then ensure the requested-slug patch is still applied. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_info_patch_applies_when_requested_slug_differs_from_resolved_slug() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + let auth_manager = codex_core::AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + ); + let manager = ModelsManager::new(config.codex_home.clone(), auth_manager); + + let requested_slug = "gpt-5.1-eval"; + let mut baseline_config = config.clone(); + baseline_config.model_info_overrides.clear(); + let baseline = manager + .get_model_info(requested_slug, &baseline_config) + .await; + + config.model_info_overrides.insert( + requested_slug.to_string(), + ModelInfoPatch { + display_name: Some("gpt-5.1-eval-dev".to_string()), + context_window: Some(456_789), + ..Default::default() + }, + ); + let model_info = manager.get_model_info(requested_slug, &config).await; + + let mut expected = baseline; + expected.slug = requested_slug.to_string(); + expected.display_name = "gpt-5.1-eval-dev".to_string(); + expected.context_window = Some(456_789); + + assert_eq!(model_info, expected); +} diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index f8e61fd57a..b2a63877d0 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -254,6 +254,37 @@ pub struct ModelInfo { pub prefer_websockets: bool, } +/// User-provided patch for overriding model metadata in local config. +/// +/// Every field is optional so users can override only the parts they need. +/// The target model slug is provided by the surrounding map key. +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, TS, JsonSchema)] +#[serde(default)] +pub struct ModelInfoPatch { + pub display_name: Option, + pub description: Option, + pub default_reasoning_level: Option, + pub supported_reasoning_levels: Option>, + pub shell_type: Option, + pub visibility: Option, + pub supported_in_api: Option, + pub priority: Option, + pub upgrade: Option, + pub base_instructions: Option, + pub model_messages: Option, + pub supports_reasoning_summaries: Option, + pub support_verbosity: Option, + pub default_verbosity: Option, + pub apply_patch_tool_type: Option, + pub truncation_policy: Option, + pub supports_parallel_tool_calls: Option, + pub context_window: Option, + pub auto_compact_token_limit: Option, + pub effective_context_window_percent: Option, + pub experimental_supported_tools: Option>, + pub input_modalities: Option>, +} + impl ModelInfo { pub fn auto_compact_token_limit(&self) -> Option { self.auto_compact_token_limit.or_else(|| { diff --git a/docs/config.md b/docs/config.md index 9d53538cae..1daff1f894 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,6 +28,32 @@ Codex can run a notification hook when the agent finishes a turn. See the config The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. +## `model_info_overrides` precedence + +`model_info_overrides` lets you patch metadata for specific model slugs in `config.toml`. +The patch is merge-only: only fields you set are overridden, and omitted fields keep their +resolved value. + +Precedence for model metadata is: + +1. Resolved model metadata (remote `/models` when available, otherwise built-in fallback) +2. `model_info_overrides[]` +3. Existing top-level config overrides (for example `model_context_window`, + `model_auto_compact_token_limit`, `model_supports_reasoning_summaries`, and + `tool_output_token_limit`) + +This means top-level overrides still win for their specific fields after per-model patching. + +Example: + +```toml +[model_info_overrides.gpt-fake] +display_name = "gpt-fake-dev" +context_window = 400000 +supports_parallel_tool_calls = true +base_instructions = "Custom model instructions" +``` + ## Notices Codex stores "do not show again" flags for some UI prompts under the `[notice]` table.