Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/agents/models/chatcmpl_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions tests/test_openai_chatcompletions_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down