diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index bc0304be0..7afc43bf6 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -43,10 +43,12 @@ from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message from openai.types.responses.response_reasoning_item import Content, Summary +from .. import _debug from ..agent_output import AgentOutputSchemaBase from ..exceptions import AgentsException, UserError from ..handoffs import Handoff from ..items import TResponseInputItem, TResponseOutputItem +from ..logger import logger from ..model_settings import MCPToolChoice from ..tool import FunctionTool, Tool from .fake_id import FAKE_RESPONSES_ID @@ -55,6 +57,44 @@ class Converter: + @classmethod + def _sanitize_tool_call_arguments(cls, arguments: str | None, tool_name: str) -> str: + """ + Validates and sanitizes tool call arguments JSON string. + + If the arguments are invalid JSON, returns "{}" as a safe default. + This prevents API errors when invalid JSON is stored in session history + and later sent to APIs that strictly validate JSON (like Anthropic via litellm). + + Args: + arguments: The raw arguments string from the tool call. + tool_name: The name of the tool (for logging purposes). + + Returns: + Valid JSON string. If input is invalid, returns "{}". + """ + if not arguments: + return "{}" + + try: + # Validate JSON by parsing it + json.loads(arguments) + return arguments + except (json.JSONDecodeError, TypeError): + # If invalid JSON, return empty object as safe default + # This prevents API errors while maintaining compatibility + if _debug.DONT_LOG_TOOL_DATA: + logger.debug( + f"Invalid JSON in tool call arguments for {tool_name}, sanitizing to '{{}}'" + ) + else: + truncated_args = arguments[:100] if len(arguments) > 100 else arguments + logger.debug( + f"Invalid JSON in tool call arguments for {tool_name}: " + f"{truncated_args}, sanitizing to '{{}}'" + ) + return "{}" + @classmethod def convert_tool_choice( cls, tool_choice: Literal["auto", "required", "none"] | str | MCPToolChoice | None @@ -524,7 +564,9 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: pending_thinking_blocks = None # Clear after using tool_calls = list(asst.get("tool_calls", [])) - arguments = func_call["arguments"] if func_call["arguments"] else "{}" + arguments = cls._sanitize_tool_call_arguments( + func_call.get("arguments"), func_call["name"] + ) new_tool_call = ChatCompletionMessageFunctionToolCallParam( id=func_call["call_id"], type="function", diff --git a/tests/test_openai_chatcompletions_converter.py b/tests/test_openai_chatcompletions_converter.py index 838c0eeed..6a79a46c4 100644 --- a/tests/test_openai_chatcompletions_converter.py +++ b/tests/test_openai_chatcompletions_converter.py @@ -379,6 +379,101 @@ def test_tool_call_conversion(): assert tool_call["function"]["arguments"] == function_call["arguments"] # type: ignore +def test_tool_call_with_valid_json_arguments(): + """ + Test that valid JSON arguments pass through unchanged. + """ + function_call = ResponseFunctionToolCallParam( + id="tool1", + call_id="abc", + name="test_tool", + arguments='{"key": "value", "number": 42}', + type="function_call", + ) + + messages = Converter.items_to_messages([function_call]) + tool_call = messages[0]["tool_calls"][0] # type: ignore + assert tool_call["function"]["arguments"] == '{"key": "value", "number": 42}' + + +def test_tool_call_with_invalid_json_arguments(): + """ + Test that invalid JSON arguments are sanitized to "{}". + This prevents API errors when invalid JSON is stored in session history + and later sent to APIs that strictly validate JSON (like Anthropic via litellm). + """ + # Test with missing closing brace (common case) + function_call = ResponseFunctionToolCallParam( + id="tool1", + call_id="abc", + name="test_tool", + arguments='{"key": "value"', # Missing closing brace + type="function_call", + ) + + messages = Converter.items_to_messages([function_call]) + tool_call = messages[0]["tool_calls"][0] # type: ignore + # Invalid JSON should be sanitized to "{}" + assert tool_call["function"]["arguments"] == "{}" + + # Test with None + function_call_none = ResponseFunctionToolCallParam( + id="tool2", + call_id="def", + name="test_tool", + arguments=None, # type: ignore + type="function_call", + ) + + messages_none = Converter.items_to_messages([function_call_none]) + tool_call_none = messages_none[0]["tool_calls"][0] # type: ignore + assert tool_call_none["function"]["arguments"] == "{}" + + # Test with empty string + function_call_empty = ResponseFunctionToolCallParam( + id="tool3", + call_id="ghi", + name="test_tool", + arguments="", + type="function_call", + ) + + messages_empty = Converter.items_to_messages([function_call_empty]) + tool_call_empty = messages_empty[0]["tool_calls"][0] # type: ignore + assert tool_call_empty["function"]["arguments"] == "{}" + + +def test_tool_call_with_malformed_json_variants(): + """ + Test various malformed JSON cases are all sanitized correctly. + """ + malformed_cases = [ + '{"key": "value"', # Missing closing brace + '{"key": "value"}}', # Extra closing brace + '{"key": "value",}', # Trailing comma + '{"key":}', # Missing value + '{key: "value"}', # Unquoted key + '{"key": "value"', # Missing closing brace (different position) + "not json at all", # Not JSON at all + ] + + for i, malformed_json in enumerate(malformed_cases): + function_call = ResponseFunctionToolCallParam( + id=f"tool{i}", + call_id=f"call{i}", + name="test_tool", + arguments=malformed_json, + type="function_call", + ) + + messages = Converter.items_to_messages([function_call]) + tool_call = messages[0]["tool_calls"][0] # type: ignore + # All malformed JSON should be sanitized to "{}" + assert tool_call["function"]["arguments"] == "{}", ( + f"Malformed JSON '{malformed_json}' should be sanitized to '{{}}'" + ) + + @pytest.mark.parametrize("role", ["user", "system", "developer"]) def test_input_message_with_all_roles(role: str): """