From 4af637df79bfc61374765f8bbd6c1dfdebb7db7f Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 6 Nov 2025 04:33:24 -0500 Subject: [PATCH 01/14] enable filesystem tools for third-party models and add relative path support - detect third-party models via slug.contains('/') and enable read_file, list_dir, grep_files - update default model family to include filesystem tools and apply_patch - add relative path resolution in read_file and list_dir handlers against workspace cwd - change apply_patch to Function type for chat completions api compatibility - allow web_search to pass through chat completions api conversion - update tool descriptions to indicate relative paths are supported --- codex-rs/core/src/model_family.rs | 92 ++++++++++++++++++- codex-rs/core/src/tools/handlers/list_dir.rs | 18 ++-- codex-rs/core/src/tools/handlers/read_file.rs | 18 ++-- codex-rs/core/src/tools/spec.rs | 42 ++++++--- 4 files changed, 136 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index a04443611b4..985ad604e89 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -169,6 +169,19 @@ pub fn find_family_for_model(slug: &str) -> Option { needs_special_apply_patch_instructions: true, support_verbosity: true, ) + + // Third-party models accessed via responses proxy + } else if slug.contains("/") { + model_family!( + slug, slug, + apply_patch_tool_type: Some(ApplyPatchToolType::Function), + experimental_supported_tools: vec![ + "grep_files".to_string(), + "list_dir".to_string(), + "read_file".to_string(), + ], + supports_parallel_tool_calls: true, + ) } else { None } @@ -182,11 +195,84 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily { supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, uses_local_shell_tool: false, - supports_parallel_tool_calls: false, - apply_patch_tool_type: None, + supports_parallel_tool_calls: true, + apply_patch_tool_type: Some(ApplyPatchToolType::Function), base_instructions: BASE_INSTRUCTIONS.to_string(), - experimental_supported_tools: Vec::new(), + experimental_supported_tools: vec![ + "grep_files".to_string(), + "list_dir".to_string(), + "read_file".to_string(), + ], effective_context_window_percent: 95, support_verbosity: false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_qwen_model_has_filesystem_tools() { + let model = "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"; + let family = find_family_for_model(model).expect("Qwen model should match"); + + // Should have filesystem tools + assert!( + family + .experimental_supported_tools + .contains(&"read_file".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"list_dir".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"grep_files".to_string()) + ); + + // Should support parallel tool calls + assert!(family.supports_parallel_tool_calls); + + // Should have Function apply_patch (compatible with Chat Completions API) + assert_eq!( + family.apply_patch_tool_type, + Some(ApplyPatchToolType::Function) + ); + } + + #[test] + fn test_default_model_family_has_filesystem_tools() { + let unknown = "some-unknown-model"; + let family = derive_default_model_family(unknown); + + // Default should also have filesystem tools + assert!( + family + .experimental_supported_tools + .contains(&"read_file".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"list_dir".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"grep_files".to_string()) + ); + + // Should support parallel tool calls + assert!(family.supports_parallel_tool_calls); + + // Should have Function apply_patch (compatible with Chat Completions API) + assert_eq!( + family.apply_patch_tool_type, + Some(ApplyPatchToolType::Function) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/list_dir.rs b/codex-rs/core/src/tools/handlers/list_dir.rs index 1c08243f729..0eff96b5894 100644 --- a/codex-rs/core/src/tools/handlers/list_dir.rs +++ b/codex-rs/core/src/tools/handlers/list_dir.rs @@ -51,7 +51,7 @@ impl ToolHandler for ListDirHandler { } async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; + let ToolInvocation { payload, turn, .. } = invocation; let arguments = match payload { ToolPayload::Function { arguments } => arguments, @@ -94,15 +94,17 @@ impl ToolHandler for ListDirHandler { } let path = PathBuf::from(&dir_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "dir_path must be an absolute path".to_string(), - )); - } - let entries = list_dir_slice(&path, offset, limit, depth).await?; + // Resolve relative paths against the workspace CWD + let resolved_path = if path.is_absolute() { + path + } else { + turn.cwd.join(&path) + }; + + let entries = list_dir_slice(&resolved_path, offset, limit, depth).await?; let mut output = Vec::with_capacity(entries.len() + 1); - output.push(format!("Absolute path: {}", path.display())); + output.push(format!("Absolute path: {}", resolved_path.display())); output.extend(entries); Ok(ToolOutput::Function { content: output.join("\n"), diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs index 58b6ea6888b..5c954ee6170 100644 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -96,7 +96,7 @@ impl ToolHandler for ReadFileHandler { } async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; + let ToolInvocation { payload, turn, .. } = invocation; let arguments = match payload { ToolPayload::Function { arguments } => arguments, @@ -134,17 +134,19 @@ impl ToolHandler for ReadFileHandler { } let path = PathBuf::from(&file_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "file_path must be an absolute path".to_string(), - )); - } + + // Resolve relative paths against the workspace CWD + let resolved_path = if path.is_absolute() { + path + } else { + turn.cwd.join(&path) + }; let collected = match mode { - ReadMode::Slice => slice::read(&path, offset, limit).await?, + ReadMode::Slice => slice::read(&resolved_path, offset, limit).await?, ReadMode::Indentation => { let indentation = indentation.unwrap_or_default(); - indentation::read_block(&path, offset, limit, indentation).await? + indentation::read_block(&resolved_path, offset, limit, indentation).await? } }; Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index eba9fd517cc..0a1eccc2e6b 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -422,7 +422,7 @@ fn create_read_file_tool() -> ToolSpec { properties.insert( "file_path".to_string(), JsonSchema::String { - description: Some("Absolute path to the file".to_string()), + description: Some("Path to the file (absolute or relative to workspace)".to_string()), }, ); properties.insert( @@ -520,7 +520,9 @@ fn create_list_dir_tool() -> ToolSpec { properties.insert( "dir_path".to_string(), JsonSchema::String { - description: Some("Absolute path to the directory to list.".to_string()), + description: Some( + "Path to the directory to list (absolute or relative to workspace)".to_string(), + ), }, ); properties.insert( @@ -693,19 +695,29 @@ pub(crate) fn create_tools_json_for_chat_completions_api( let tools_json = responses_api_tools_json .into_iter() .filter_map(|mut tool| { - if tool.get("type") != Some(&serde_json::Value::String("function".to_string())) { - return None; - } - - if let Some(map) = tool.as_object_mut() { - // Remove "type" field as it is not needed in chat completions. - map.remove("type"); - Some(json!({ - "type": "function", - "function": map, - })) - } else { - None + let tool_type = tool.get("type").and_then(|t| t.as_str()); + + match tool_type { + Some("function") => { + if let Some(map) = tool.as_object_mut() { + // Remove "type" field as it is not needed in chat completions. + map.remove("type"); + Some(json!({ + "type": "function", + "function": map, + })) + } else { + None + } + } + Some("web_search") => { + // Pass through web_search as-is + Some(tool) + } + _ => { + // Filter out custom, local_shell, and other unsupported types + None + } } }) .collect::>(); From 9afbf1a4b029b46514b5ae62ff84d567d90d7612 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 6 Nov 2025 05:09:57 -0500 Subject: [PATCH 02/14] skip updates for now --- codex-rs/tui/src/updates.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 8b92af4fc98..3dcc98c8d0e 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -104,6 +104,9 @@ fn is_newer(latest: &str, current: &str) -> Option { /// Returns the latest version to show in a popup, if it should be shown. /// This respects the user's dismissal choice for the current latest version. pub fn get_upgrade_version_for_popup(config: &Config) -> Option { + // Skip version checks for custom fork + return None; + let version_file = version_filepath(config); let latest = get_upgrade_version(config)?; // If the user dismissed this exact version previously, do not show the popup. From 0b490fe342eebe57bc2f191b034381f4fe81d81f Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 6 Nov 2025 04:33:24 -0500 Subject: [PATCH 03/14] enable filesystem tools for third-party models and add relative path support - detect third-party models via slug.contains('/') and enable read_file, list_dir, grep_files - update default model family to include filesystem tools and apply_patch - add relative path resolution in read_file and list_dir handlers against workspace cwd - change apply_patch to Function type for chat completions api compatibility - allow web_search to pass through chat completions api conversion - update tool descriptions to indicate relative paths are supported --- codex-rs/core/src/model_family.rs | 100 +++++++++++++++++- codex-rs/core/src/tools/handlers/list_dir.rs | 18 ++-- codex-rs/core/src/tools/handlers/read_file.rs | 18 ++-- codex-rs/core/src/tools/spec.rs | 42 +++++--- 4 files changed, 144 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 150420fecfa..286e5097a97 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -38,6 +38,11 @@ pub struct ModelFamily { // Define if we need a special handling of reasoning summary pub reasoning_summary_format: ReasoningSummaryFormat, + // This should be set to true when the model expects a tool named + // "local_shell" to be provided. Its contract must be understood natively by + // the model such that its description can be omitted. + pub uses_local_shell_tool: bool, + /// Whether this model supports parallel tool calls when using the /// Responses API. pub supports_parallel_tool_calls: bool, @@ -80,6 +85,7 @@ macro_rules! model_family { needs_special_apply_patch_instructions: false, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, + uses_local_shell_tool: false, supports_parallel_tool_calls: false, apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), @@ -118,6 +124,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, "codex-mini-latest", supports_reasoning_summaries: true, + uses_local_shell_tool: true, needs_special_apply_patch_instructions: true, shell_type: ConfigShellToolType::Local, ) @@ -199,6 +206,19 @@ pub fn find_family_for_model(slug: &str) -> Option { needs_special_apply_patch_instructions: true, support_verbosity: true, ) + + // Third-party models accessed via responses proxy + } else if slug.contains("/") { + model_family!( + slug, slug, + apply_patch_tool_type: Some(ApplyPatchToolType::Function), + experimental_supported_tools: vec![ + "grep_files".to_string(), + "list_dir".to_string(), + "read_file".to_string(), + ], + supports_parallel_tool_calls: true, + ) } else { None } @@ -211,10 +231,15 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily { needs_special_apply_patch_instructions: false, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, - supports_parallel_tool_calls: false, - apply_patch_tool_type: None, + uses_local_shell_tool: false, + supports_parallel_tool_calls: true, + apply_patch_tool_type: Some(ApplyPatchToolType::Function), base_instructions: BASE_INSTRUCTIONS.to_string(), - experimental_supported_tools: Vec::new(), + experimental_supported_tools: vec![ + "grep_files".to_string(), + "list_dir".to_string(), + "read_file".to_string(), + ], effective_context_window_percent: 95, support_verbosity: false, shell_type: ConfigShellToolType::Default, @@ -222,3 +247,72 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily { default_reasoning_effort: None, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_qwen_model_has_filesystem_tools() { + let model = "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"; + let family = find_family_for_model(model).expect("Qwen model should match"); + + // Should have filesystem tools + assert!( + family + .experimental_supported_tools + .contains(&"read_file".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"list_dir".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"grep_files".to_string()) + ); + + // Should support parallel tool calls + assert!(family.supports_parallel_tool_calls); + + // Should have Function apply_patch (compatible with Chat Completions API) + assert_eq!( + family.apply_patch_tool_type, + Some(ApplyPatchToolType::Function) + ); + } + + #[test] + fn test_default_model_family_has_filesystem_tools() { + let unknown = "some-unknown-model"; + let family = derive_default_model_family(unknown); + + // Default should also have filesystem tools + assert!( + family + .experimental_supported_tools + .contains(&"read_file".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"list_dir".to_string()) + ); + assert!( + family + .experimental_supported_tools + .contains(&"grep_files".to_string()) + ); + + // Should support parallel tool calls + assert!(family.supports_parallel_tool_calls); + + // Should have Function apply_patch (compatible with Chat Completions API) + assert_eq!( + family.apply_patch_tool_type, + Some(ApplyPatchToolType::Function) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/list_dir.rs b/codex-rs/core/src/tools/handlers/list_dir.rs index 1c08243f729..0eff96b5894 100644 --- a/codex-rs/core/src/tools/handlers/list_dir.rs +++ b/codex-rs/core/src/tools/handlers/list_dir.rs @@ -51,7 +51,7 @@ impl ToolHandler for ListDirHandler { } async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; + let ToolInvocation { payload, turn, .. } = invocation; let arguments = match payload { ToolPayload::Function { arguments } => arguments, @@ -94,15 +94,17 @@ impl ToolHandler for ListDirHandler { } let path = PathBuf::from(&dir_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "dir_path must be an absolute path".to_string(), - )); - } - let entries = list_dir_slice(&path, offset, limit, depth).await?; + // Resolve relative paths against the workspace CWD + let resolved_path = if path.is_absolute() { + path + } else { + turn.cwd.join(&path) + }; + + let entries = list_dir_slice(&resolved_path, offset, limit, depth).await?; let mut output = Vec::with_capacity(entries.len() + 1); - output.push(format!("Absolute path: {}", path.display())); + output.push(format!("Absolute path: {}", resolved_path.display())); output.extend(entries); Ok(ToolOutput::Function { content: output.join("\n"), diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs index 58b6ea6888b..5c954ee6170 100644 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -96,7 +96,7 @@ impl ToolHandler for ReadFileHandler { } async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; + let ToolInvocation { payload, turn, .. } = invocation; let arguments = match payload { ToolPayload::Function { arguments } => arguments, @@ -134,17 +134,19 @@ impl ToolHandler for ReadFileHandler { } let path = PathBuf::from(&file_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "file_path must be an absolute path".to_string(), - )); - } + + // Resolve relative paths against the workspace CWD + let resolved_path = if path.is_absolute() { + path + } else { + turn.cwd.join(&path) + }; let collected = match mode { - ReadMode::Slice => slice::read(&path, offset, limit).await?, + ReadMode::Slice => slice::read(&resolved_path, offset, limit).await?, ReadMode::Indentation => { let indentation = indentation.unwrap_or_default(); - indentation::read_block(&path, offset, limit, indentation).await? + indentation::read_block(&resolved_path, offset, limit, indentation).await? } }; Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 88ddbf55177..a553cf18c0b 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -534,7 +534,7 @@ fn create_read_file_tool() -> ToolSpec { properties.insert( "file_path".to_string(), JsonSchema::String { - description: Some("Absolute path to the file".to_string()), + description: Some("Path to the file (absolute or relative to workspace)".to_string()), }, ); properties.insert( @@ -632,7 +632,9 @@ fn create_list_dir_tool() -> ToolSpec { properties.insert( "dir_path".to_string(), JsonSchema::String { - description: Some("Absolute path to the directory to list.".to_string()), + description: Some( + "Path to the directory to list (absolute or relative to workspace)".to_string(), + ), }, ); properties.insert( @@ -805,19 +807,29 @@ pub(crate) fn create_tools_json_for_chat_completions_api( let tools_json = responses_api_tools_json .into_iter() .filter_map(|mut tool| { - if tool.get("type") != Some(&serde_json::Value::String("function".to_string())) { - return None; - } - - if let Some(map) = tool.as_object_mut() { - // Remove "type" field as it is not needed in chat completions. - map.remove("type"); - Some(json!({ - "type": "function", - "function": map, - })) - } else { - None + let tool_type = tool.get("type").and_then(|t| t.as_str()); + + match tool_type { + Some("function") => { + if let Some(map) = tool.as_object_mut() { + // Remove "type" field as it is not needed in chat completions. + map.remove("type"); + Some(json!({ + "type": "function", + "function": map, + })) + } else { + None + } + } + Some("web_search") => { + // Pass through web_search as-is + Some(tool) + } + _ => { + // Filter out custom, local_shell, and other unsupported types + None + } } }) .collect::>(); From eeacf6dbd8bc1472d3405f9654dc5ac54e860013 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 6 Nov 2025 05:09:57 -0500 Subject: [PATCH 04/14] skip updates for now --- codex-rs/tui/src/updates.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 0c3f2044ef9..1b05fdee41a 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -141,6 +141,9 @@ fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result Option { + // Skip version checks for custom fork + return None; + let version_file = version_filepath(config); let latest = get_upgrade_version(config)?; // If the user dismissed this exact version previously, do not show the popup. From b64f6a3c907ce90c010d874ee499496f5cfd7bb4 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:39:03 -0500 Subject: [PATCH 05/14] dedupe model_family tests --- codex-rs/core/src/model_family.rs | 69 ------------------------------- 1 file changed, 69 deletions(-) diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 15487bf8081..286e5097a97 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -316,72 +316,3 @@ mod tests { ); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_qwen_model_has_filesystem_tools() { - let model = "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"; - let family = find_family_for_model(model).expect("Qwen model should match"); - - // Should have filesystem tools - assert!( - family - .experimental_supported_tools - .contains(&"read_file".to_string()) - ); - assert!( - family - .experimental_supported_tools - .contains(&"list_dir".to_string()) - ); - assert!( - family - .experimental_supported_tools - .contains(&"grep_files".to_string()) - ); - - // Should support parallel tool calls - assert!(family.supports_parallel_tool_calls); - - // Should have Function apply_patch (compatible with Chat Completions API) - assert_eq!( - family.apply_patch_tool_type, - Some(ApplyPatchToolType::Function) - ); - } - - #[test] - fn test_default_model_family_has_filesystem_tools() { - let unknown = "some-unknown-model"; - let family = derive_default_model_family(unknown); - - // Default should also have filesystem tools - assert!( - family - .experimental_supported_tools - .contains(&"read_file".to_string()) - ); - assert!( - family - .experimental_supported_tools - .contains(&"list_dir".to_string()) - ); - assert!( - family - .experimental_supported_tools - .contains(&"grep_files".to_string()) - ); - - // Should support parallel tool calls - assert!(family.supports_parallel_tool_calls); - - // Should have Function apply_patch (compatible with Chat Completions API) - assert_eq!( - family.apply_patch_tool_type, - Some(ApplyPatchToolType::Function) - ); - } -} From 62ba0205fa212ecdc741dfdf954de9da01ba2de4 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:47:33 -0500 Subject: [PATCH 06/14] fix chat tool bridging --- codex-rs/core/src/chat_completions.rs | 67 ++++++-- codex-rs/core/src/tools/spec.rs | 159 ++++++++++++++---- .../core/tests/chat_completions_payload.rs | 42 ++++- 3 files changed, 218 insertions(+), 50 deletions(-) diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 785a4d4ce50..02f0ed78109 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -18,6 +18,8 @@ use bytes::Bytes; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::LocalShellAction; +use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::SessionSource; @@ -137,7 +139,9 @@ pub(crate) async fn stream_chat_completions( // Otherwise, attach to immediate next assistant anchor (tool-calls or assistant message) if !attached && idx + 1 < input.len() { match &input[idx + 1] { - ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => { + ResponseItem::FunctionCall { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::CustomToolCall { .. } => { reasoning_by_anchor_index .entry(idx + 1) .and_modify(|v| v.push_str(&text)) @@ -240,19 +244,25 @@ pub(crate) async fn stream_chat_completions( } ResponseItem::LocalShellCall { id, - call_id: _, - status, + call_id, action, + .. } => { - // Confirm with API team. + let tool_call_id = call_id.clone().or_else(|| id.clone()).unwrap_or_default(); + let arguments = match action { + LocalShellAction::Exec(exec) => local_shell_arguments(exec), + }; + let mut msg = json!({ "role": "assistant", "content": null, "tool_calls": [{ - "id": id.clone().unwrap_or_else(|| "".to_string()), - "type": "local_shell_call", - "status": status, - "action": action, + "id": tool_call_id, + "type": "function", + "function": { + "name": "local_shell", + "arguments": arguments.to_string(), + } }] }); if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) @@ -289,24 +299,29 @@ pub(crate) async fn stream_chat_completions( })); } ResponseItem::CustomToolCall { - id, - call_id: _, + call_id, name, input, - status: _, + .. } => { - messages.push(json!({ + let mut msg = json!({ "role": "assistant", "content": null, "tool_calls": [{ - "id": id, - "type": "custom", - "custom": { + "id": call_id, + "type": "function", + "function": { "name": name, - "input": input, + "arguments": json!({ "input": input }).to_string(), } }] - })); + }); + if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) + && let Some(obj) = msg.as_object_mut() + { + obj.insert("reasoning".to_string(), json!(reasoning)); + } + messages.push(msg); } ResponseItem::CustomToolCallOutput { call_id, output } => { messages.push(json!({ @@ -431,6 +446,24 @@ pub(crate) async fn stream_chat_completions( } } +fn local_shell_arguments(action: &LocalShellExecAction) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert("command".to_string(), json!(action.command)); + if let Some(workdir) = &action.working_directory { + map.insert("workdir".to_string(), json!(workdir)); + } + if let Some(timeout) = action.timeout_ms { + map.insert("timeout_ms".to_string(), json!(timeout)); + } + if let Some(env) = &action.env { + map.insert("env".to_string(), json!(env)); + } + if let Some(user) = &action.user { + map.insert("user".to_string(), json!(user)); + } + serde_json::Value::Object(map) +} + async fn append_assistant_text( tx_event: &mpsc::Sender>, assistant_item: &mut Option, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a553cf18c0b..4667fb0a83e 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,3 +1,4 @@ +use crate::client_common::tools::FreeformTool; use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::features::Feature; @@ -801,39 +802,100 @@ pub fn create_tools_json_for_responses_api( pub(crate) fn create_tools_json_for_chat_completions_api( tools: &[ToolSpec], ) -> crate::error::Result> { - // We start with the JSON for the Responses API and than rewrite it to match - // the chat completions tool call format. - let responses_api_tools_json = create_tools_json_for_responses_api(tools)?; - let tools_json = responses_api_tools_json - .into_iter() - .filter_map(|mut tool| { - let tool_type = tool.get("type").and_then(|t| t.as_str()); - - match tool_type { - Some("function") => { - if let Some(map) = tool.as_object_mut() { - // Remove "type" field as it is not needed in chat completions. - map.remove("type"); - Some(json!({ - "type": "function", - "function": map, - })) - } else { - None + tools + .iter() + .map(|tool| match tool { + ToolSpec::Function(spec) => wrap_function_for_chat(spec), + ToolSpec::Freeform(spec) => Ok(freeform_tool_for_chat(spec)), + ToolSpec::LocalShell {} => local_shell_tool_for_chat(), + ToolSpec::WebSearch {} => Ok(json!({ "type": "web_search" })), + }) + .collect() +} + +fn wrap_function_for_chat(tool: &ResponsesApiTool) -> crate::error::Result { + let value = serde_json::to_value(tool)?; + let map = match value { + serde_json::Value::Object(map) => map, + _ => unreachable!("ResponsesApiTool should serialize to an object"), + }; + Ok(json!({ + "type": "function", + "function": map, + })) +} + +fn freeform_tool_for_chat(tool: &FreeformTool) -> serde_json::Value { + let mut description = tool.description.clone(); + if !tool.format.definition.is_empty() { + description.push_str("\n\nInput format:\n"); + description.push_str(&tool.format.definition); + } + if !tool.format.syntax.is_empty() { + description.push_str("\n\nSyntax:\n"); + description.push_str(&tool.format.syntax); + } + + json!({ + "type": "function", + "function": { + "name": tool.name, + "description": description, + "parameters": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Raw payload for the freeform tool call." } - } - Some("web_search") => { - // Pass through web_search as-is - Some(tool) - } - _ => { - // Filter out custom, local_shell, and other unsupported types - None - } + }, + "required": ["input"], + "additionalProperties": false } - }) - .collect::>(); - Ok(tools_json) + } + }) +} + +fn local_shell_tool_for_chat() -> crate::error::Result { + let ToolSpec::Function(mut tool) = create_shell_tool() else { + unreachable!("create_shell_tool must return a function tool"); + }; + tool.name = "local_shell".to_string(); + tool.description.push_str( + "\n\nExecutes commands on the end-user's local terminal without using a sandbox.", + ); + + if let JsonSchema::Object { + properties, + additional_properties, + .. + } = &mut tool.parameters + { + properties.insert( + "env".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some( + JsonSchema::String { + description: Some("Environment variable value".to_string()), + } + .into(), + ), + }, + ); + properties.insert( + "user".to_string(), + JsonSchema::String { + description: Some("User context to run the command as".to_string()), + }, + ); + if additional_properties.is_none() { + *additional_properties = Some(false.into()); + } + } + + wrap_function_for_chat(&tool) } pub(crate) fn mcp_tool_to_openai_tool( @@ -1132,6 +1194,7 @@ pub(crate) fn build_specs( #[cfg(test)] mod tests { use crate::client_common::tools::FreeformTool; + use crate::client_common::tools::FreeformToolFormat; use crate::model_family::find_family_for_model; use crate::tools::registry::ConfiguredToolSpec; use mcp_types::ToolInputSchema; @@ -1822,6 +1885,40 @@ mod tests { ); } + #[test] + fn chat_tools_include_freeform_input_wrapper() { + let tools = vec![ToolSpec::Freeform(FreeformTool { + name: "demo_freeform".to_string(), + description: "demo description".to_string(), + format: FreeformToolFormat { + r#type: "text/plain".to_string(), + syntax: "plain text".to_string(), + definition: "Input MUST contain patches".to_string(), + }, + })]; + let chat_tools = create_tools_json_for_chat_completions_api(&tools) + .expect("freeform conversion succeeds"); + assert_eq!(chat_tools.len(), 1); + let tool = &chat_tools[0]; + assert_eq!(tool["type"], json!("function")); + assert_eq!(tool["function"]["name"], json!("demo_freeform")); + assert_eq!(tool["function"]["parameters"]["required"], json!(["input"])); + } + + #[test] + fn chat_tools_include_local_shell_function() { + let tools = vec![ToolSpec::LocalShell {}]; + let chat_tools = create_tools_json_for_chat_completions_api(&tools) + .expect("local shell conversion succeeds"); + assert_eq!(chat_tools.len(), 1); + let tool = &chat_tools[0]; + assert_eq!(tool["type"], json!("function")); + assert_eq!(tool["function"]["name"], json!("local_shell")); + let properties = &tool["function"]["parameters"]["properties"]; + assert!(properties.get("command").is_some()); + assert!(properties.get("env").is_some()); + } + #[test] fn test_mcp_tool_array_without_items_gets_default_string_items() { let model_family = find_family_for_model("gpt-5-codex") diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index accac55e01b..153e021f5ff 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -17,6 +17,7 @@ use core_test_support::load_default_config_for_test; use core_test_support::skip_if_no_network; use futures::StreamExt; use serde_json::Value; +use serde_json::json; use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; @@ -171,6 +172,16 @@ fn local_shell_call() -> ResponseItem { } } +fn custom_tool_call() -> ResponseItem { + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "custom-call-id".to_string(), + name: "custom_tool".to_string(), + input: "payload".to_string(), + } +} + fn messages_from(body: &Value) -> Vec { match body["messages"].as_array() { Some(arr) => arr.clone(), @@ -250,10 +261,17 @@ async fn attaches_reasoning_to_local_shell_call() { let assistant = first_assistant(&messages); assert_eq!(assistant["reasoning"], Value::String("rShell".into())); + let tool_call = &assistant["tool_calls"][0]; + assert_eq!(tool_call["type"], Value::String("function".into())); assert_eq!( - assistant["tool_calls"][0]["type"], - Value::String("local_shell_call".into()) + tool_call["function"]["name"], + Value::String("local_shell".into()) ); + let args = tool_call["function"]["arguments"] + .as_str() + .expect("arguments string"); + let parsed_args: Value = serde_json::from_str(args).expect("valid json arguments"); + assert_eq!(parsed_args["command"], json!(["echo"])); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -317,3 +335,23 @@ async fn suppresses_duplicate_assistant_messages() { Value::String("dup".into()) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_tool_calls_are_encoded_as_functions() { + skip_if_no_network!(); + + let body = run_request(vec![user_message("u1"), custom_tool_call()]).await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + let call = &assistant["tool_calls"][0]; + assert_eq!(call["type"], Value::String("function".into())); + assert_eq!( + call["function"]["name"], + Value::String("custom_tool".into()) + ); + let args = call["function"]["arguments"] + .as_str() + .expect("arguments string"); + let parsed_args: Value = serde_json::from_str(args).expect("valid json"); + assert_eq!(parsed_args["input"], Value::String("payload".into())); +} From d907b38f0313061aebcd6568feba5731e9390d06 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:45:44 -0500 Subject: [PATCH 07/14] ci: add linux/windows binary artifacts --- .github/workflows/rust-ci.yml | 51 ++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 0bd91ca53b2..0a0cfe40f04 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -469,10 +469,57 @@ jobs: echo "Tests failed. See logs for details." exit 1 + build_binaries: + name: Binary artifacts — ${{ matrix.artifact_name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + needs: changed + if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + artifact_name: codex-linux-x86_64 + - runner: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: codex-windows-x86_64 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@1.90 + with: + targets: ${{ matrix.target }} + - name: cargo build (release) + run: cargo build --target ${{ matrix.target }} --release --bin codex + - name: Stage binary (Linux) + if: ${{ !startsWith(matrix.runner, 'windows') }} + shell: bash + run: | + set -euo pipefail + dest="artifacts/${{ matrix.artifact_name }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex" "$dest/codex" + - name: Stage binary (Windows) + if: ${{ startsWith(matrix.runner, 'windows') }} + shell: pwsh + run: | + $dest = "artifacts/${{ matrix.artifact_name }}" + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item "target/${{ matrix.target }}/release/codex.exe" "$dest/codex.exe" + - name: Upload binary artifact + uses: actions/upload-artifact@v5 + with: + name: ${{ matrix.artifact_name }} + path: codex-rs/artifacts/${{ matrix.artifact_name }} + # --- Gatherer job that you mark as the ONLY required status ----------------- results: name: CI results (required) - needs: [changed, general, cargo_shear, lint_build, tests] + needs: [changed, general, cargo_shear, lint_build, tests, build_binaries] if: always() runs-on: ubuntu-24.04 steps: @@ -483,6 +530,7 @@ jobs: echo "shear : ${{ needs.cargo_shear.result }}" echo "lint : ${{ needs.lint_build.result }}" echo "tests : ${{ needs.tests.result }}" + echo "bins : ${{ needs.build_binaries.result }}" # If nothing relevant changed (PR touching only root README, etc.), # declare success regardless of other jobs. @@ -496,6 +544,7 @@ jobs: [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } + [[ '${{ needs.build_binaries.result }}' == 'success' ]] || { echo 'build_binaries failed'; exit 1; } - name: sccache summary note if: always() From aabd36723b8c952eb4a9f4d0b784242fb43ff7e4 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:11:25 -0500 Subject: [PATCH 08/14] ci: log fmt diff --- .github/workflows/rust-ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 0a0cfe40f04..507a732a0f3 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -61,7 +61,23 @@ jobs: with: components: rustfmt - name: cargo fmt - run: cargo fmt -- --config imports_granularity=Item --check + shell: bash + run: | + set -euo pipefail + if ! cargo fmt -- --config imports_granularity=Item --check; then + { + echo "## cargo fmt diff"; + echo; + echo "\`\`\`"; + git status --short + echo "\`\`\`"; + echo; + echo "\`\`\`diff"; + git diff + echo "\`\`\`"; + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi - name: Verify codegen for mcp-types run: ./mcp-types/check_lib_rs.py From 2b84b9c44100dcf633c860a3f1121e0bf7fa05a7 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:24:23 -0500 Subject: [PATCH 09/14] ci: annotate fmt failures --- .github/workflows/rust-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 507a732a0f3..e4b38263897 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -65,6 +65,10 @@ jobs: run: | set -euo pipefail if ! cargo fmt -- --config imports_granularity=Item --check; then + while IFS= read -r -d '' entry; do + file="${entry:3}" + echo "::error file=${file}::cargo fmt would rewrite ${file}" + done < <(git status --porcelain -z) { echo "## cargo fmt diff"; echo; From c822fd78e1aa79c2f830e12b0f8fe20d7926b1d9 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:08:00 -0500 Subject: [PATCH 10/14] fmt: trim updates popup blank line --- codex-rs/tui/src/updates.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 1b05fdee41a..c2a82abfe3e 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -143,7 +143,7 @@ fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result Option { // Skip version checks for custom fork return None; - + let version_file = version_filepath(config); let latest = get_upgrade_version(config)?; // If the user dismissed this exact version previously, do not show the popup. From b23b554b94ad4c33d24083cd19da28fdc8b38201 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:14:19 -0500 Subject: [PATCH 11/14] ci: build binaries for all os --- .github/workflows/rust-ci.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index e4b38263897..b798df9a339 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -502,17 +502,42 @@ jobs: fail-fast: false matrix: include: + - runner: macos-14 + target: aarch64-apple-darwin + artifact_name: codex-macos-aarch64 + - runner: macos-14 + target: x86_64-apple-darwin + artifact_name: codex-macos-x86_64 - runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu - artifact_name: codex-linux-x86_64 + artifact_name: codex-linux-x86_64-gnu + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + artifact_name: codex-linux-x86_64-musl + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + artifact_name: codex-linux-aarch64-gnu + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + artifact_name: codex-linux-aarch64-musl - runner: windows-latest target: x86_64-pc-windows-msvc artifact_name: codex-windows-x86_64 + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + artifact_name: codex-windows-arm64 steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} + - name: Install musl build tools + if: contains(matrix.target, 'musl') && startsWith(matrix.runner, 'ubuntu') + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y musl-tools pkg-config - name: cargo build (release) run: cargo build --target ${{ matrix.target }} --release --bin codex - name: Stage binary (Linux) From 02a4cb807138e0f13090279a01c26e521b11267f Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 23:22:25 -0500 Subject: [PATCH 12/14] ci: add nightly release asset publishing to CI workflow --- .github/workflows/rust-ci.yml | 62 ++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index b798df9a339..14678902966 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,4 +1,7 @@ name: rust-ci +permissions: + contents: write + actions: read on: pull_request: {} push: @@ -562,9 +565,62 @@ jobs: path: codex-rs/artifacts/${{ matrix.artifact_name }} # --- Gatherer job that you mark as the ONLY required status ----------------- + publish_release_assets: + name: Publish nightly release assets + runs-on: ubuntu-24.04 + needs: build_binaries + if: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && needs.build_binaries.result == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + pattern: codex-* + path: release-artifacts + merge-multiple: false + - name: Prepare release assets + shell: bash + run: | + set -euo pipefail + mkdir -p release-assets + shopt -s nullglob + count=0 + for dir in release-artifacts/*; do + name="$(basename "$dir")" + if [[ -f "$dir/codex.exe" ]]; then + cp "$dir/codex.exe" "release-assets/${name}.exe" + elif [[ -f "$dir/codex" ]]; then + cp "$dir/codex" "release-assets/${name}" + chmod +x "release-assets/${name}" + else + echo "::error::No codex binary found in $dir" + exit 1 + fi + count=$((count + 1)) + done + if [[ $count -eq 0 ]]; then + echo "::error::No artifacts available to publish." + exit 1 + fi + - name: Ensure nightly release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if ! gh release view nightly >/dev/null 2>&1; then + gh release create nightly --prerelease --title "Nightly builds" --notes "Latest successful CI binaries." + fi + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh release upload nightly release-assets/* --clobber + results: name: CI results (required) - needs: [changed, general, cargo_shear, lint_build, tests, build_binaries] + needs: [changed, general, cargo_shear, lint_build, tests, build_binaries, publish_release_assets] if: always() runs-on: ubuntu-24.04 steps: @@ -576,6 +632,7 @@ jobs: echo "lint : ${{ needs.lint_build.result }}" echo "tests : ${{ needs.tests.result }}" echo "bins : ${{ needs.build_binaries.result }}" + echo "release: ${{ needs.publish_release_assets.result }}" # If nothing relevant changed (PR touching only root README, etc.), # declare success regardless of other jobs. @@ -590,6 +647,9 @@ jobs: [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } [[ '${{ needs.build_binaries.result }}' == 'success' ]] || { echo 'build_binaries failed'; exit 1; } + if [[ '${{ github.event_name }}' == 'push' || '${{ github.event_name }}' == 'workflow_dispatch' ]]; then + [[ '${{ needs.publish_release_assets.result }}' == 'success' ]] || { echo 'publish_release_assets failed'; exit 1; } + fi - name: sccache summary note if: always() From a5c3dc26b27caacb8815eeb872ee5546e52ce505 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 23:28:37 -0500 Subject: [PATCH 13/14] refactor: make workflow and repository configurable via environment variables --- scripts/stage_npm_packages.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 01bc162c732..f786ffffb85 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -16,8 +16,8 @@ REPO_ROOT = Path(__file__).resolve().parent.parent BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py" -WORKFLOW_NAME = ".github/workflows/rust-release.yml" -GITHUB_REPO = "openai/codex" +WORKFLOW_NAME = os.environ.get("CODEX_RELEASE_WORKFLOW", ".github/workflows/rust-release.yml") +RELEASE_REPO = os.environ.get("CODEX_RELEASE_REPO", "openai/codex") _SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT) if _SPEC is None or _SPEC.loader is None: @@ -72,6 +72,8 @@ def resolve_release_workflow(version: str) -> dict: "gh", "run", "list", + "--repo", + RELEASE_REPO, "--branch", f"rust-v{version}", "--json", From 09b5a7ba92bc4870d8c149427d11314d0904fb81 Mon Sep 17 00:00:00 2001 From: sirouk <8901571+sirouk@users.noreply.github.com> Date: Thu, 20 Nov 2025 23:34:16 -0500 Subject: [PATCH 14/14] refactor: enhance release workflow resolution with fallback mechanism and configurable repositories --- scripts/stage_npm_packages.py | 70 +++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index f786ffffb85..c100b11e4ea 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -17,7 +17,13 @@ BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py" WORKFLOW_NAME = os.environ.get("CODEX_RELEASE_WORKFLOW", ".github/workflows/rust-release.yml") -RELEASE_REPO = os.environ.get("CODEX_RELEASE_REPO", "openai/codex") +DEFAULT_RELEASE_REPOS = [ + os.environ.get("CODEX_RELEASE_REPO"), + os.environ.get("GITHUB_REPOSITORY"), + "chutesai/codex", + "openai/codex", +] +DEFAULT_RELEASE_REPOS = [repo for repo in DEFAULT_RELEASE_REPOS if repo] _SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT) if _SPEC is None or _SPEC.loader is None: @@ -66,37 +72,55 @@ def collect_native_components(packages: list[str]) -> set[str]: return components -def resolve_release_workflow(version: str) -> dict: - stdout = subprocess.check_output( - [ - "gh", - "run", - "list", - "--repo", - RELEASE_REPO, - "--branch", - f"rust-v{version}", - "--json", - "workflowName,url,headSha", - "--workflow", - WORKFLOW_NAME, - "--jq", - "first(.[])", - ], - cwd=REPO_ROOT, - text=True, - ) +def resolve_release_workflow(version: str, repo: str) -> dict | None: + try: + stdout = subprocess.check_output( + [ + "gh", + "run", + "list", + "--repo", + repo, + "--branch", + f"rust-v{version}", + "--json", + "workflowName,url,headSha", + "--workflow", + WORKFLOW_NAME, + "--jq", + "first(.[])", + ], + cwd=REPO_ROOT, + text=True, + ) + except subprocess.CalledProcessError: + return None + workflow = json.loads(stdout or "null") if not workflow: - raise RuntimeError(f"Unable to find rust-release workflow for version {version}.") + return None return workflow +def resolve_release_workflow_with_fallback(version: str) -> tuple[dict | None, str | None]: + for repo in DEFAULT_RELEASE_REPOS: + workflow = resolve_release_workflow(version, repo) + if workflow: + return workflow, repo + return None, None + + def resolve_workflow_url(version: str, override: str | None) -> tuple[str, str | None]: if override: return override, None - workflow = resolve_release_workflow(version) + workflow, repo = resolve_release_workflow_with_fallback(version) + if not workflow or not repo: + checked = ", ".join(DEFAULT_RELEASE_REPOS) or "" + raise RuntimeError( + f"Unable to find rust-release workflow for version {version}. Checked repos: {checked}" + ) + print(f"Using release artifacts from {repo}") return workflow["url"], workflow.get("headSha")