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
60 changes: 58 additions & 2 deletions src/google/adk/models/lite_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,24 @@ def _quote_unquoted_json_object_keys(value: str) -> str:
return "".join(result)


def _parse_tool_call_arguments(arguments: Any) -> Any:
def _parse_tool_call_arguments(
arguments: Any, *, strict: bool = True
) -> Any:
"""Parses LiteLLM tool call arguments.

LiteLLM normally returns OpenAI-compatible tool call arguments as JSON
strings, but some providers can stream a complete tool call whose finalized
argument payload is a Python dict literal or has unquoted object keys. Keep
strict JSON as the primary path, then repair only those complete
object-literal shapes so ADK can still surface the intended function call.

When ``strict=False`` (non-strict mode), all repairs failing produces a
warning log and an empty dict ``{}`` fallback instead of raising.

Args:
arguments: The tool call arguments to parse.
strict: If ``True`` (default), raises ``json.JSONDecodeError`` when all
repairs fail. If ``False``, logs a warning and returns ``{}``.
"""
if not arguments:
return {}
Expand All @@ -206,11 +216,13 @@ def _parse_tool_call_arguments(arguments: Any) -> Any:
except json.JSONDecodeError as exc:
json_error = exc

# Retry with Python literal eval (handles single-quoted keys, etc.).
try:
return ast.literal_eval(arguments)
except (SyntaxError, ValueError):
pass

# Retry after quoting unquoted JSON object keys.
repaired_arguments = _quote_unquoted_json_object_keys(arguments)
if repaired_arguments != arguments:
try:
Expand All @@ -221,6 +233,48 @@ def _parse_tool_call_arguments(arguments: Any) -> Any:
except (SyntaxError, ValueError):
pass

# Retry after stripping Markdown code fences (```json ... ```).
fence_match = re.search(
r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", arguments
)
if fence_match:
candidate = fence_match.group(1).strip()
if candidate != arguments:
try:
return json.loads(candidate)
except json.JSONDecodeError:
try:
return ast.literal_eval(candidate)
except (SyntaxError, ValueError):
pass
# Also try with unquoted-key repair on the fence-stripped content.
repaired_candidate = _quote_unquoted_json_object_keys(candidate)
if repaired_candidate != candidate:
try:
return json.loads(repaired_candidate)
except json.JSONDecodeError:
try:
return ast.literal_eval(repaired_candidate)
except (SyntaxError, ValueError):
pass

# Fall back to the first balanced {…} block (tolerates trailing content).
open_brace = arguments.find("{")
if open_brace != -1:
try:
candidate, _ = _JSON_DECODER.raw_decode(arguments, open_brace)
return candidate
except json.JSONDecodeError:
pass

if not strict:
logger.warning(
"Failed to parse tool call arguments as JSON, falling back to empty"
" args. Arguments (first 200 chars): %s",
arguments[:200],
)
return {}

raise json_error


Expand Down Expand Up @@ -2388,7 +2442,9 @@ def _message_to_generate_content_response(
thought_signature = _extract_thought_signature_from_tool_call(tool_call)
part = types.Part.from_function_call(
name=tool_call.function.name,
args=_parse_tool_call_arguments(tool_call.function.arguments),
args=_parse_tool_call_arguments(
tool_call.function.arguments, strict=False
),
)
function_call = part.function_call
if function_call is None:
Expand Down
65 changes: 65 additions & 0 deletions tests/unittests/models/test_litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from google.adk.models.lite_llm import _model_response_to_chunk
from google.adk.models.lite_llm import _model_response_to_generate_content_response
from google.adk.models.lite_llm import _parse_deepseek_tool_calls_from_text
from google.adk.models.lite_llm import _parse_tool_call_arguments
from google.adk.models.lite_llm import _parse_tool_calls_from_text
from google.adk.models.lite_llm import _redact_file_uri_for_log
from google.adk.models.lite_llm import _redirect_litellm_loggers_to_stdout
Expand Down Expand Up @@ -7076,3 +7077,67 @@ async def test_generate_content_async_omits_tool_choice_when_functions_override(
_, kwargs = mock_acompletion.call_args
assert kwargs.get("tools") is None
assert "tool_choice" not in kwargs


class TestParseToolCallArguments:
"""Tests for _parse_tool_call_arguments."""

def test_valid_json(self):
assert _parse_tool_call_arguments('{"a": 1}') == {"a": 1}

def test_empty(self):
assert _parse_tool_call_arguments(None) == {}
assert _parse_tool_call_arguments("") == {}

def test_already_dict(self):
assert _parse_tool_call_arguments({"a": 1}) == {"a": 1}

def test_unquoted_keys(self):
result = _parse_tool_call_arguments('{a: 1}')
assert result == {"a": 1}

def test_markdown_fence_valid(self):
result = _parse_tool_call_arguments(
"```json\n{\"a\": 1}\n```"
)
assert result == {"a": 1}

def test_markdown_fence_malformed(self):
result = _parse_tool_call_arguments("```\n{a: 1}\n```")
assert result == {"a": 1}

def test_extra_trailing_text(self):
result = _parse_tool_call_arguments('{"a": 1} extra stuff')
assert result == {"a": 1}

def test_truly_malformed_strict_raises(self):
with pytest.raises(json.JSONDecodeError):
_parse_tool_call_arguments("{bad")

def test_truly_malformed_nonstrict_returns_empty(self):
result = _parse_tool_call_arguments("{bad", strict=False)
assert result == {}

def test_trailing_comma(self):
result = _parse_tool_call_arguments('{"a": 1,}')
assert result == {"a": 1}

def test_single_quotes(self):
result = _parse_tool_call_arguments("{'a': 1}")
assert result == {"a": 1}

def test_empty_object(self):
result = _parse_tool_call_arguments("{}")
assert result == {}

def test_nested_objects(self):
result = _parse_tool_call_arguments('{"a": {"b": 2}}')
assert result == {"a": {"b": 2}}

def test_array_values(self):
result = _parse_tool_call_arguments('{"a": [1, 2, 3]}')
assert result == {"a": [1, 2, 3]}

def test_string_with_escaped_chars(self):
result = _parse_tool_call_arguments('{"a": "he\\"llo"}')
assert result == {"a": 'he"llo'}