diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ea1eec8f83..cfb4669747 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1302,7 +1302,7 @@ dependencies = [ "allocative", "anyhow", "clap", - "derive_more 2.0.1", + "derive_more 2.1.0", "env_logger", "log", "multimap", @@ -1483,6 +1483,7 @@ name = "codex-process-hardening" version = "0.0.0" dependencies = [ "libc", + "pretty_assertions", ] [[package]] @@ -1593,7 +1594,7 @@ dependencies = [ "codex-windows-sandbox", "color-eyre", "crossterm", - "derive_more 2.0.1", + "derive_more 2.1.0", "diffy", "dirs", "dunce", @@ -1795,9 +1796,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -2136,11 +2137,11 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ - "derive_more-impl 2.0.1", + "derive_more-impl 2.1.0", ] [[package]] @@ -2158,13 +2159,14 @@ dependencies = [ [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.10.0", "proc-macro2", "quote", + "rustc_version", "syn 2.0.104", "unicode-xid", ] @@ -2561,7 +2563,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3398,9 +3400,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.2" +version = "1.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" dependencies = [ "console", "once_cell", @@ -3464,7 +3466,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5252,7 +5254,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6937,9 +6939,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ts-rs" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "serde_json", "thiserror 2.0.17", @@ -6949,9 +6951,9 @@ dependencies = [ [[package]] name = "ts-rs-macros" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", @@ -7407,9 +7409,9 @@ dependencies = [ [[package]] name = "wildmatch" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] name = "winapi" @@ -7433,7 +7435,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0587ec1d46..2086fbe897 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -139,7 +139,7 @@ icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" image = { version = "^0.25.9", default-features = false } indexmap = "2.12.0" -insta = "1.43.2" +insta = "1.44.3" itertools = "0.14.0" keyring = { version = "3.6", default-features = false } landlock = "0.4.1" @@ -223,7 +223,7 @@ vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" which = "6" -wildmatch = "2.5.0" +wildmatch = "2.6.1" wiremock = "0.6" zeroize = "1.8.2" diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index fec8d7f292..39f7b30c32 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -181,12 +181,13 @@ mod tests { "display_name": "gpt-test", "description": "desc", "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium", "high"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 99, 0], "supported_in_api": true, - "priority": 1 + "priority": 1, + "upgrade": null, })) .unwrap(), ], diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3b4077f534..fff9c53f7a 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -10,6 +10,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use http::HeaderMap; use http::Method; use wiremock::Mock; @@ -57,15 +58,25 @@ async fn models_client_hits_models_endpoint() { description: Some("desc".to_string()), default_reasoning_level: ReasoningEffort::Medium, supported_reasoning_levels: vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: ReasoningEffort::Low.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: ReasoningEffort::High.to_string(), + }, ], shell_type: ConfigShellToolType::ShellCommand, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, }], }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 89435ee6d0..c33904e2fd 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -536,7 +536,7 @@ impl Session { for (alias, feature) in config.features.legacy_feature_usages() { let canonical = feature.key(); - let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead."); + let summary = format!("`{alias}` is deprecated. Use `[features].{canonical}` instead."); let details = if alias == canonical { None } else { @@ -1470,6 +1470,16 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv let mut previous_context: Option> = Some(sess.new_turn(SessionSettingsUpdate::default()).await); + if config.features.enabled(Feature::RemoteModels) + && let Err(err) = sess + .services + .models_manager + .refresh_available_models(&config.model_provider) + .await + { + error!("failed to refresh available models: {err}"); + } + // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1d775360c4..69442815e7 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -54,6 +54,8 @@ pub enum Feature { WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) RemoteCompaction, + /// Refresh remote models and emit AppReady once the list is available. + RemoteModels, /// Allow model to call multiple tools in parallel (only for models supporting it). ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). @@ -333,6 +335,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, + FeatureSpec { + id: Feature::RemoteModels, + key: "remote_models", + stage: Stage::Experimental, + default_enabled: false, + }, FeatureSpec { id: Feature::ParallelToolCalls, key: "parallel", diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 6ee18ad9e3..507e1a48d9 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -291,6 +291,7 @@ mod tests { use super::*; use codex_protocol::openai_models::ClientVersion; use codex_protocol::openai_models::ModelVisibility; + use codex_protocol::openai_models::ReasoningEffortPreset; fn remote(slug: &str, effort: ReasoningEffort, shell: ConfigShellToolType) -> ModelInfo { ModelInfo { @@ -298,12 +299,16 @@ mod tests { display_name: slug.to_string(), description: Some(format!("{slug} desc")), default_reasoning_level: effort, - supported_reasoning_levels: vec![effort], + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort, + description: effort.to_string(), + }], shell_type: shell, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, } } diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 22edf04ffe..55c11f4554 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -36,7 +36,6 @@ impl ModelsManager { } } - // do not use this function yet. It's work in progress. pub async fn refresh_available_models( &self, provider: &ModelProviderInfo, @@ -47,16 +46,21 @@ impl ModelsManager { let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); + let mut client_version = env!("CARGO_PKG_VERSION"); + if client_version == "0.0.0" { + client_version = "99.99.99"; + } let response = client - .list_models(env!("CARGO_PKG_VERSION"), HeaderMap::new()) + .list_models(client_version, HeaderMap::new()) .await .map_err(map_api_error)?; let models = response.models; *self.remote_models.write().await = models.clone(); + let available_models = self.build_available_models().await; { let mut available_models_guard = self.available_models.write().await; - *available_models_guard = self.build_available_models().await; + *available_models_guard = available_models; } Ok(models) } @@ -75,8 +79,11 @@ impl ModelsManager { async fn build_available_models(&self) -> Vec { let mut available_models = self.remote_models.read().await.clone(); available_models.sort_by(|a, b| b.priority.cmp(&a.priority)); - let mut model_presets: Vec = - available_models.into_iter().map(Into::into).collect(); + let mut model_presets: Vec = available_models + .into_iter() + .map(Into::into) + .filter(|preset: &ModelPreset| preset.show_in_picker) + .collect(); if let Some(default) = model_presets.first_mut() { default.is_default = true; } @@ -103,12 +110,13 @@ mod tests { "display_name": display, "description": format!("{display} desc"), "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 1, 0], "supported_in_api": true, - "priority": priority + "priority": priority, + "upgrade": null, })) .expect("valid model") } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e72becd882..89a71b0edc 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -804,10 +804,16 @@ pub(crate) fn create_tools_json_for_chat_completions_api( } if let Some(map) = tool.as_object_mut() { + let name = map + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); // Remove "type" field as it is not needed in chat completions. map.remove("type"); Some(json!({ "type": "function", + "name": name, "function": map, })) } else { @@ -2083,4 +2089,58 @@ Examples of valid command strings: }) ); } + + #[test] + fn chat_tools_include_top_level_name() { + let mut properties = BTreeMap::new(); + properties.insert("foo".to_string(), JsonSchema::String { description: None }); + let tools = vec![ToolSpec::Function(ResponsesApiTool { + name: "demo".to_string(), + description: "A demo tool".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: None, + }, + })]; + + let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); + assert_eq!( + responses_json, + vec![json!({ + "type": "function", + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + })] + ); + + let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap(); + + assert_eq!( + tools_json, + vec![json!({ + "type": "function", + "name": "demo", + "function": { + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + } + })] + ); + } } diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 4e240f0a07..bab715ebd8 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -36,7 +36,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() let DeprecationNoticeEvent { summary, details } = notice; assert_eq!( summary, - "`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead." + "`use_experimental_unified_exec_tool` is deprecated. Use `[features].unified_exec` instead." .to_string(), ); assert_eq!( diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e2d78004a5..2112cbb7aa 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -41,6 +41,7 @@ mod otel; mod prompt_caching; mod quota_exceeded; mod read_file; +mod remote_models; mod resume; mod review; mod rmcp_client; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs new file mode 100644 index 0000000000..4178ed1c2a --- /dev/null +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -0,0 +1,183 @@ +#![cfg(not(target_os = "windows"))] +// unified exec is not supported on Windows OS +use std::sync::Arc; + +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use serde_json::json; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use wiremock::BodyPrintLimit; +use wiremock::MockServer; + +const REMOTE_MODEL_SLUG: &str = "codex-test"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let remote_model = ModelInfo { + slug: REMOTE_MODEL_SLUG.to_string(), + display_name: "Remote Test".to_string(), + description: Some("A remote model that requires the test shell".to_string()), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + upgrade: None, + }; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::RemoteModels); + config.model = "gpt-5.1".to_string(); + }); + + let TestCodex { + codex, + cwd, + config, + conversation_manager, + .. + } = builder.build(&server).await?; + + let models_manager = conversation_manager.get_models_manager(); + let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; + + assert_eq!(available_model.model, REMOTE_MODEL_SLUG); + + let requests = models_mock.requests(); + assert_eq!( + requests.len(), + 1, + "expected a single /models refresh request for the remote models feature" + ); + assert_eq!(requests[0].url.path(), "/v1/models"); + + let family = models_manager + .construct_model_family(REMOTE_MODEL_SLUG, &config) + .await; + assert_eq!(family.shell_type, ConfigShellToolType::UnifiedExec); + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(REMOTE_MODEL_SLUG.to_string()), + effort: None, + summary: None, + }) + .await?; + + let call_id = "call"; + let args = json!({ + "cmd": "/bin/echo call", + "yield_time_ms": 250, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run call".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REMOTE_MODEL_SLUG.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin_event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), + _ => None, + }) + .await; + + assert_eq!(begin_event.source, ExecCommandSource::UnifiedExecStartup); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + Ok(()) +} + +async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(model) = { + let guard = manager.available_models.read().await; + guard.iter().find(|model| model.model == slug).cloned() + } { + return model; + } + if Instant::now() >= deadline { + panic!("timed out waiting for the remote model {slug} to appear"); + } + sleep(Duration::from_millis(25)).await; + } +} diff --git a/codex-rs/process-hardening/Cargo.toml b/codex-rs/process-hardening/Cargo.toml index 2a867572df..7cc88ed608 100644 --- a/codex-rs/process-hardening/Cargo.toml +++ b/codex-rs/process-hardening/Cargo.toml @@ -13,3 +13,6 @@ workspace = true [dependencies] libc = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/process-hardening/src/lib.rs b/codex-rs/process-hardening/src/lib.rs index 772647671f..71c3cee0e0 100644 --- a/codex-rs/process-hardening/src/lib.rs +++ b/codex-rs/process-hardening/src/lib.rs @@ -1,3 +1,9 @@ +#[cfg(unix)] +use std::ffi::OsString; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; + /// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform /// various process hardening steps, such as /// - disabling core dumps @@ -51,15 +57,7 @@ pub(crate) fn pre_main_hardening_linux() { // Official Codex releases are MUSL-linked, which means that variables such // as LD_PRELOAD are ignored anyway, but just to be sure, clear them here. - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { @@ -73,15 +71,7 @@ pub(crate) fn pre_main_hardening_bsd() { // FreeBSD/OpenBSD: set RLIMIT_CORE to 0 and clear LD_* env vars set_core_file_size_limit_to_zero(); - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { std::env::remove_var(key); @@ -106,15 +96,7 @@ pub(crate) fn pre_main_hardening_macos() { // Remove all DYLD_ environment variables, which can be used to subvert // library loading. - let dyld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("DYLD_") { - Some(key) - } else { - None - } - }) - .collect(); + let dyld_keys = env_keys_with_prefix(std::env::vars_os(), b"DYLD_"); for key in dyld_keys { unsafe { @@ -144,3 +126,55 @@ fn set_core_file_size_limit_to_zero() { pub(crate) fn pre_main_hardening_windows() { // TODO(mbolin): Perform the appropriate configuration for Windows. } + +#[cfg(unix)] +fn env_keys_with_prefix(vars: I, prefix: &[u8]) -> Vec +where + I: IntoIterator, +{ + vars.into_iter() + .filter_map(|(key, _)| { + key.as_os_str() + .as_bytes() + .starts_with(prefix) + .then_some(key) + }) + .collect() +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::ffi::OsStringExt; + + #[test] + fn env_keys_with_prefix_handles_non_utf8_entries() { + let non_utf8_key = OsString::from_vec(vec![b'L', b'D', b'_', 0xF0]); + assert!(non_utf8_key.clone().into_string().is_err()); + let value = OsString::from_vec(vec![0xF0, 0x9F, 0x92, 0xA9]); + + let keys = env_keys_with_prefix(vec![(non_utf8_key.clone(), value)], b"LD_"); + assert_eq!( + keys, + vec![non_utf8_key], + "non-UTF-8 env entries with LD_ prefix should be retained" + ); + } + + #[test] + fn env_keys_with_prefix_filters_only_matching_keys() { + let ld_test_var = OsStr::from_bytes(b"LD_TEST"); + let vars = vec![ + (OsString::from("PATH"), OsString::from("/usr/bin")), + (ld_test_var.to_os_string(), OsString::from("1")), + (OsString::from("DYLD_FOO"), OsString::from("bar")), + ]; + + let keys = env_keys_with_prefix(vars, b"LD_"); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].as_os_str(), ld_test_var); + } +} diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 02d50627ca..0804811a3f 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; use ts_rs::TS; @@ -36,7 +37,7 @@ pub enum ReasoningEffort { } /// A reasoning effort option that can be surfaced for a model. -#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] pub struct ReasoningEffortPreset { /// Effort level that the model supports. pub effort: ReasoningEffort, @@ -123,7 +124,7 @@ pub struct ModelInfo { #[serde(default)] pub description: Option, pub default_reasoning_level: ReasoningEffort, - pub supported_reasoning_levels: Vec, + pub supported_reasoning_levels: Vec, pub shell_type: ConfigShellToolType, #[serde(default = "default_visibility")] pub visibility: ModelVisibility, @@ -132,6 +133,8 @@ pub struct ModelInfo { pub supported_in_api: bool, #[serde(default)] pub priority: i32, + #[serde(default)] + pub upgrade: Option, } /// Response wrapper for `/models`. @@ -149,22 +152,57 @@ impl From for ModelPreset { fn from(info: ModelInfo) -> Self { ModelPreset { id: info.slug.clone(), - model: info.slug, + model: info.slug.clone(), display_name: info.display_name, description: info.description.unwrap_or_default(), default_reasoning_effort: info.default_reasoning_level, - supported_reasoning_efforts: info - .supported_reasoning_levels - .into_iter() - .map(|level| ReasoningEffortPreset { - effort: level, - // todo: add description for each reasoning effort - description: level.to_string(), - }) - .collect(), + supported_reasoning_efforts: info.supported_reasoning_levels.clone(), is_default: false, // default is the highest priority available model - upgrade: None, // no upgrade available (todo: think about it) + upgrade: info.upgrade.as_ref().map(|upgrade_slug| ModelUpgrade { + id: upgrade_slug.clone(), + reasoning_effort_mapping: reasoning_effort_mapping_from_presets( + &info.supported_reasoning_levels, + ), + migration_config_key: info.slug.clone(), + }), show_in_picker: info.visibility == ModelVisibility::List, } } } + +fn reasoning_effort_mapping_from_presets( + presets: &[ReasoningEffortPreset], +) -> Option> { + if presets.is_empty() { + return None; + } + + // Map every canonical effort to the closest supported effort for the new model. + let supported: Vec = presets.iter().map(|p| p.effort).collect(); + let mut map = HashMap::new(); + for effort in ReasoningEffort::iter() { + let nearest = nearest_effort(effort, &supported); + map.insert(effort, nearest); + } + Some(map) +} + +fn effort_rank(effort: ReasoningEffort) -> i32 { + match effort { + ReasoningEffort::None => 0, + ReasoningEffort::Minimal => 1, + ReasoningEffort::Low => 2, + ReasoningEffort::Medium => 3, + ReasoningEffort::High => 4, + ReasoningEffort::XHigh => 5, + } +} + +fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { + let target_rank = effort_rank(target); + supported + .iter() + .copied() + .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) + .unwrap_or(target) +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 225a622dcc..89b5fd315a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1348,7 +1348,7 @@ pub struct ReviewLineRange { pub end: u32, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ExecCommandSource { Agent, diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 1f93fd9a4f..cbce1f1bb0 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -292,7 +292,9 @@ fn gpt_5_1_codex_max_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1-codex-max".cyan().underlined(), + "https://openai.com/index/gpt-5-1-codex-max/" + .cyan() + .underlined(), ".".into(), ]), ], @@ -312,7 +314,7 @@ fn gpt5_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1".cyan().underlined(), + "https://openai.com/index/gpt-5-1/".cyan().underlined(), ".".into(), ]), Line::from(vec!["Press enter to continue".dim()]), diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap index 5b3136803f..1f95142169 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap @@ -9,7 +9,7 @@ expression: terminal.backend() than its predecessors and capable of long-running project-scale work. - Learn more at www.openai.com/index/gpt-5-1-codex-max. + Learn more at https://openai.com/index/gpt-5-1-codex-max/. Choose how you'd like Codex to proceed. diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap index 5a0ccd9b5b..52718e5793 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap index 5a0ccd9b5b..52718e5793 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap index 5a0ccd9b5b..52718e5793 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/docs/config.md b/docs/config.md index 0ba711f02c..08ff2aa349 100644 --- a/docs/config.md +++ b/docs/config.md @@ -350,6 +350,8 @@ Though using this option may also be necessary if you try to use Codex in enviro ### tools.\* +These `[tools]` configuration options are deprecated. Use `[features]` instead (see [Feature flags](#feature-flags)). + Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default: ```toml @@ -358,8 +360,6 @@ web_search = true # allow Codex to issue first-party web searches without prom view_image = false # disable image uploads (they're enabled by default) ``` -`web_search` is deprecated; use the `web_search_request` feature flag instead. - The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow. ### approval_presets @@ -615,12 +615,12 @@ Set `otel.exporter` to control where events go: endpoint, protocol, and headers your collector expects: ```toml - [otel] - exporter = { otlp-http = { - endpoint = "https://otel.example.com/v1/logs", - protocol = "binary", - headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } - }} + [otel.exporter."otlp-http"] + endpoint = "https://otel.example.com/v1/logs" + protocol = "binary" + + [otel.exporter."otlp-http".headers] + "x-otlp-api-key" = "${OTLP_TOKEN}" ``` - `otlp-grpc` – streams OTLP log records over gRPC. Provide the endpoint and any @@ -628,27 +628,24 @@ Set `otel.exporter` to control where events go: ```toml [otel] - exporter = { otlp-grpc = { - endpoint = "https://otel.example.com:4317", - headers = { "x-otlp-meta" = "abc123" } - }} + exporter = { otlp-grpc = {endpoint = "https://otel.example.com:4317",headers = { "x-otlp-meta" = "abc123" }}} ``` Both OTLP exporters accept an optional `tls` block so you can trust a custom CA or enable mutual TLS. Relative paths are resolved against `~/.codex/`: ```toml -[otel] -exporter = { otlp-http = { - endpoint = "https://otel.example.com/v1/logs", - protocol = "binary", - headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }, - tls = { - ca-certificate = "certs/otel-ca.pem", - client-certificate = "/etc/codex/certs/client.pem", - client-private-key = "/etc/codex/certs/client-key.pem", - } -}} +[otel.exporter."otlp-http"] +endpoint = "https://otel.example.com/v1/logs" +protocol = "binary" + +[otel.exporter."otlp-http".headers] +"x-otlp-api-key" = "${OTLP_TOKEN}" + +[otel.exporter."otlp-http".tls] +ca-certificate = "certs/otel-ca.pem" +client-certificate = "/etc/codex/certs/client.pem" +client-private-key = "/etc/codex/certs/client-key.pem" ``` If the exporter is `none` nothing is written anywhere; otherwise you must run or point to your diff --git a/docs/example-config.md b/docs/example-config.md index f5b3c62904..1f326ac14b 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -341,30 +341,28 @@ environment = "dev" exporter = "none" # Example OTLP/HTTP exporter configuration -# [otel] -# exporter = { otlp-http = { -# endpoint = "https://otel.example.com/v1/logs", -# protocol = "binary", # "binary" | "json" -# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } -# }} +# [otel.exporter."otlp-http"] +# endpoint = "https://otel.example.com/v1/logs" +# protocol = "binary" # "binary" | "json" + +# [otel.exporter."otlp-http".headers] +# "x-otlp-api-key" = "${OTLP_TOKEN}" # Example OTLP/gRPC exporter configuration -# [otel] -# exporter = { otlp-grpc = { -# endpoint = "https://otel.example.com:4317", -# headers = { "x-otlp-meta" = "abc123" } -# }} +# [otel.exporter."otlp-grpc"] +# endpoint = "https://otel.example.com:4317", +# headers = { "x-otlp-meta" = "abc123" } # Example OTLP exporter with mutual TLS -# [otel] -# exporter = { otlp-http = { -# endpoint = "https://otel.example.com/v1/logs", -# protocol = "binary", -# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }, -# tls = { -# ca-certificate = "certs/otel-ca.pem", -# client-certificate = "/etc/codex/certs/client.pem", -# client-private-key = "/etc/codex/certs/client-key.pem", -# } -# }} +# [otel.exporter."otlp-http"] +# endpoint = "https://otel.example.com/v1/logs" +# protocol = "binary" + +# [otel.exporter."otlp-http".headers] +# "x-otlp-api-key" = "${OTLP_TOKEN}" + +# [otel.exporter."otlp-http".tls] +# ca-certificate = "certs/otel-ca.pem" +# client-certificate = "/etc/codex/certs/client.pem" +# client-private-key = "/etc/codex/certs/client-key.pem" ```