diff --git a/crates/coverage-report/src/requests_expected_differences.json b/crates/coverage-report/src/requests_expected_differences.json index e7d3f8ad..ef451acd 100644 --- a/crates/coverage-report/src/requests_expected_differences.json +++ b/crates/coverage-report/src/requests_expected_differences.json @@ -654,6 +654,28 @@ "skip": true, "reason": "Google code_execution is provider-specific and is not a lossless equivalent of Anthropic bash" }, + { + "testCase": "codeInterpreterToolParam", + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "params.tools", "reason": "Google code_execution has no OpenAI Chat Completions equivalent and is dropped" }, + { "pattern": "messages[*].content.length", "reason": "Google executableCode/codeExecutionResult followup content does not roundtrip through Chat Completions" } + ] + }, + { + "testCase": "codeInterpreterToolParam", + "source": "Google", + "target": "Responses", + "fields": [ + { "pattern": "params.tools[*].builtin_type", "reason": "Google code_execution is canonicalized to the OpenAI Responses code_interpreter builtin type" }, + { "pattern": "params.tools[*].name", "reason": "Google code_execution is canonicalized to the OpenAI Responses code_interpreter builtin name" }, + { "pattern": "params.tools[*].provider", "reason": "Google code_execution is canonicalized from a Google builtin to a Responses builtin" }, + { "pattern": "params.tools[*].config.type", "reason": "Google code_execution is normalized to OpenAI code_interpreter config" }, + { "pattern": "params.tools[*].config.container", "reason": "Google code_execution is normalized to OpenAI code_interpreter container config" }, + { "pattern": "messages[*].content[*].provider_options", "reason": "Google code execution metadata is not losslessly representable through Responses message content" } + ] + }, { "testCase": "toolChoiceRequiredWithReasoningParam", "source": "Google", diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index d50f8753..51a23ecd 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -758,6 +758,70 @@ mod tests { assert!(output.get("top_p").is_none(), "Should not have top_p"); } + #[test] + #[cfg(all(feature = "openai", feature = "google"))] + fn test_google_code_execution_maps_to_responses_code_interpreter() { + let payload = json!({ + "contents": [{ + "role": "user", + "parts": [{"text": "Execute Python code to generate a random number"}] + }], + "tools": [{ + "codeExecution": {} + }] + }); + let input = to_bytes(&payload); + + let result = + transform_request(input, ProviderFormat::Responses, Some("gpt-5-nano")).unwrap(); + + assert!(!result.is_passthrough()); + assert_eq!(result.source_format(), Some(ProviderFormat::Google)); + + let output: Value = crate::serde_json::from_slice(result.as_bytes()).unwrap(); + assert_eq!(output.get("model").unwrap().as_str().unwrap(), "gpt-5-nano"); + assert_eq!( + output.get("tools"), + Some(&json!([ + { + "type": "code_interpreter", + "container": { + "type": "auto" + } + } + ])), + "Google codeExecution should map to Responses code_interpreter" + ); + } + + #[test] + #[cfg(all(feature = "openai", feature = "google"))] + fn test_google_code_execution_is_stripped_for_chat_requests() { + let payload = json!({ + "contents": [{ + "role": "user", + "parts": [{"text": "Execute Python code to generate a random number"}] + }], + "tools": [{ + "codeExecution": {} + }] + }); + let input = to_bytes(&payload); + + let result = + transform_request(input, ProviderFormat::ChatCompletions, Some("gpt-5-nano")).unwrap(); + + assert!(!result.is_passthrough()); + assert_eq!(result.source_format(), Some(ProviderFormat::Google)); + + let output: Value = crate::serde_json::from_slice(result.as_bytes()).unwrap(); + assert_eq!(output.get("model").unwrap().as_str().unwrap(), "gpt-5-nano"); + assert!( + output.get("tools").is_none(), + "Google codeExecution should be stripped for Chat Completions" + ); + } + #[test] #[cfg(feature = "openai")] fn test_non_reasoning_model_still_passthroughs() { diff --git a/crates/lingua/src/universal/tools.rs b/crates/lingua/src/universal/tools.rs index 2a9120c5..c9d84f6c 100644 --- a/crates/lingua/src/universal/tools.rs +++ b/crates/lingua/src/universal/tools.rs @@ -35,8 +35,8 @@ use crate::providers::anthropic::generated::{ }; use crate::providers::google::generated::GoogleSearch; use crate::providers::openai::generated::{ - ApproximateLocation, Tool as OpenAIResponsesTool, UserLocationType as OpenAIUserLocationType, - WebSearchTool, + ApproximateLocation, CodeInterpreterTool, Tool as OpenAIResponsesTool, + UserLocationType as OpenAIUserLocationType, WebSearchTool, }; use crate::serde_json::{self, json, Map, Value}; @@ -526,6 +526,28 @@ impl UniversalTool { error: e.to_string(), }) } + BuiltinToolProvider::Google if builtin_type == "code_execution" => { + let _google_config = + config + .clone() + .ok_or_else(|| ConvertError::UnsupportedToolType { + tool_name: self.name.clone(), + tool_type: builtin_type.clone(), + target_provider: ProviderFormat::Responses, + })?; + + let tool = OpenAIResponsesTool::CodeInterpreter(CodeInterpreterTool { + container: json!({ "type": "auto" }), + }); + + serde_json::to_value(tool).map_err(|e| ConvertError::JsonSerializationFailed { + field: format!( + "OpenAI responses Google code execution tool conversion for '{}'", + self.name + ), + error: e.to_string(), + }) + } BuiltinToolProvider::Anthropic | BuiltinToolProvider::Converse | BuiltinToolProvider::Google => Err(ConvertError::UnsupportedToolType { @@ -558,7 +580,9 @@ pub fn tools_to_openai_chat_value(tools: &[UniversalTool]) -> Result {} + }) if tool_type == "web_search_20250305" + || tool_type == "google_search" + || tool_type == "code_execution" => {} Err(err) => return Err(err), } } @@ -577,10 +601,12 @@ pub fn tools_to_responses_value(tools: &[UniversalTool]) -> Result if tools.is_empty() { return Ok(None); } - let converted: Vec = tools - .iter() - .map(|t| t.to_responses_value()) - .collect::, _>>()?; + let mut converted = Vec::new(); + + for tool in tools { + converted.push(tool.to_responses_value()?); + } + Ok(Some(Value::Array(converted))) } @@ -926,6 +952,19 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_batch_conversion_to_openai_chat_drops_google_code_execution() { + let tools = vec![UniversalTool::builtin( + "code_execution", + BuiltinToolProvider::Google, + "code_execution", + Some(json!({})), + )]; + + let result = tools_to_openai_chat_value(&tools).unwrap(); + assert!(result.is_none()); + } + #[test] fn test_anthropic_web_search_to_openai_chat_is_unsupported_without_filters() { let tool = UniversalTool::builtin( @@ -1098,6 +1137,48 @@ mod tests { } } + #[test] + fn test_google_code_execution_to_responses() { + let tool = UniversalTool::builtin( + "code_execution", + BuiltinToolProvider::Google, + "code_execution", + Some(json!({})), + ); + + let typed: OpenAIResponsesTool = + serde_json::from_value(tool.to_responses_value().unwrap()).unwrap(); + match typed { + OpenAIResponsesTool::CodeInterpreter(code_interpreter) => { + assert_eq!(code_interpreter.container, json!({ "type": "auto" })); + } + other => panic!("expected code_interpreter tool, got {:?}", other), + } + } + + #[test] + fn test_batch_conversion_to_responses_maps_google_code_execution() { + let tools = vec![UniversalTool::builtin( + "code_execution", + BuiltinToolProvider::Google, + "code_execution", + Some(json!({})), + )]; + + let value = tools_to_responses_value(&tools).unwrap(); + assert_eq!( + value, + Some(json!([ + { + "type": "code_interpreter", + "container": { + "type": "auto" + } + } + ])) + ); + } + #[test] fn test_google_function_declaration_to_openai_tool_parameters_are_object() { let decl: FunctionDeclaration = serde_json::from_value(json!({ diff --git a/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap b/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap index 03bed7f2..7c0baf03 100644 --- a/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap +++ b/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap @@ -16258,6 +16258,70 @@ exports[`google → anthropic > webSearchToolParam > response 1`] = ` } `; +exports[`google → chat-completions > codeInterpreterToolParam > request 1`] = ` +{ + "messages": [ + { + "content": "Execute Python code to generate a random number", + "role": "user", + }, + ], + "model": "gpt-5-nano", +} +`; + +exports[`google → chat-completions > codeInterpreterToolParam > response 1`] = ` +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I can’t run Python code directly in this chat, but I can give you ready-to-run snippets and help you generate a number. + +What range and type do you want? +- Integer between a and b (inclusive) +- Float between a and b (inclusive of endpoints or exclusive, typically inclusive) + +Examples you can run: + +- Random integer between 1 and 100 (inclusive): + import random + print(random.randint(1, 100)) + +- One-liner you can run in a shell: + python -c "import random; print(random.randint(1, 100))" + +- Random float in [0.0, 1.0): + import random + print(random.random()) + +- Random float in [a, b): + import random + a = 5.0 + b = 10.0 + print(random.uniform(a, b)) + +If you tell me the exact range and integer vs float, I’ll tailor a snippet for you. I can also provide a single sample number here if you just want a quick value (without executing code).", + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + }, + ], + "modelVersion": "gpt-5-nano-2025-08-07", + "usageMetadata": { + "cachedContentTokenCount": 0, + "candidatesTokenCount": 231, + "promptTokenCount": 14, + "thoughtsTokenCount": 1408, + "totalTokenCount": 1653, + }, +} +`; + exports[`google → chat-completions > complexReasoningRequest > request 1`] = ` { "max_completion_tokens": 20000, @@ -18366,6 +18430,103 @@ If you want, I can also prepare a short, current-news brief for you once you pas } `; +exports[`google → responses > codeInterpreterToolParam > request 1`] = ` +{ + "input": [ + { + "content": "Execute Python code to generate a random number", + "role": "user", + }, + ], + "model": "gpt-5-nano", + "tools": [ + { + "container": { + "type": "auto", + }, + "type": "code_interpreter", + }, + ], +} +`; + +exports[`google → responses > codeInterpreterToolParam > response 1`] = ` +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "", + "thought": true, + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + }, + { + "content": { + "parts": [ + { + "functionCall": { + "args": { + "code": "import random +random.randint(1,100)", + "container_id": "cntr_69efdd6ed1c48193898cda163efe14440982887c1fad39fd", + "outputs": null, + "status": "completed", + }, + "id": "ci_04778756f10776c40069efdd72ec0c8197b3e93a0f6c322ff5", + "name": "code_interpreter", + }, + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 1, + }, + { + "content": { + "parts": [ + { + "text": "", + "thought": true, + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 2, + }, + { + "content": { + "parts": [ + { + "text": "Here’s a random number (1–100): 73 + +If you want a different range or a float, tell me the specifics.", + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 3, + }, + ], + "modelVersion": "gpt-5-nano-2025-08-07", + "usageMetadata": { + "cachedContentTokenCount": 0, + "candidatesTokenCount": 118, + "promptTokenCount": 1108, + "thoughtsTokenCount": 448, + "totalTokenCount": 1674, + }, +} +`; + exports[`google → responses > complexReasoningRequest > request 1`] = ` { "input": [ diff --git a/payloads/transforms/google_to_chat-completions/codeInterpreterToolParam.json b/payloads/transforms/google_to_chat-completions/codeInterpreterToolParam.json index 0c0ff140..d14589ff 100644 --- a/payloads/transforms/google_to_chat-completions/codeInterpreterToolParam.json +++ b/payloads/transforms/google_to_chat-completions/codeInterpreterToolParam.json @@ -1,3 +1,35 @@ { - "error": "Conversion from universal format failed: Tool 'code_execution' of type 'code_execution' is not supported by openai" + "id": "chatcmpl-DZOLedMxb7fii49QX0rAavlTmuwYj", + "object": "chat.completion", + "created": 1777327446, + "model": "gpt-5-nano-2025-08-07", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I can’t run Python code directly in this chat, but I can give you ready-to-run snippets and help you generate a number.\n\nWhat range and type do you want?\n- Integer between a and b (inclusive)\n- Float between a and b (inclusive of endpoints or exclusive, typically inclusive)\n\nExamples you can run:\n\n- Random integer between 1 and 100 (inclusive):\n import random\n print(random.randint(1, 100))\n\n- One-liner you can run in a shell:\n python -c \"import random; print(random.randint(1, 100))\"\n\n- Random float in [0.0, 1.0):\n import random\n print(random.random())\n\n- Random float in [a, b):\n import random\n a = 5.0\n b = 10.0\n print(random.uniform(a, b))\n\nIf you tell me the exact range and integer vs float, I’ll tailor a snippet for you. I can also provide a single sample number here if you just want a quick value (without executing code).", + "refusal": null, + "annotations": [] + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 14, + "completion_tokens": 1639, + "total_tokens": 1653, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 1408, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null } \ No newline at end of file diff --git a/payloads/transforms/google_to_responses/codeInterpreterToolParam.json b/payloads/transforms/google_to_responses/codeInterpreterToolParam.json index 38792db5..7288ff99 100644 --- a/payloads/transforms/google_to_responses/codeInterpreterToolParam.json +++ b/payloads/transforms/google_to_responses/codeInterpreterToolParam.json @@ -1,3 +1,98 @@ { - "error": "Conversion from universal format failed: Tool 'code_execution' of type 'code_execution' is not supported by responses" + "id": "resp_04778756f10776c40069efdd6e4a7481979c516db84c2f8a4b", + "object": "response", + "created_at": 1777327470, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1777327478, + "error": null, + "frequency_penalty": 0, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-5-nano-2025-08-07", + "moderation": null, + "output": [ + { + "id": "rs_04778756f10776c40069efdd6f67348197a06b062cc5888207", + "type": "reasoning", + "summary": [] + }, + { + "id": "ci_04778756f10776c40069efdd72ec0c8197b3e93a0f6c322ff5", + "type": "code_interpreter_call", + "status": "completed", + "code": "import random\r\nrandom.randint(1,100)", + "container_id": "cntr_69efdd6ed1c48193898cda163efe14440982887c1fad39fd", + "outputs": null + }, + { + "id": "rs_04778756f10776c40069efdd74e11481979c606f9d10db60ee", + "type": "reasoning", + "summary": [] + }, + { + "id": "msg_04778756f10776c40069efdd75a03881978efacc6051b44247", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "Here’s a random number (1–100): 73\n\nIf you want a different range or a float, tell me the specifics." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": "in_memory", + "reasoning": { + "effort": "medium", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "code_interpreter", + "container": { + "type": "auto" + } + } + ], + "top_logprobs": 0, + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 1108, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 566, + "output_tokens_details": { + "reasoning_tokens": 448 + }, + "total_tokens": 1674 + }, + "user": null, + "metadata": {}, + "output_text": "Here’s a random number (1–100): 73\n\nIf you want a different range or a float, tell me the specifics." } \ No newline at end of file diff --git a/payloads/transforms/transform_errors.json b/payloads/transforms/transform_errors.json index 2fd344f7..683d63f2 100644 --- a/payloads/transforms/transform_errors.json +++ b/payloads/transforms/transform_errors.json @@ -33,11 +33,9 @@ "reasoningEffortLowParam": "Anthropic requires max_tokens > budget_tokens; min budget (1024) exceeds max_tokens (100)" }, "google_to_chat-completions": { - "codeInterpreterToolParam": "Expected: Google code_execution is provider-specific and is not supported by OpenAI Chat Completions", "urlContextToolParam": "Tool 'url_context' of type 'url_context' is not supported by openai" }, "google_to_responses": { - "codeInterpreterToolParam": "Expected: Google code_execution is provider-specific and is not a lossless equivalent of OpenAI Responses code_interpreter", "urlContextToolParam": "Tool 'url_context' of type 'url_context' is not supported by responses" } }