diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index a854c0104b..df9593ae90 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -187,7 +187,9 @@ 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 @@ -195,6 +197,14 @@ def _parse_tool_call_arguments(arguments: Any) -> Any: 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 {} @@ -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: @@ -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 @@ -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: diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 68b0e74c17..dbb72c25fe 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -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 @@ -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'}