diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c59b8d9c4df..b69894e725a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1813,6 +1813,20 @@ dependencies = [ "wildmatch", ] +[[package]] +name = "codex-config-schema" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-config", + "codex-protocol", + "codex-utils-absolute-path", + "schemars 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "codex-connectors" version = "0.0.0" @@ -1848,6 +1862,7 @@ dependencies = [ "codex-async-utils", "codex-code-mode", "codex-config", + "codex-config-schema", "codex-connectors", "codex-core-skills", "codex-exec-server", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2d80bbc69eb..5e0493b0570 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -22,6 +22,7 @@ members = [ "cli", "connectors", "config", + "config-schema", "shell-command", "shell-escalation", "skills", @@ -117,6 +118,7 @@ codex-cloud-tasks-client = { path = "cloud-tasks-client" } codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } codex-code-mode = { path = "code-mode" } codex-config = { path = "config" } +codex-config-schema = { path = "config-schema" } codex-connectors = { path = "connectors" } codex-core = { path = "core" } codex-core-skills = { path = "core-skills" } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cff..7b7968f40fe 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -228,6 +228,8 @@ impl MessageProcessor { config.as_ref(), auth_manager.clone(), session_source, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig { default_mode_request_user_input: config .features diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 8072ff45f6c..3fa8d7e9d97 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -15,6 +15,7 @@ use std::path::Path; fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { ModelInfo { slug: preset.id.clone(), + request_model: None, display_name: preset.display_name.clone(), description: Some(preset.description.clone()), default_reasoning_level: Some(preset.default_reasoning_effort), diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3ffb2496b4b..76aef1d9f44 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -54,6 +54,7 @@ async fn models_client_hits_models_endpoint() { let response = ModelsResponse { models: vec![ModelInfo { slug: "gpt-test".to_string(), + request_model: None, display_name: "gpt-test".to_string(), description: Some("desc".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/config-schema/BUILD.bazel b/codex-rs/config-schema/BUILD.bazel new file mode 100644 index 00000000000..b9e5b8a8353 --- /dev/null +++ b/codex-rs/config-schema/BUILD.bazel @@ -0,0 +1,10 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "config-schema", + crate_name = "codex_config_schema", + compile_data = ["//codex-rs/core:config.schema.json"], + rustc_env = { + "CARGO_MANIFEST_DIR": "codex-rs/config-schema", + }, +) diff --git a/codex-rs/config-schema/Cargo.toml b/codex-rs/config-schema/Cargo.toml new file mode 100644 index 00000000000..8c678d27f48 --- /dev/null +++ b/codex-rs/config-schema/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codex-config-schema" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +doctest = false +name = "codex_config_schema" +path = "src/lib.rs" + +[[bin]] +name = "codex-write-config-schema" +path = "src/bin/config_schema.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-config = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/codex-rs/config-schema/src/bin/config_schema.rs b/codex-rs/config-schema/src/bin/config_schema.rs new file mode 100644 index 00000000000..0e153ef2203 --- /dev/null +++ b/codex-rs/config-schema/src/bin/config_schema.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +/// Generate the JSON Schema for `config.toml` and write it to `config.schema.json`. +#[derive(Parser)] +#[command(name = "codex-write-config-schema")] +struct Args { + #[arg(short, long, value_name = "PATH")] + out: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let out_path = args.out.unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../core/config.schema.json") + }); + codex_config_schema::write_config_schema(&out_path)?; + Ok(()) +} diff --git a/codex-rs/config-schema/src/config_toml.rs b/codex-rs/config-schema/src/config_toml.rs new file mode 100644 index 00000000000..87c7e3cf4b6 --- /dev/null +++ b/codex-rs/config-schema/src/config_toml.rs @@ -0,0 +1,617 @@ +use crate::features::features_schema; +use crate::model_provider::ModelProviderInfo; +use crate::permissions::PermissionsToml; +use codex_config::AppToolApproval; +use codex_config::McpServerConfig; +use codex_config::RawMcpServerConfig; +use codex_config::SkillsConfig; +use codex_protocol::config_types::AltScreenMode; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::ObjectValidation; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::collections::HashMap; + +const fn default_enabled() -> bool { + true +} + +const fn default_true() -> bool { + true +} + +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuthCredentialsStoreMode { + #[default] + File, + Keyring, + Auto, + Ephemeral, +} + +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum OAuthCredentialsStoreMode { + #[default] + Auto, + File, + Keyring, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigProfile { + pub model: Option, + pub service_tier: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub approvals_reviewer: Option, + pub sandbox_mode: Option, + pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub model_catalog_json: Option, + pub personality: Option, + pub chatgpt_base_url: Option, + pub model_instructions_file: Option, + pub js_repl_node_path: Option, + pub js_repl_node_module_dirs: Option>, + pub zsh_path: Option, + #[schemars(skip)] + pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, + pub include_apply_patch_tool: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + pub tools_view_image: Option, + pub tools: Option, + pub web_search: Option, + pub analytics: Option, + #[serde(default)] + pub windows: Option, + #[serde(default)] + #[schemars(schema_with = "features_schema")] + pub features: Option, + pub oss_provider: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +pub struct FeaturesToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum WindowsSandboxModeToml { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct WindowsToml { + pub sandbox: Option, + pub sandbox_private_desktop: Option, +} + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)] +pub enum UriBasedFileOpener { + #[serde(rename = "vscode")] + VsCode, + #[serde(rename = "vscode-insiders")] + VsCodeInsiders, + #[serde(rename = "windsurf")] + Windsurf, + #[serde(rename = "cursor")] + Cursor, + #[serde(rename = "none")] + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct History { + pub persistence: HistoryPersistence, + pub max_bytes: Option, +} + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HistoryPersistence { + #[default] + SaveAll, + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AnalyticsConfigToml { + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct FeedbackConfigToml { + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToolSuggestDiscoverableType { + Connector, + Plugin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestDiscoverable { + #[serde(rename = "type")] + pub kind: ToolSuggestDiscoverableType, + pub id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestConfig { + #[serde(default)] + pub discoverables: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MemoriesToml { + pub no_memories_if_mcp_or_web_search: Option, + pub generate_memories: Option, + pub use_memories: Option, + pub max_raw_memories_for_consolidation: Option, + pub max_unused_days: Option, + pub max_rollout_age_days: Option, + pub max_rollouts_per_startup: Option, + pub min_rollout_idle_hours: Option, + pub extract_model: Option, + pub consolidation_model: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub destructive_enabled: bool, + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destructive_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsConfigToml { + #[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(default, flatten)] + pub apps: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum OtelHttpProtocol { + Binary, + Json, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +pub struct OtelTlsConfig { + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +pub enum OtelExporterKind { + None, + Statsig, + OtlpHttp { + endpoint: String, + #[serde(default)] + headers: HashMap, + protocol: OtelHttpProtocol, + #[serde(default)] + tls: Option, + }, + OtlpGrpc { + endpoint: String, + #[serde(default)] + headers: HashMap, + #[serde(default)] + tls: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OtelConfigToml { + pub log_user_prompt: Option, + pub environment: Option, + pub exporter: Option, + pub trace_exporter: Option, + pub metrics_exporter: Option, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Notifications { + Enabled(bool), + Custom(Vec), +} + +impl Default for Notifications { + fn default() -> Self { + Self::Enabled(true) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationMethod { + #[default] + Auto, + Osc9, + Bel, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ModelAvailabilityNuxConfig { + #[serde(default, flatten)] + pub shown_count: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct Tui { + #[serde(default)] + pub notifications: Notifications, + #[serde(default)] + pub notification_method: NotificationMethod, + #[serde(default = "default_true")] + pub animations: bool, + #[serde(default = "default_true")] + pub show_tooltips: bool, + #[serde(default)] + pub alternate_screen: AltScreenMode, + #[serde(default)] + pub status_line: Option>, + #[serde(default)] + pub terminal_title: Option>, + #[serde(default)] + pub theme: Option, + #[serde(default)] + pub model_availability_nux: ModelAvailabilityNuxConfig, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +pub struct Notice { + pub hide_full_access_warning: Option, + pub hide_world_writable_warning: Option, + pub hide_rate_limit_model_nudge: Option, + pub hide_gpt5_1_migration_prompt: Option, + #[serde(rename = "hide_gpt-5.1-codex-max_migration_prompt")] + pub hide_gpt_5_1_codex_max_migration_prompt: Option, + #[serde(default)] + pub model_migrations: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PluginConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ShellEnvironmentPolicyInherit { + Core, + #[default] + All, + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ShellEnvironmentPolicyToml { + pub inherit: Option, + pub ignore_default_excludes: Option, + pub exclude: Option>, + pub r#set: Option>, + pub include_only: Option>, + pub experimental_use_profile: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ProjectConfig { + pub trust_level: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeWsMode { + #[default] + Conversational, + Transcription, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeToml { + pub version: Option, + #[serde(rename = "type")] + pub session_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeAudioToml { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct CustomModelToml { + pub name: String, + pub model: String, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolsToml { + pub web_search: Option, + #[serde(default)] + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentsToml { + #[schemars(range(min = 1))] + pub max_threads: Option, + #[schemars(range(min = 1))] + pub max_depth: Option, + #[schemars(range(min = 1))] + pub job_max_runtime_seconds: Option, + #[serde(default, flatten)] + pub roles: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentRoleToml { + pub description: Option, + pub config_file: Option, + pub nickname_candidates: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct GhostSnapshotToml { + #[serde(alias = "ignore_untracked_files_over_bytes")] + pub ignore_large_untracked_files: Option, + #[serde(alias = "large_untracked_dir_warning_threshold")] + pub ignore_large_untracked_dirs: Option, + pub disable_warnings: Option, +} + +/// Base config deserialized from ~/.codex/config.toml. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigToml { + pub model: Option, + pub review_model: Option, + pub model_provider: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub approval_policy: Option, + pub approvals_reviewer: Option, + #[serde(default)] + pub shell_environment_policy: ShellEnvironmentPolicyToml, + pub allow_login_shell: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub default_permissions: Option, + #[serde(default)] + pub permissions: Option, + #[serde(default)] + pub notify: Option>, + pub instructions: Option, + #[serde(default)] + pub developer_instructions: Option, + pub model_instructions_file: Option, + pub compact_prompt: Option, + pub commit_attribution: Option, + #[serde(default)] + pub forced_chatgpt_workspace_id: Option, + #[serde(default)] + pub forced_login_method: Option, + #[serde(default)] + pub cli_auth_credentials_store: Option, + #[serde(default)] + #[schemars(schema_with = "mcp_servers_schema")] + pub mcp_servers: HashMap, + #[serde(default)] + pub mcp_oauth_credentials_store: Option, + pub mcp_oauth_callback_port: Option, + pub mcp_oauth_callback_url: Option, + #[serde(default)] + pub model_providers: HashMap, + #[serde(default)] + pub custom_models: Vec, + pub project_doc_max_bytes: Option, + pub project_doc_fallback_filenames: Option>, + pub tool_output_token_limit: Option, + pub background_terminal_max_timeout: Option, + pub js_repl_node_path: Option, + pub js_repl_node_module_dirs: Option>, + pub zsh_path: Option, + pub profile: Option, + #[serde(default)] + pub profiles: HashMap, + #[serde(default)] + pub history: Option, + pub sqlite_home: Option, + pub log_dir: Option, + pub file_opener: Option, + pub tui: Option, + pub hide_agent_reasoning: Option, + pub show_raw_agent_reasoning: Option, + pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub model_supports_reasoning_summaries: Option, + pub model_catalog_json: Option, + pub personality: Option, + pub service_tier: Option, + pub chatgpt_base_url: Option, + pub openai_base_url: Option, + #[serde(default)] + pub audio: Option, + pub experimental_realtime_ws_base_url: Option, + pub experimental_realtime_ws_model: Option, + #[serde(default)] + pub realtime: Option, + pub experimental_realtime_ws_backend_prompt: Option, + pub experimental_realtime_ws_startup_context: Option, + pub experimental_realtime_start_instructions: Option, + pub projects: Option>, + pub web_search: Option, + pub tools: Option, + pub tool_suggest: Option, + pub agents: Option, + pub memories: Option, + pub skills: Option, + #[serde(default)] + pub plugins: HashMap, + #[serde(default)] + #[schemars(schema_with = "features_schema")] + pub features: Option, + pub suppress_unstable_features_warning: Option, + #[serde(default)] + pub ghost_snapshot: Option, + #[serde(default)] + pub project_root_markers: Option>, + pub check_for_update_on_startup: Option, + pub disable_paste_burst: Option, + pub analytics: Option, + pub feedback: Option, + #[serde(default)] + pub apps: Option, + pub otel: Option, + #[serde(default)] + pub windows: Option, + pub windows_wsl_setup_acknowledged: Option, + pub notice: Option, + #[schemars(skip)] + pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + pub oss_provider: Option, +} + +/// Schema for the `[mcp_servers]` map using the raw input shape. +pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let validation = ObjectValidation { + additional_properties: Some(Box::new(schema_gen.subschema_for::())), + ..Default::default() + }; + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} diff --git a/codex-rs/config-schema/src/features.rs b/codex-rs/config-schema/src/features.rs new file mode 100644 index 00000000000..7bb0ed454b1 --- /dev/null +++ b/codex-rs/config-schema/src/features.rs @@ -0,0 +1,96 @@ +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::ObjectValidation; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; + +const FEATURE_KEYS: &[&str] = &[ + "undo", + "shell_tool", + "unified_exec", + "shell_zsh_fork", + "shell_snapshot", + "js_repl", + "code_mode", + "code_mode_only", + "js_repl_tools_only", + "web_search_request", + "web_search_cached", + "search_tool", + "codex_git_commit", + "runtime_metrics", + "general_analytics", + "sqlite", + "memories", + "child_agents_md", + "image_detail_original", + "apply_patch_freeform", + "exec_permission_approvals", + "codex_hooks", + "request_permissions_tool", + "use_linux_sandbox_bwrap", + "use_legacy_landlock", + "request_rule", + "experimental_windows_sandbox", + "elevated_windows_sandbox", + "remote_models", + "enable_request_compression", + "multi_agent", + "multi_agent_v2", + "enable_fanout", + "apps", + "tool_search", + "tool_suggest", + "plugins", + "image_generation", + "skill_mcp_dependency_install", + "skill_env_var_dependency_prompt", + "steer", + "default_mode_request_user_input", + "guardian_approval", + "collaboration_modes", + "tool_call_mcp_elicitation", + "personality", + "fast_mode", + "realtime_conversation", + "tui_app_server", + "prevent_idle_sleep", + "responses_websockets", + "responses_websockets_v2", +]; + +const LEGACY_FEATURE_KEYS: &[&str] = &[ + "connectors", + "enable_experimental_windows_sandbox", + "experimental_use_unified_exec_tool", + "experimental_use_freeform_apply_patch", + "include_apply_patch_tool", + "request_permissions", + "web_search", + "collab", + "memory_tool", +]; + +/// Schema for the `[features]` map with known + legacy keys only. +pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let mut validation = ObjectValidation::default(); + for key in FEATURE_KEYS { + validation + .properties + .insert((*key).to_string(), schema_gen.subschema_for::()); + } + for key in LEGACY_FEATURE_KEYS { + validation + .properties + .insert((*key).to_string(), schema_gen.subschema_for::()); + } + validation.additional_properties = Some(Box::new(Schema::Bool(false))); + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} diff --git a/codex-rs/config-schema/src/lib.rs b/codex-rs/config-schema/src/lib.rs new file mode 100644 index 00000000000..0e6f267d457 --- /dev/null +++ b/codex-rs/config-schema/src/lib.rs @@ -0,0 +1,146 @@ +mod config_toml; +mod features; +mod model_provider; +mod permissions; + +use config_toml::ConfigToml; +use schemars::r#gen::SchemaSettings; +use schemars::schema::RootSchema; +use serde_json::Map; +use serde_json::Value; +use std::path::Path; + +pub use config_toml::mcp_servers_schema; +pub use features::features_schema; + +/// Build the config schema for `config.toml`. +pub fn config_schema() -> RootSchema { + SchemaSettings::draft07() + .with(|settings| { + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::() +} + +/// Canonicalize a JSON value by sorting its keys. +pub fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + Value::Object(map) => { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut sorted = Map::with_capacity(map.len()); + for (key, child) in entries { + sorted.insert(key.clone(), canonicalize(child)); + } + Value::Object(sorted) + } + _ => value.clone(), + } +} + +/// Render the config schema as pretty-printed JSON. +pub fn config_schema_json() -> anyhow::Result> { + let schema = config_schema(); + let value = serde_json::to_value(schema)?; + let value = preserve_schema_presentation(value)?; + let value = canonicalize(&value); + let mut json = serde_json::to_vec_pretty(&value)?; + json.push(b'\n'); + Ok(json) +} + +/// Write the config schema fixture to disk. +pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { + let json = config_schema_json()?; + std::fs::write(out_path, json)?; + Ok(()) +} + +fn preserve_schema_presentation(value: Value) -> anyhow::Result { + let template = include_str!("../../core/config.schema.json"); + let template = serde_json::from_str(template)?; + Ok(preserve_schema_presentation_from_template(value, &template)) +} + +fn preserve_schema_presentation_from_template(mut value: Value, template: &Value) -> Value { + if let (Some(value_ref), Some(template_ref)) = ( + value.get("$ref").and_then(Value::as_str), + template_ref(template), + ) && value_ref == template_ref + { + return template.clone(); + } + + if let (Some(value_enum), Some(template_one_of)) = + (string_enum_values(&value), string_one_of_values(template)) + && value_enum == template_one_of + { + return template.clone(); + } + + if let (Value::Array(values), Value::Array(template_values)) = (&mut value, template) { + for (value_child, template_child) in values.iter_mut().zip(template_values) { + *value_child = preserve_schema_presentation_from_template( + std::mem::take(value_child), + template_child, + ); + } + return value; + } + + let (Value::Object(value_object), Value::Object(template_object)) = (&mut value, template) + else { + return value; + }; + + if !value_object.contains_key("description") + && let Some(description) = template_object.get("description") + { + value_object.insert("description".to_string(), description.clone()); + } + + for (key, value_child) in value_object.iter_mut() { + let Some(template_child) = template_object.get(key) else { + continue; + }; + *value_child = + preserve_schema_presentation_from_template(std::mem::take(value_child), template_child); + } + + value +} + +fn template_ref(template: &Value) -> Option<&str> { + let template_object = template.as_object()?; + let all_of = template_object.get("allOf")?.as_array()?; + let [schema] = all_of.as_slice() else { + return None; + }; + schema.get("$ref")?.as_str() +} + +fn string_enum_values(value: &Value) -> Option> { + let value_object = value.as_object()?; + if value_object.get("type").and_then(Value::as_str) != Some("string") { + return None; + } + let enums = value_object.get("enum")?.as_array()?; + enums.iter().map(Value::as_str).collect() +} + +fn string_one_of_values(value: &Value) -> Option> { + let one_of = value.get("oneOf")?.as_array()?; + let mut values = Vec::new(); + for item in one_of { + let item_object = item.as_object()?; + if item_object.get("type").and_then(Value::as_str) != Some("string") { + return None; + } + let enums = item_object.get("enum")?.as_array()?; + let mut item_values: Vec<&str> = enums.iter().map(Value::as_str).collect::>()?; + values.append(&mut item_values); + } + Some(values) +} diff --git a/codex-rs/config-schema/src/model_provider.rs b/codex-rs/config-schema/src/model_provider.rs new file mode 100644 index 00000000000..4655ac77fd5 --- /dev/null +++ b/codex-rs/config-schema/src/model_provider.rs @@ -0,0 +1,68 @@ +use codex_protocol::config_types::ModelProviderAuthInfo; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; + +/// Wire protocol that the provider speaks. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum WireApi { + /// The Responses API exposed by OpenAI at `/v1/responses`. + #[default] + Responses, +} + +/// Serializable representation of a provider definition. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ModelProviderInfo { + /// Friendly display name. + pub name: String, + /// Base URL for the provider's OpenAI-compatible API. + pub base_url: Option, + /// Environment variable that stores the user's API key for this provider. + pub env_key: Option, + /// Optional instructions to help the user get a valid value for the + /// variable and set it. + pub env_key_instructions: Option, + /// Value to use with `Authorization: Bearer ` header. Use of this + /// config is discouraged in favor of `env_key` for security reasons, but + /// this may be necessary when using this programmatically. + pub experimental_bearer_token: Option, + /// Command-backed bearer-token configuration for this provider. + pub auth: Option, + /// Which wire protocol this provider expects. + #[serde(default)] + pub wire_api: WireApi, + /// Optional query parameters to append to the base URL. + pub query_params: Option>, + /// Additional HTTP headers to include in requests to this provider where + /// the (key, value) pairs are the header name and value. + pub http_headers: Option>, + /// Optional HTTP headers to include in requests to this provider where the + /// (key, value) pairs are the header name and _environment variable_ whose + /// value should be used. If the environment variable is not set, or the + /// value is empty, the header will not be included in the request. + pub env_http_headers: Option>, + /// Maximum number of times to retry a failed HTTP request to this provider. + pub request_max_retries: Option, + /// Number of times to retry reconnecting a dropped streaming response before failing. + pub stream_max_retries: Option, + /// Idle timeout (in milliseconds) to wait for activity on a streaming + /// response before treating the connection as lost. + pub stream_idle_timeout_ms: Option, + /// Maximum time (in milliseconds) to wait for a websocket connection + /// attempt before treating it as failed. + pub websocket_connect_timeout_ms: Option, + /// Does this provider require an OpenAI API Key or ChatGPT login token? If + /// true, user is presented with login screen on first run, and login + /// preference and token/key are stored in auth.json. If false (which is + /// the default), login screen is skipped, and API key (if needed) comes + /// from the "env_key" environment variable. + #[serde(default)] + pub requires_openai_auth: bool, + /// Whether this provider supports the Responses API WebSocket transport. + #[serde(default)] + pub supports_websockets: bool, +} diff --git a/codex-rs/config-schema/src/permissions.rs b/codex-rs/config-schema/src/permissions.rs new file mode 100644 index 00000000000..d426cf5adcf --- /dev/null +++ b/codex-rs/config-schema/src/permissions.rs @@ -0,0 +1,85 @@ +use codex_protocol::permissions::FileSystemAccessMode; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct PermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileToml { + pub filesystem: Option, + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct FilesystemPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum FilesystemPermissionToml { + Access(FileSystemAccessMode), + Scoped(BTreeMap), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NetworkModeSchema { + Limited, + Full, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkToml { + pub enabled: Option, + pub proxy_url: Option, + pub enable_socks5: Option, + pub socks_url: Option, + pub enable_socks5_udp: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + pub mode: Option, + pub domains: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, +} diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 591eb1f28a9..e895e9ff47e 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -2,6 +2,7 @@ load("//:defs.bzl", "codex_rust_crate") exports_files( [ + "config.schema.json", "templates/collaboration_mode/default.md", "templates/collaboration_mode/plan.md", ], diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index ad82b6c0863..98a9338e209 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -34,6 +34,7 @@ codex-async-utils = { workspace = true } codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } +codex-config-schema = { workspace = true } codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a8f3229b541..d0b8b3eb497 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -606,6 +606,34 @@ }, "type": "object" }, + "CustomModelToml": { + "additionalProperties": false, + "properties": { + "model": { + "description": "Provider-facing model slug used on API requests.", + "type": "string" + }, + "model_auto_compact_token_limit": { + "description": "Optional auto-compaction token limit override applied when this alias is selected.", + "format": "int64", + "type": "integer" + }, + "model_context_window": { + "description": "Optional context window override applied when this alias is selected.", + "format": "int64", + "type": "integer" + }, + "name": { + "description": "User-facing alias shown in the model picker.", + "type": "string" + } + }, + "required": [ + "model", + "name" + ], + "type": "object" + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -1983,6 +2011,14 @@ "description": "Compact prompt used for history compaction.", "type": "string" }, + "custom_models": { + "default": [], + "description": "User-defined model aliases that can override model context settings.", + "items": { + "$ref": "#/definitions/CustomModelToml" + }, + "type": "array" + }, "default_permissions": { "description": "Default named permissions profile to apply from the `[permissions]` table.", "type": "string" @@ -2627,4 +2663,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 407c4f2e9be..b8972e04c86 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -390,7 +390,7 @@ impl ModelClient { }; let text = create_text_param_for_request(verbosity, &prompt.output_schema); let payload = ApiCompactionInput { - model: &model_info.slug, + model: model_info.request_model_slug(), input: &input, instructions: &instructions, tools, @@ -443,7 +443,7 @@ impl ModelClient { .with_telemetry(Some(request_telemetry)); let payload = ApiMemorySummarizeInput { - model: model_info.slug.clone(), + model: model_info.request_model_slug().to_string(), raw_memories, reasoning: effort.map(|effort| Reasoning { effort: Some(effort), @@ -731,7 +731,7 @@ impl ModelClientSession { let text = create_text_param_for_request(verbosity, &prompt.output_schema); let prompt_cache_key = Some(self.client.state.conversation_id.to_string()); let request = ResponsesApiRequest { - model: model_info.slug.clone(), + model: model_info.request_model_slug().to_string(), instructions: instructions.clone(), input, tools, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 92d75390d42..c2f09c8803e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3448,7 +3448,8 @@ impl Session { turn_context: &Arc, server_model: String, ) -> bool { - let requested_model = turn_context.model_info.slug.clone(); + let requested_model = turn_context.model_info.request_model_slug().to_string(); + let selected_model = turn_context.model_info.slug.clone(); let server_model_normalized = server_model.to_ascii_lowercase(); let requested_model_normalized = requested_model.to_ascii_lowercase(); if server_model_normalized == requested_model_normalized { @@ -3456,7 +3457,9 @@ impl Session { return false; } - warn!("server reported model {server_model} while requested model was {requested_model}"); + warn!( + "server reported model {server_model} while requested model was {requested_model} (selected alias: {selected_model})" + ); let warning_message = format!( "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: {CYBER_VERIFY_URL} or learn more: {CYBER_SAFETY_URL}" @@ -3465,7 +3468,7 @@ impl Session { self.send_event( turn_context, EventMsg::ModelReroute(ModelRerouteEvent { - from_model: requested_model.clone(), + from_model: selected_model, to_model: server_model.clone(), reason: ModelRerouteReason::HighRiskCyberActivity, }), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 8f1bba647c4..fd10a80af52 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -99,6 +99,7 @@ use core_test_support::tracing::install_test_tracing; use core_test_support::wait_for_event; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; +use std::collections::HashMap; use std::path::Path; use std::time::Duration; use tokio::time::sleep; @@ -254,7 +255,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { ThreadId::try_from("00000000-0000-4000-8000-000000000001") .expect("test thread id should be valid"), crate::model_provider_info::ModelProviderInfo::create_openai_provider( - /* base_url */ /*base_url*/ None, + /*base_url*/ None, ), codex_protocol::protocol::SessionSource::Exec, /*model_verbosity*/ None, @@ -2487,6 +2488,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); @@ -2580,6 +2582,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); @@ -3417,6 +3420,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28eb..1113b9678ca 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -426,6 +426,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config.codex_home.clone(), auth_manager.clone(), /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), )); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 02f9582561b..6f8655ba57f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -244,6 +244,48 @@ web_search = false ); } +#[test] +fn config_toml_deserializes_custom_models() { + let custom_models = r#" +[[custom_models]] +name = "gpt-5.4 1m" +model = "gpt-5.4" +model_context_window = 1000000 +model_auto_compact_token_limit = 900000 +"#; + let custom_models_cfg = toml::from_str::(custom_models) + .expect("TOML deserialization should succeed for custom models"); + + assert_eq!( + custom_models_cfg.custom_models, + vec![CustomModelToml { + name: "gpt-5.4 1m".to_string(), + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }] + ); + + let config = Config::load_from_base_config_with_overrides( + custom_models_cfg, + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config from custom models settings"); + + assert_eq!( + config.custom_models, + HashMap::from([( + "gpt-5.4 1m".to_string(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + )]) + ); +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( @@ -4425,6 +4467,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -4567,6 +4610,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai-custom".to_string(), model_provider: fixture.openai_custom_provider.clone(), @@ -4707,6 +4751,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -4833,6 +4878,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + custom_models: HashMap::new(), service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f22258dfb9e..828b4c14845 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -179,6 +179,12 @@ pub(crate) fn test_config() -> Config { .expect("load default test config") } +impl Config { + pub(crate) fn custom_model_alias(&self, alias: &str) -> Option<&CustomModelConfig> { + self.custom_models.get(alias) + } +} + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Permissions { @@ -390,6 +396,9 @@ pub struct Config { /// Combined provider map (defaults plus user-defined providers). pub model_providers: HashMap, + /// User-defined model aliases shown in the picker. + pub custom_models: HashMap, + /// Maximum number of bytes to include from an AGENTS.md project doc file. pub project_doc_max_bytes: usize, @@ -1229,6 +1238,10 @@ pub struct ConfigToml { #[serde(default, deserialize_with = "deserialize_model_providers")] pub model_providers: HashMap, + /// User-defined model aliases that can override model context settings. + #[serde(default)] + pub custom_models: Vec, + /// Maximum number of bytes to include from an AGENTS.md project doc file. pub project_doc_max_bytes: Option, @@ -1507,6 +1520,29 @@ pub struct RealtimeAudioToml { pub speaker: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomModelConfig { + /// Provider-facing model slug used on API requests. + pub model: String, + /// Optional context window override applied when this alias is selected. + pub model_context_window: Option, + /// Optional auto-compaction token limit override applied when this alias is selected. + pub model_auto_compact_token_limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct CustomModelToml { + /// User-facing alias shown in the model picker. + pub name: String, + /// Provider-facing model slug used on API requests. + pub model: String, + /// Optional context window override applied when this alias is selected. + pub model_context_window: Option, + /// Optional auto-compaction token limit override applied when this alias is selected. + pub model_auto_compact_token_limit: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ToolsToml { @@ -2306,6 +2342,24 @@ impl Config { for (key, provider) in cfg.model_providers.into_iter() { model_providers.entry(key).or_insert(provider); } + let mut custom_models = HashMap::new(); + for custom in cfg.custom_models { + let alias = custom.name; + if custom_models.contains_key(&alias) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("duplicate custom model alias: {alias}"), + )); + } + custom_models.insert( + alias, + CustomModelConfig { + model: custom.model, + model_context_window: custom.model_context_window, + model_auto_compact_token_limit: custom.model_auto_compact_token_limit, + }, + ); + } let model_provider_id = model_provider .or(config_profile.model_provider) @@ -2623,6 +2677,7 @@ impl Config { mcp_oauth_callback_port: cfg.mcp_oauth_callback_port, mcp_oauth_callback_url: cfg.mcp_oauth_callback_url.clone(), model_providers, + custom_models, project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES), project_doc_fallback_filenames: cfg .project_doc_fallback_filenames diff --git a/codex-rs/core/src/config/schema.rs b/codex-rs/core/src/config/schema.rs index bde38f7eb58..97d87614cd1 100644 --- a/codex-rs/core/src/config/schema.rs +++ b/codex-rs/core/src/config/schema.rs @@ -1,102 +1,25 @@ -use crate::config::ConfigToml; -use codex_config::types::RawMcpServerConfig; -use codex_features::FEATURES; -use codex_features::legacy_feature_keys; +pub use codex_config_schema::config_schema; +pub use codex_config_schema::config_schema_json; +use codex_config_schema::mcp_servers_schema as config_schema_mcp_servers_schema; +pub use codex_config_schema::write_config_schema; use schemars::r#gen::SchemaGenerator; -use schemars::r#gen::SchemaSettings; -use schemars::schema::InstanceType; -use schemars::schema::ObjectValidation; -use schemars::schema::RootSchema; -use schemars::schema::Schema; -use schemars::schema::SchemaObject; -use serde_json::Map; +#[cfg(test)] use serde_json::Value; -use std::path::Path; /// Schema for the `[features]` map with known + legacy keys only. -pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { - let mut object = SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }; - - let mut validation = ObjectValidation::default(); - for feature in FEATURES { - if feature.id == codex_features::Feature::Artifact { - continue; - } - validation - .properties - .insert(feature.key.to_string(), schema_gen.subschema_for::()); - } - for legacy_key in legacy_feature_keys() { - validation - .properties - .insert(legacy_key.to_string(), schema_gen.subschema_for::()); - } - validation.additional_properties = Some(Box::new(Schema::Bool(false))); - object.object = Some(Box::new(validation)); - - Schema::Object(object) +pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> schemars::schema::Schema { + codex_config_schema::features_schema(schema_gen) } /// Schema for the `[mcp_servers]` map using the raw input shape. -pub(crate) fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { - let mut object = SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }; - - let validation = ObjectValidation { - additional_properties: Some(Box::new(schema_gen.subschema_for::())), - ..Default::default() - }; - object.object = Some(Box::new(validation)); - - Schema::Object(object) -} - -/// Build the config schema for `config.toml`. -pub fn config_schema() -> RootSchema { - SchemaSettings::draft07() - .with(|settings| { - settings.option_add_null_type = false; - }) - .into_generator() - .into_root_schema_for::() +pub(crate) fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> schemars::schema::Schema { + config_schema_mcp_servers_schema(schema_gen) } /// Canonicalize a JSON value by sorting its keys. +#[cfg(test)] fn canonicalize(value: &Value) -> Value { - match value { - Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), - Value::Object(map) => { - let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - let mut sorted = Map::with_capacity(map.len()); - for (key, child) in entries { - sorted.insert(key.clone(), canonicalize(child)); - } - Value::Object(sorted) - } - _ => value.clone(), - } -} - -/// Render the config schema as pretty-printed JSON. -pub fn config_schema_json() -> anyhow::Result> { - let schema = config_schema(); - let value = serde_json::to_value(schema)?; - let value = canonicalize(&value); - let json = serde_json::to_vec_pretty(&value)?; - Ok(json) -} - -/// Write the config schema fixture to disk. -pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { - let json = config_schema_json()?; - std::fs::write(out_path, json)?; - Ok(()) + codex_config_schema::canonicalize(value) } #[cfg(test)] diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 8f52bab371b..85a45a8dfc5 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -4,6 +4,7 @@ use crate::api_bridge::map_api_error; use crate::auth_env_telemetry::AuthEnvTelemetry; use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::config::Config; +use crate::config::CustomModelConfig; use crate::error::CodexErr; use crate::error::Result as CoreResult; use crate::model_provider_info::ModelProviderInfo; @@ -29,6 +30,8 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; +use std::collections::HashMap; +use std::collections::HashSet; use std::fmt; use std::path::PathBuf; use std::sync::Arc; @@ -176,6 +179,7 @@ enum CatalogMode { #[derive(Debug)] pub struct ModelsManager { remote_models: RwLock>, + custom_models: HashMap, catalog_mode: CatalogMode, collaboration_modes_config: CollaborationModesConfig, auth_manager: Arc, @@ -194,12 +198,14 @@ impl ModelsManager { codex_home: PathBuf, auth_manager: Arc, model_catalog: Option, + custom_models: HashMap, collaboration_modes_config: CollaborationModesConfig, ) -> Self { Self::new_with_provider( codex_home, auth_manager, model_catalog, + custom_models, collaboration_modes_config, ModelProviderInfo::create_openai_provider(/*base_url*/ None), ) @@ -210,6 +216,7 @@ impl ModelsManager { codex_home: PathBuf, auth_manager: Arc, model_catalog: Option, + custom_models: HashMap, collaboration_modes_config: CollaborationModesConfig, provider: ModelProviderInfo, ) -> Self { @@ -229,6 +236,7 @@ impl ModelsManager { }); Self { remote_models: RwLock::new(remote_models), + custom_models, catalog_mode, collaboration_modes_config, auth_manager, @@ -315,7 +323,7 @@ impl ModelsManager { #[instrument(level = "info", skip(self, config), fields(model = model))] pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo { let remote_models = self.get_remote_models().await; - Self::construct_model_info_from_candidates(model, &remote_models, config) + self.construct_model_info_from_candidates(model, &remote_models, config) } fn find_model_by_longest_prefix(model: &str, candidates: &[ModelInfo]) -> Option { @@ -355,10 +363,41 @@ impl ModelsManager { } fn construct_model_info_from_candidates( + &self, + model: &str, + candidates: &[ModelInfo], + config: &Config, + ) -> ModelInfo { + let custom_model = config + .custom_model_alias(model) + .or_else(|| self.custom_models.get(model)); + Self::construct_model_info_from_candidates_with_custom( + model, + candidates, + config, + custom_model, + ) + } + + fn construct_model_info_from_candidates_with_custom( model: &str, candidates: &[ModelInfo], config: &Config, + custom_model: Option<&CustomModelConfig>, ) -> ModelInfo { + if let Some(custom_model) = custom_model { + let mut config = config.clone(); + config.model_context_window = custom_model + .model_context_window + .or(config.model_context_window); + config.model_auto_compact_token_limit = custom_model + .model_auto_compact_token_limit + .or(config.model_auto_compact_token_limit); + let model_info = + Self::construct_model_info_for_custom_alias(model, custom_model, candidates); + return model_info::with_config_overrides(model_info, &config); + } + // First use the normal longest-prefix match. If that misses, allow a narrowly scoped // retry for namespaced slugs like `custom/gpt-5.3-codex`. let remote = Self::find_model_by_longest_prefix(model, candidates) @@ -375,6 +414,30 @@ impl ModelsManager { model_info::with_config_overrides(model_info, config) } + fn construct_model_info_for_custom_alias( + alias: &str, + custom_model: &CustomModelConfig, + candidates: &[ModelInfo], + ) -> ModelInfo { + let remote = Self::find_model_by_longest_prefix(&custom_model.model, candidates) + .or_else(|| Self::find_model_by_namespaced_suffix(&custom_model.model, candidates)); + if let Some(remote) = remote { + ModelInfo { + slug: alias.to_string(), + request_model: Some(custom_model.model.clone()), + display_name: alias.to_string(), + used_fallback_model_metadata: false, + ..remote + } + } else { + let mut fallback_model = model_info::model_info_from_slug(&custom_model.model); + fallback_model.slug = alias.to_string(); + fallback_model.request_model = Some(custom_model.model.clone()); + fallback_model.display_name = alias.to_string(); + fallback_model + } + } + /// Refresh models if the provided ETag differs from the cached ETag. /// /// Uses `Online` strategy to fetch latest models when ETags differ. @@ -524,12 +587,37 @@ impl ModelsManager { fn build_available_models(&self, mut remote_models: Vec) -> Vec { remote_models.sort_by(|a, b| a.priority.cmp(&b.priority)); - let mut presets: Vec = remote_models.into_iter().map(Into::into).collect(); + let mut presets: Vec = remote_models.iter().cloned().map(Into::into).collect(); + let mut existing_models: HashSet = + presets.iter().map(|preset| preset.model.clone()).collect(); + let mut custom_presets = Vec::new(); + + let mut custom_models = self.custom_models.iter().collect::>(); + custom_models.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (alias, custom) in custom_models { + if existing_models.contains(alias) { + continue; + } + + let model_info = + Self::construct_model_info_for_custom_alias(alias, custom, &remote_models); + let mut preset = ModelPreset::from(model_info); + preset.show_in_picker = true; + custom_presets.push(preset); + existing_models.insert(alias.to_string()); + } + let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt)); presets = ModelPreset::filter_by_auth(presets, chatgpt_mode); + custom_presets = ModelPreset::filter_by_auth(custom_presets, chatgpt_mode); ModelPreset::mark_default_by_picker_visibility(&mut presets); + if !presets.iter().any(|preset| preset.is_default) { + ModelPreset::mark_default_by_picker_visibility(&mut custom_presets); + } + custom_presets.extend(presets); + presets = custom_presets; presets } @@ -551,6 +639,7 @@ impl ModelsManager { codex_home, auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), provider, ) @@ -582,7 +671,12 @@ impl ModelsManager { } else { &[] }; - Self::construct_model_info_from_candidates(model, candidates, config) + Self::construct_model_info_from_candidates_with_custom( + model, + candidates, + config, + config.custom_model_alias(model), + ) } } diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 3e0ca152c79..09fa7258f64 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -238,6 +238,7 @@ async fn get_model_info_tracks_fallback_usage() { codex_home.path().to_path_buf(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); let known_slug = manager @@ -277,6 +278,7 @@ async fn get_model_info_uses_custom_catalog() { Some(ModelsResponse { models: vec![overlay], }), + HashMap::new(), CollaborationModesConfig::default(), ); @@ -309,6 +311,7 @@ async fn get_model_info_matches_namespaced_suffix() { Some(ModelsResponse { models: vec![remote], }), + HashMap::new(), CollaborationModesConfig::default(), ); let namespaced_model = "custom/gpt-image".to_string(); @@ -333,6 +336,7 @@ async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { codex_home.path().to_path_buf(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); let known_slug = manager @@ -853,6 +857,202 @@ fn build_available_models_picks_default_after_hiding_hidden_models() { assert_eq!(available, vec![expected_hidden, expected_visible]); } +#[tokio::test] +async fn get_model_info_uses_custom_alias_metadata_and_request_model() { + let codex_home = tempdir().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + + let alias = "gpt-5.4 1m".to_string(); + let custom_model = CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }; + config + .custom_models + .insert(alias.clone(), custom_model.clone()); + + 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-5.4", "GPT 5.4", /*priority*/ 0)], + }), + HashMap::from([(alias.clone(), custom_model)]), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info(&alias, &config).await; + + assert_eq!(model_info.slug, alias); + assert_eq!(model_info.request_model.as_deref(), Some("gpt-5.4")); + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(800_000)); +} + +#[tokio::test] +async fn get_model_info_prefers_custom_alias_context_over_global_config() { + let codex_home = tempdir().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + + config.model_context_window = Some(250_000); + config.model_auto_compact_token_limit = Some(200_000); + + let alias = "gpt-5.4 1m".to_string(); + let custom_model = CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }; + config + .custom_models + .insert(alias.clone(), custom_model.clone()); + + 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-5.4", "GPT 5.4", /*priority*/ 0)], + }), + HashMap::from([(alias.clone(), custom_model)]), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info(&alias, &config).await; + + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(800_000)); +} + +#[tokio::test] +async fn get_model_info_prefers_active_config_alias_over_startup_snapshot() { + let codex_home = tempdir().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + + let alias = "gpt-5.4 1m".to_string(); + config.custom_models.insert( + alias.clone(), + CustomModelConfig { + model: "gpt-5.4-updated".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + ); + + 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-5.4", "GPT 5.4", /*priority*/ 0), + remote_model("gpt-5.4-updated", "GPT 5.4 Updated", /*priority*/ 1), + ], + }), + HashMap::from([( + alias.clone(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(500_000), + model_auto_compact_token_limit: Some(400_000), + }, + )]), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info(&alias, &config).await; + + assert_eq!(model_info.slug, alias); + assert_eq!(model_info.request_model.as_deref(), Some("gpt-5.4-updated")); + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(900_000)); +} + +#[test] +fn build_available_models_includes_custom_aliases() { + let codex_home = tempdir().expect("temp dir"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let provider = provider_for("http://example.test".to_string()); + let mut manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + manager.custom_models = HashMap::from([( + "gpt-5.4 1m".to_string(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }, + )]); + + let available = manager.build_available_models(vec![remote_model( + "gpt-5.4", "GPT 5.4", /*priority*/ 0, + )]); + let alias = available + .iter() + .find(|preset| preset.model == "gpt-5.4 1m") + .expect("custom alias should be listed"); + + assert!(alias.show_in_picker); + assert_eq!(alias.display_name, "gpt-5.4 1m"); +} + +#[test] +fn build_available_models_lists_custom_aliases_before_remote_models() { + let codex_home = tempdir().expect("temp dir"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let provider = provider_for("http://example.test".to_string()); + let mut manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + manager.custom_models = HashMap::from([( + "gpt-5.4 1m".to_string(), + CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(800_000), + }, + )]); + + let available = manager.build_available_models(vec![ + remote_model("gpt-5.4", "GPT 5.4", /*priority*/ 0), + remote_model("gpt-5.3", "GPT 5.3", /*priority*/ 1), + ]); + + assert_eq!( + available + .iter() + .map(|preset| preset.model.as_str()) + .collect::>(), + vec!["gpt-5.4 1m", "gpt-5.4", "gpt-5.3"] + ); + assert_eq!( + available + .iter() + .find(|preset| preset.is_default) + .map(|preset| preset.model.as_str()), + Some("gpt-5.4") + ); +} + #[test] fn bundled_models_json_roundtrips() { let file_contents = include_str!("../../models.json"); diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 055a6459d46..200aa76b55d 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -62,6 +62,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo { warn!("Unknown model {slug} is used. This will use fallback model metadata."); ModelInfo { slug: slug.to_string(), + request_model: None, display_name: slug.to_string(), description: None, default_reasoning_level: None, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d62094..c2c0f920662 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -8,6 +8,7 @@ use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; use crate::codex_thread::CodexThread; use crate::config::Config; +use crate::config::CustomModelConfig; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::file_watcher::FileWatcher; @@ -31,6 +32,7 @@ use codex_protocol::config_types::CollaborationModeMask; #[cfg(test)] use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; @@ -216,6 +218,8 @@ impl ThreadManager { config: &Config, auth_manager: Arc, session_source: SessionSource, + model_catalog: Option, + custom_models: HashMap, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, ) -> Self { @@ -245,7 +249,8 @@ impl ThreadManager { models_manager: Arc::new(ModelsManager::new_with_provider( codex_home, auth_manager.clone(), - config.model_catalog.clone(), + model_catalog, + custom_models, collaboration_modes_config, openai_models_provider, )), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 62a9aa199fd..9b7720f7822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::UserMessageEvent; use core_test_support::PathExt; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; +use std::collections::HashMap; use std::time::Duration; use tempfile::tempdir; use wiremock::MockServer; @@ -294,6 +295,8 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { &config, auth_manager, SessionSource::Exec, + /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, @@ -424,6 +427,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor &config, auth_manager.clone(), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, @@ -524,6 +529,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { &config, auth_manager.clone(), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, @@ -613,6 +620,8 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ &config, auth_manager.clone(), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d4..c3e01e8abe6 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -486,6 +486,8 @@ impl TestCodexBuilder { &config, codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig::default(), Arc::clone(&environment_manager), ) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc9..767eba23b14 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1059,6 +1059,8 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { &config, auth_manager, SessionSource::Exec, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig { default_mode_request_user_input: config .features diff --git a/codex-rs/core/tests/suite/model_info_overrides.rs b/codex-rs/core/tests/suite/model_info_overrides.rs index 52656affeca..aeb3ec1dde0 100644 --- a/codex-rs/core/tests/suite/model_info_overrides.rs +++ b/codex-rs/core/tests/suite/model_info_overrides.rs @@ -2,8 +2,11 @@ use codex_core::models_manager::collaboration_mode_presets::CollaborationModesCo use codex_core::models_manager::manager::ModelsManager; use codex_login::CodexAuth; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::openai_models::WebSearchToolType; +use codex_protocol::openai_models::default_input_modalities; use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; +use std::collections::HashMap; use tempfile::TempDir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -17,6 +20,7 @@ async fn offline_model_info_without_tool_output_override() { config.codex_home.clone(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); @@ -40,6 +44,7 @@ async fn offline_model_info_with_tool_output_override() { config.codex_home.clone(), auth_manager, /*model_catalog*/ None, + HashMap::new(), CollaborationModesConfig::default(), ); @@ -50,3 +55,68 @@ async fn offline_model_info_with_tool_output_override() { TruncationPolicyConfig::tokens(/*limit*/ 123) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_model_alias_applies_request_model_and_context_overrides() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.custom_models.insert( + "gpt-5.4 1m".to_string(), + codex_core::config::CustomModelConfig { + model: "gpt-5.4".to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + ); + + 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, + Some(codex_protocol::openai_models::ModelsResponse { + models: vec![codex_protocol::openai_models::ModelInfo { + slug: "gpt-5.4".to_string(), + request_model: None, + display_name: "GPT-5.4".to_string(), + description: Some("desc".to_string()), + default_reasoning_level: None, + supported_reasoning_levels: Vec::new(), + shell_type: codex_protocol::openai_models::ConfigShellToolType::ShellCommand, + visibility: codex_protocol::openai_models::ModelVisibility::List, + supported_in_api: true, + priority: 1, + availability_nux: None, + upgrade: None, + base_instructions: "base".to_string(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: codex_protocol::config_types::ReasoningSummary::Auto, + support_verbosity: false, + default_verbosity: None, + supports_search_tool: false, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), + supports_parallel_tool_calls: false, + supports_image_detail_original: false, + context_window: Some(272_000), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + input_modalities: default_input_modalities(), + web_search_tool_type: WebSearchToolType::Text, + used_fallback_model_metadata: false, + }], + }), + config.custom_models.clone(), + CollaborationModesConfig::default(), + ); + + let model_info = manager.get_model_info("gpt-5.4 1m", &config).await; + + assert_eq!(model_info.slug, "gpt-5.4 1m"); + assert_eq!(model_info.request_model.as_deref(), Some("gpt-5.4")); + assert_eq!(model_info.context_window, Some(1_000_000)); + assert_eq!(model_info.auto_compact_token_limit, Some(900_000)); +} diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b661..b553c7c6495 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -68,6 +68,7 @@ fn test_model_info( ) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: display_name.to_string(), description: Some(description.to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -884,6 +885,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< let base_model = ModelInfo { slug: large_model_slug.to_string(), + request_model: None, display_name: "Larger Model".to_string(), description: Some("larger context window model".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f62..9570b41b0d4 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -317,6 +317,7 @@ struct ModelsCache { fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: "Remote Test".to_string(), description: Some("remote model".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73c..14ea86ecb13 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -633,6 +633,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow let friendly_personality_message = "Friendly variant"; let remote_model = ModelInfo { slug: remote_slug.to_string(), + request_model: None, display_name: "Remote default personality test".to_string(), description: Some("Remote model with default personality template".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -749,6 +750,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - let remote_pragmatic_message = "Pragmatic from remote template"; let remote_model = ModelInfo { slug: remote_slug.to_string(), + request_model: None, display_name: "Remote personality test".to_string(), description: Some("Remote model with personality template".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7b..0b197a84dbd 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use anyhow::Result; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; +use codex_core::config::CustomModelConfig; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::manager::RefreshStrategy; use codex_login::CodexAuth; @@ -268,6 +269,79 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_model_alias_sends_base_model_slug() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let alias = "gpt-5.4 1m"; + let base_model = "gpt-5.4"; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let mut builder = test_codex() + .with_model(alias) + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(move |config| { + config.custom_models.insert( + alias.to_string(), + CustomModelConfig { + model: base_model.to_string(), + model_context_window: Some(1_000_000), + model_auto_compact_token_limit: Some(900_000), + }, + ); + config.model_catalog = Some(ModelsResponse { + models: vec![test_remote_model( + base_model, + ModelVisibility::List, + /*priority*/ 1, + )], + }); + }); + + let TestCodex { + codex, + cwd, + config, + session_configured, + .. + } = builder.build(&server).await?; + + assert_eq!(session_configured.model, alias); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "check custom alias model routing".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: alias.to_string(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let body = response_mock.single_request().body_json(); + assert_eq!(body["model"].as_str(), Some(base_model)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { skip_if_no_network!(Ok(())); @@ -280,6 +354,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { let remote_model = ModelInfo { slug: REMOTE_MODEL_SLUG.to_string(), + request_model: None, display_name: "Remote Test".to_string(), description: Some("A remote model that requires the test shell".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -524,6 +599,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { let remote_base = "Use the remote base instructions only."; let remote_model = ModelInfo { slug: model.to_string(), + request_model: None, display_name: "Parallel Remote".to_string(), description: Some("A remote model with custom instructions".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -1004,6 +1080,7 @@ fn test_remote_model_with_policy( ) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: format!("{slug} display"), description: Some(format!("{slug} description")), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab00..d6b9afb7d38 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -394,6 +394,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re ModelsResponse { models: vec![ModelInfo { slug: text_only_model_slug.to_string(), + request_model: None, display_name: "RMCP Text Only".to_string(), description: Some("Test model without image input support".to_string()), default_reasoning_level: None, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 3b1cfa20025..759d0bc8d4f 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -56,6 +56,7 @@ fn test_model_info( ) -> ModelInfo { ModelInfo { slug: slug.to_string(), + request_model: None, display_name: display_name.to_string(), description: Some(description.to_string()), default_reasoning_level: Some(default_reasoning_level), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18b..e5c7fbf871e 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1335,6 +1335,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an let model_slug = "text-only-view-image-test-model"; let text_only_model = ModelInfo { slug: model_slug.to_string(), + request_model: None, display_name: "Text-only view_image test model".to_string(), description: Some("Remote model for view_image unsupported-path coverage".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index a41c6ddcff2..11e15d5e9c6 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -65,6 +65,8 @@ impl MessageProcessor { config.as_ref(), auth_manager, SessionSource::Mcp, + config.model_catalog.clone(), + config.custom_models.clone(), CollaborationModesConfig { default_mode_request_user_input: config .features diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 3c633cbd889..4a9fd8dcebd 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -243,6 +243,11 @@ const fn default_effective_context_window_percent() -> i64 { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInfo { pub slug: String, + /// Provider-facing model slug to send on API requests. + /// + /// When unset, `slug` is used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_model: Option, pub display_name: String, pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -294,6 +299,10 @@ pub struct ModelInfo { } impl ModelInfo { + pub fn request_model_slug(&self) -> &str { + self.request_model.as_deref().unwrap_or(self.slug.as_str()) + } + pub fn auto_compact_token_limit(&self) -> Option { let context_limit = self .context_window @@ -519,6 +528,7 @@ mod tests { fn test_model(spec: Option) -> ModelInfo { ModelInfo { slug: "test-model".to_string(), + request_model: None, display_name: "Test Model".to_string(), description: None, default_reasoning_level: None, diff --git a/docs/config.md b/docs/config.md index 71f3548debb..909157699cc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -88,4 +88,20 @@ developer message Codex inserts when realtime becomes active. It only affects the realtime start message in prompt history and does not change websocket backend prompt settings or the realtime end/inactive message. +## Custom model aliases + +You can add aliases to the model picker via `custom_models` in `~/.codex/config.toml`. +Each entry maps a user-facing alias to a provider-facing model slug and can override context settings: + +```toml +[[custom_models]] +name = "gpt-5.4 1m" +model = "gpt-5.4" +model_context_window = 1000000 +model_auto_compact_token_limit = 900000 +``` + +When selected, Codex sends `model = "gpt-5.4"` to the backend while using your +alias-specific context overrides for that session. + Ctrl+C/Ctrl+D quitting uses a ~1 second double-press hint (`ctrl + c again to quit`). diff --git a/justfile b/justfile index 7dc6ae1005a..8086419fc64 100644 --- a/justfile +++ b/justfile @@ -88,7 +88,7 @@ mcp-server-run *args: # Regenerate the json schema for config.toml from the current config types. write-config-schema: - cargo run -p codex-core --bin codex-write-config-schema + cargo run -p codex-config-schema --bin codex-write-config-schema # Regenerate vendored app-server protocol schema artifacts. write-app-server-schema *args: diff --git a/patches/aws-lc-sys_windows_msvc_memcmp_probe.patch b/patches/aws-lc-sys_windows_msvc_memcmp_probe.patch index d244e9418fc..3cc468e4069 100644 --- a/patches/aws-lc-sys_windows_msvc_memcmp_probe.patch +++ b/patches/aws-lc-sys_windows_msvc_memcmp_probe.patch @@ -17,7 +17,7 @@ diff --git a/builder/cc_builder.rs b/builder/cc_builder.rs + emit_warning("Skipping memcmp probe for Bazel windows-msvc build scripts."); + return; + } - + let basename = "memcmp_invalid_stripped_check"; let exec_path = out_dir().join(basename); let memcmp_build = cc::Build::default(); @@ -30,7 +30,7 @@ diff --git a/builder/cc_builder.rs b/builder/cc_builder.rs memcmp_compile_args.push(flag.into()); } } - + - if let Some(execroot) = Self::bazel_execroot(self.manifest_dir.as_path()) { + if let Some(execroot) = bazel_execroot { // In Bazel build-script sandboxes, `cc` can pass `bazel-out/...` args diff --git a/patches/aws-lc-sys_windows_msvc_prebuilt_nasm.patch b/patches/aws-lc-sys_windows_msvc_prebuilt_nasm.patch index 37f334b8bdf..7cac18c4020 100644 --- a/patches/aws-lc-sys_windows_msvc_prebuilt_nasm.patch +++ b/patches/aws-lc-sys_windows_msvc_prebuilt_nasm.patch @@ -18,7 +18,7 @@ diff --git a/builder/main.rs b/builder/main.rs + .components() + .any(|component| component.as_os_str() == "bazel-out") } - + fn use_prebuilt_nasm() -> bool { + let use_prebuilt_for_bazel_windows_msvc = is_bazel_windows_msvc_build_script(); target_os() == "windows" @@ -31,7 +31,7 @@ diff --git a/builder/main.rs b/builder/main.rs + && (use_prebuilt_for_bazel_windows_msvc || !test_nasm_command()) && (Some(true) == allow_prebuilt_nasm() || is_prebuilt_nasm()) } - + fn allow_prebuilt_nasm() -> Option { diff --git a/builder/nasm_builder.rs b/builder/nasm_builder.rs --- a/builder/nasm_builder.rs @@ -40,7 +40,7 @@ diff --git a/builder/nasm_builder.rs b/builder/nasm_builder.rs if self.files.is_empty() { return vec![]; } - + - if test_nasm_command() { + if test_nasm_command() && !use_prebuilt_nasm() { for src in &self.files { diff --git a/patches/rules_rust_repository_set_exec_constraints.patch b/patches/rules_rust_repository_set_exec_constraints.patch index 31afae4f7a5..43cca621a40 100644 --- a/patches/rules_rust_repository_set_exec_constraints.patch +++ b/patches/rules_rust_repository_set_exec_constraints.patch @@ -15,7 +15,7 @@ diff --git a/rust/extensions.bzl b/rust/extensions.bzl "extra_target_triples": {repository_set.target_triple: [str(v) for v in repository_set.target_compatible_with]}, "name": repository_set.name, @@ -166,6 +167,9 @@ _COMMON_TAG_KWARGS = { - + _RUST_REPOSITORY_SET_TAG_ATTRS = { + "exec_compatible_with": attr.label_list( + doc = "Execution platform constraints for this repository_set.", diff --git a/patches/rules_rust_windows_bootstrap_process_wrapper_linker.patch b/patches/rules_rust_windows_bootstrap_process_wrapper_linker.patch index 9a978b8be6e..d9753761ab4 100644 --- a/patches/rules_rust_windows_bootstrap_process_wrapper_linker.patch +++ b/patches/rules_rust_windows_bootstrap_process_wrapper_linker.patch @@ -1,9 +1,9 @@ --- a/rust/private/rustc.bzl +++ b/rust/private/rustc.bzl -@@ -472,7 +472,19 @@ +@@ -472,6 +472,18 @@ ) ld_is_direct_driver = False - + - if not ld or toolchain.linker_preference == "rust": + # The bootstrap process wrapper is built without the normal rules_rust + # process wrapper. On Windows nightly toolchains that expose rust-lld, the @@ -20,4 +20,3 @@ + if not ld or toolchain.linker_preference == "rust" or use_bootstrap_rust_linker: ld = toolchain.linker.path ld_is_direct_driver = toolchain.linker_type == "direct" - diff --git a/patches/rules_rust_windows_exec_bin_target.patch b/patches/rules_rust_windows_exec_bin_target.patch index e4cf306dff0..7810ff46d5c 100644 --- a/patches/rules_rust_windows_exec_bin_target.patch +++ b/patches/rules_rust_windows_exec_bin_target.patch @@ -13,7 +13,7 @@ diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl @@ -129,6 +129,20 @@ build_setting = config.bool(flag = True), ) - + -def _get_rustc_env(attr, toolchain, crate_name): +def _effective_target_arch(toolchain, use_exec_target): + return toolchain.exec_triple.arch if use_exec_target else toolchain.target_arch @@ -31,9 +31,9 @@ diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl + +def _get_rustc_env(attr, toolchain, crate_name, use_exec_target = False): """Gathers rustc environment variables - + @@ -147,6 +161,6 @@ - + result = { - "CARGO_CFG_TARGET_ARCH": "" if toolchain.target_arch == None else toolchain.target_arch, - "CARGO_CFG_TARGET_OS": "" if toolchain.target_os == None else toolchain.target_os, @@ -44,15 +44,15 @@ diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl @@ -997,9 +1011,11 @@ if build_metadata and not use_json_output: fail("build_metadata requires parse_json_output") - + + use_exec_target = is_exec_configuration(ctx) and crate_info.type == "bin" + output_dir = getattr(crate_info.output, "dirname", None) linker_script = getattr(file, "linker_script", None) - + - env = _get_rustc_env(attr, toolchain, crate_info.name) + env = _get_rustc_env(attr, toolchain, crate_info.name, use_exec_target) - + # Wrapper args first @@ -1138,5 +1154,5 @@ if error_format != "json": @@ -64,7 +64,7 @@ diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl @@ -1144,6 +1160,6 @@ if linker_script: rustc_flags.add(linker_script, format = "--codegen=link-arg=-T%s") - + # Tell Rustc where to find the standard library (or libcore) - rustc_flags.add_all(toolchain.rust_std_paths, before_each = "-L", format_each = "%s") + rustc_flags.add_all(_effective_rust_std_paths(toolchain, use_exec_target), before_each = "-L", format_each = "%s") diff --git a/patches/rules_rust_windows_exec_msvc_build_script_env.patch b/patches/rules_rust_windows_exec_msvc_build_script_env.patch index c78dba43ccb..0a60cfcebc8 100644 --- a/patches/rules_rust_windows_exec_msvc_build_script_env.patch +++ b/patches/rules_rust_windows_exec_msvc_build_script_env.patch @@ -6,7 +6,7 @@ diff --git a/cargo/private/cargo_build_script.bzl b/cargo/private/cargo_build_sc """Translate GNU-flavored cc args when exec-side build scripts target Windows MSVC.""" if toolchain.target_flag_value != toolchain.exec_triple.str or not toolchain.exec_triple.str.endswith("-pc-windows-msvc"): return args - + - rewritten = [] - skip_next = False - for arg in args: @@ -24,22 +24,22 @@ diff --git a/cargo/private/cargo_build_script.bzl b/cargo/private/cargo_build_sc + if skip_next: + skip_next = False + continue - + if arg == "-target": - skip_next = True + skip_next = True continue - + if arg.startswith("-target=") or arg.startswith("--target="): continue - + if arg == "-nostdlibinc" or arg.startswith("--sysroot"): continue - + - if "mingw-w64-" in arg or "mingw_import_libraries_directory" in arg or "mingw_crt_library_search_directory" in arg: + if arg.startswith("-fstack-protector") or arg.startswith("-D_FORTIFY_SOURCE="): continue - + - if arg.startswith("-fstack-protector"): - continue - @@ -50,9 +50,9 @@ diff --git a/cargo/private/cargo_build_script.bzl b/cargo/private/cargo_build_sc + if "mingw-w64-" in path or "mingw_import_libraries_directory" in path or "mingw_crt_library_search_directory" in path: + skip_next = True + continue - + rewritten.append(arg) - + - return [ - "-target", - toolchain.target_flag_value, @@ -98,7 +98,7 @@ diff --git a/cargo/private/cargo_build_script.bzl b/cargo/private/cargo_build_sc + rewritten.append(arg) + + return rewritten - + def get_cc_compile_args_and_env(cc_toolchain, feature_configuration): """Gather cc environment variables from the given `cc_toolchain` @@ -509,6 +550,7 @@ def _construct_build_script_env( @@ -107,5 +107,5 @@ diff --git a/cargo/private/cargo_build_script.bzl b/cargo/private/cargo_build_sc env["LD"] = linker + link_args = _rewrite_windows_exec_msvc_link_args(toolchain, link_args) env["LDFLAGS"] = " ".join(_pwd_flags(link_args)) - + # Defaults for cxx flags. diff --git a/patches/rules_rust_windows_exec_std.patch b/patches/rules_rust_windows_exec_std.patch index aa20ee0e51b..2373e14bc69 100644 --- a/patches/rules_rust_windows_exec_std.patch +++ b/patches/rules_rust_windows_exec_std.patch @@ -16,7 +16,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl rustfmt = None, linker = None): @@ -312,7 +313,15 @@ def _generate_sysroot( - + # Made available to support $(location) expansion in stdlib_linkflags and extra_rustc_flags. transitive_file_sets.append(depset(ctx.files.rust_std)) + @@ -24,7 +24,7 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl + if exec_rust_std: + sysroot_exec_rust_std = _symlink_sysroot_tree(ctx, name, exec_rust_std) + transitive_file_sets.extend([sysroot_exec_rust_std]) - + + # Made available to support $(location) expansion in extra_exec_rustc_flags. + transitive_file_sets.append(depset(ctx.files.exec_rust_std)) + @@ -49,10 +49,10 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl rustc_lib = sysroot_rustc_lib, @@ -410,12 +421,14 @@ def _rust_toolchain_impl(ctx): ) - + rust_std = ctx.attr.rust_std + exec_rust_std = ctx.attr.exec_rust_std if ctx.attr.exec_rust_std else rust_std - + sysroot = _generate_sysroot( ctx = ctx, rustc = ctx.file.rustc, @@ -63,12 +63,12 @@ diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl rustfmt = ctx.file.rustfmt, clippy = ctx.file.clippy_driver, @@ -452,7 +465,7 @@ def _rust_toolchain_impl(ctx): - + expanded_stdlib_linkflags = _expand_flags(ctx, "stdlib_linkflags", rust_std[rust_common.stdlib_info].srcs, make_variables) expanded_extra_rustc_flags = _expand_flags(ctx, "extra_rustc_flags", rust_std[rust_common.stdlib_info].srcs, make_variables) - expanded_extra_exec_rustc_flags = _expand_flags(ctx, "extra_exec_rustc_flags", rust_std[rust_common.stdlib_info].srcs, make_variables) + expanded_extra_exec_rustc_flags = _expand_flags(ctx, "extra_exec_rustc_flags", exec_rust_std[rust_common.stdlib_info].srcs, make_variables) - + linking_context = cc_common.create_linking_context( linker_inputs = depset([ @@ -793,6 +806,10 @@ rust_toolchain = rule( @@ -123,13 +123,13 @@ diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl @@ -1011,7 +1011,10 @@ def construct_arguments( if build_metadata and not use_json_output: fail("build_metadata requires parse_json_output") - + - use_exec_target = is_exec_configuration(ctx) and crate_info.type == "bin" + # Exec-configuration crates (build scripts, proc-macros, and their + # dependencies) must all target the exec triple so they can link against + # each other and the exec-side standard library. + use_exec_target = is_exec_configuration(ctx) - + output_dir = getattr(crate_info.output, "dirname", None) linker_script = getattr(file, "linker_script", None) diff --git a/rust/repositories.bzl b/rust/repositories.bzl @@ -138,7 +138,7 @@ diff --git a/rust/repositories.bzl b/rust/repositories.bzl @@ -536,6 +536,18 @@ def _rust_toolchain_tools_repository_impl(ctx): build_components.append(rust_stdlib_content) sha256s.update(rust_stdlib_sha256) - + + exec_rust_std_label = None + if exec_triple.str != target_triple.str: + exec_rust_stdlib_content, exec_rust_stdlib_sha256 = load_rust_stdlib( diff --git a/patches/rules_rust_windows_msvc_direct_link_args.patch b/patches/rules_rust_windows_msvc_direct_link_args.patch index 2f04f8ca899..18bd25d5205 100644 --- a/patches/rules_rust_windows_msvc_direct_link_args.patch +++ b/patches/rules_rust_windows_msvc_direct_link_args.patch @@ -3,7 +3,7 @@ @@ -2305,7 +2305,7 @@ return crate.metadata.dirname return crate.output.dirname - + -def _portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, for_windows = False, for_darwin = False, flavor_msvc = False): +def _portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, for_windows = False, for_darwin = False, flavor_msvc = False, use_direct_driver = False): artifact = get_preferred_artifact(lib, use_pic) @@ -18,7 +18,7 @@ + return [ + "-Clink-arg={}".format(artifact.path), + ] - + if flavor_msvc: return [ @@ -2381,7 +2386,7 @@ @@ -27,7 +27,7 @@ get_lib_name = get_lib_name_for_windows if flavor_msvc else get_lib_name_default - ret.extend(_portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, flavor_msvc = flavor_msvc)) + ret.extend(_portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, flavor_msvc = flavor_msvc, use_direct_driver = use_direct_driver)) - + # Windows toolchains can inherit POSIX defaults like -pthread from C deps, # which fails to link with the MinGW/LLD toolchain. Drop them here. @@ -2558,17 +2563,25 @@ @@ -59,6 +59,6 @@ + map_each = get_lib_name, + format_each = "-lstatic=%s", + ) - + def _get_dirname(file): """A helper function for `_add_native_link_flags`.