From 08710d51fc04843ce217c58749677d84cf62740b Mon Sep 17 00:00:00 2001 From: Komi Date: Tue, 21 Jul 2026 10:13:00 +0800 Subject: [PATCH] fix(proxy): default Codex tool parameters to object schema (#5315) Normalize function tool parameters on the Codex Responses -> Chat Completions bridge: default null/missing parameters (direct and nested function forms) to {"type":"object","properties":{}}, coerce explicit type:null, and add a root type for top-level oneOf schemas so strict OpenAI-compatible upstreams (DeepSeek etc.) no longer reject built-in Codex tools like codex_app__automation_update. Also hardens the Codex -> Anthropic tool path with the same object-type guarantee and adds regression coverage for all reported shapes. --- .../providers/transform_codex_anthropic.rs | 57 ++++++- .../proxy/providers/transform_codex_chat.rs | 142 +++++++++++++++++- 2 files changed, 185 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/proxy/providers/transform_codex_anthropic.rs b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs index a45e99aed..50b17b1bc 100644 --- a/src-tauri/src/proxy/providers/transform_codex_anthropic.rs +++ b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs @@ -440,14 +440,17 @@ fn chat_tool_to_anthropic_tool(chat_tool: &Value) -> Option { .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty())?; - let mut tool = json!({ - "name": name, - "input_schema": function - .get("parameters") - .cloned() - .filter(|value| value.as_object().is_some_and(|object| !object.is_empty())) - .unwrap_or_else(|| json!({ "type": "object", "properties": {} })) - }); + let mut input_schema = function + .get("parameters") + .cloned() + .filter(|value| value.as_object().is_some_and(|object| !object.is_empty())) + .unwrap_or_else(|| json!({ "type": "object", "properties": {} })); + if let Some(schema) = input_schema.as_object_mut() { + if schema.get("type").and_then(Value::as_str) != Some("object") { + schema.insert("type".to_string(), json!("object")); + } + } + let mut tool = json!({ "name": name, "input_schema": input_schema }); if let Some(description) = function.get("description").and_then(|value| value.as_str()) { tool["description"] = json!(description); } @@ -1545,6 +1548,44 @@ mod tests { assert_eq!(tools[1]["name"], "apply_patch"); } + #[test] + fn test_request_tool_search_output_schema_defaults_root_type_to_object() { + let input = json!({ + "model": "claude", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": "hi" }, + { + "type": "tool_search_output", + "call_id": "tool_demo123", + "tools": [{ + "type": "function", + "name": "demo_union_tool", + "parameters": { + "oneOf": [ + { "type": "object", "properties": { "mode": { "type": "string" } } }, + { "type": "object", "properties": { "name": { "type": "string" } } } + ] + } + }] + } + ] + }); + + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let input_schema = &result["tools"][0]["input_schema"]; + + assert_eq!(input_schema["type"], "object"); + assert_eq!( + input_schema["oneOf"][0]["properties"]["mode"]["type"], + "string" + ); + assert_eq!( + input_schema["oneOf"][1]["properties"]["name"]["type"], + "string" + ); + } + #[test] fn test_request_tool_choice_mapping() { // A function tool must be present, else tool_choice is (correctly) dropped. diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 065af037a..e90c728d2 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -1174,12 +1174,8 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option .and_then(|value| value.as_object_mut()) { // Ensure parameters.type is "object" for strict OpenAI-compatible providers - if let Some(params) = obj.get("parameters") { - let normalized = normalize_function_parameters(Some(params)); - if normalized != *params { - obj.insert("parameters".to_string(), normalized); - } - } + let parameters = normalize_function_parameters(obj.get("parameters")); + obj.insert("parameters".to_string(), parameters); obj.insert("name".to_string(), json!(chat_name)); if let Some(strict) = tool.get("strict").cloned() { @@ -2054,6 +2050,140 @@ mod tests { assert_eq!(result["reasoning_effort"], "high"); } + #[test] + fn responses_request_to_chat_defaults_null_tool_parameters() { + let input = json!({ + "model": "gpt-5.4", + "tools": [{ + "type": "function", + "name": "codex_app__automation_update", + "description": "Update an automation.", + "parameters": null + }], + "input": "hi" + }); + + let result = responses_to_chat_completions(input).unwrap(); + + assert_eq!( + result["tools"][0]["function"]["parameters"], + json!({"type": "object", "properties": {}}) + ); + } + + #[test] + fn responses_request_to_chat_defaults_nested_null_tool_parameters() { + let input = json!({ + "model": "gpt-5.4", + "tools": [{ + "type": "function", + "function": { + "name": "codex_app__automation_update", + "description": "Update an automation.", + "parameters": null + } + }], + "input": "hi" + }); + + let result = responses_to_chat_completions(input).unwrap(); + + assert_eq!( + result["tools"][0]["function"]["parameters"], + json!({"type": "object", "properties": {}}) + ); + } + + #[test] + fn responses_request_to_chat_defaults_nested_missing_tool_parameters() { + let input = json!({ + "model": "gpt-5.4", + "tools": [{ + "type": "function", + "function": { + "name": "codex_app__automation_update", + "description": "Update an automation." + } + }], + "input": "hi" + }); + + let result = responses_to_chat_completions(input).unwrap(); + + assert_eq!( + result["tools"][0]["function"]["parameters"], + json!({"type": "object", "properties": {}}) + ); + } + + #[test] + fn responses_request_to_chat_normalizes_explicit_null_tool_parameter_type() { + let input = json!({ + "model": "gpt-5.4", + "tools": [{ + "type": "function", + "name": "search", + "parameters": { + "type": null, + "properties": { + "query": {"type": "string"} + }, + "required": ["query"] + } + }], + "input": "hi" + }); + + let result = responses_to_chat_completions(input).unwrap(); + let parameters = &result["tools"][0]["function"]["parameters"]; + + assert_eq!(parameters["type"], "object"); + assert_eq!(parameters["properties"]["query"]["type"], "string"); + assert_eq!(parameters["required"], json!(["query"])); + } + + #[test] + fn responses_request_to_chat_defaults_top_level_one_of_tool_parameters_to_object() { + let input = json!({ + "model": "gpt-5.4", + "tools": [{ + "type": "function", + "name": "lookup", + "parameters": { + "oneOf": [ + { + "type": "object", + "properties": {"id": {"type": "string"}} + }, + { + "type": "object", + "properties": {"slug": {"type": "string"}} + } + ] + } + }], + "input": "hi" + }); + + let result = responses_to_chat_completions(input).unwrap(); + let parameters = &result["tools"][0]["function"]["parameters"]; + + assert_eq!(parameters["type"], "object"); + assert_eq!( + parameters["oneOf"], + json!([ + { + "type": "object", + "properties": {"id": {"type": "string"}} + }, + { + "type": "object", + "properties": {"slug": {"type": "string"}} + } + ]) + ); + } + #[test] fn responses_request_to_chat_exposes_tool_search_and_loaded_namespace_tools() { let input = json!({