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
16 changes: 16 additions & 0 deletions src/google/adk/models/lite_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from pydantic import BaseModel
from pydantic import Field
from pydantic import PrivateAttr
from pydantic_core import to_jsonable_python
from typing_extensions import NotRequired
from typing_extensions import override
from typing_extensions import Required
Expand Down Expand Up @@ -1292,6 +1293,14 @@ def completion(
def _safe_json_serialize(obj: object) -> str:
"""Convert any Python object to a JSON-serializable type or string.

A tool result can hold values the json module cannot encode, such as
datetimes, Decimals, UUIDs, enums, sets, bytes or Pydantic models (an
``output_schema`` agent run through ``AgentTool`` returns dates and Decimals
this way). Those are converted with ``pydantic_core.to_jsonable_python``, as
the Gemini and Gemma paths do, so the model still gets JSON rather than the
Python repr of the whole payload. Only a payload Pydantic cannot serialize
either falls back to ``str()``.

Args:
obj: The object to serialize.

Expand All @@ -1302,6 +1311,13 @@ def _safe_json_serialize(obj: object) -> str:
try:
# Try direct JSON serialization first
return json.dumps(obj, ensure_ascii=False)
except (TypeError, ValueError, OverflowError, RecursionError):
pass
try:
jsonable = to_jsonable_python(
obj, by_alias=False, exclude_none=True, bytes_mode="base64"
)
return json.dumps(jsonable, ensure_ascii=False)
except (TypeError, ValueError, OverflowError, RecursionError):
return str(obj)

Expand Down
52 changes: 52 additions & 0 deletions tests/unittests/models/test_litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

import base64
import contextlib
import datetime
import decimal
import enum
import json
import logging
import os
Expand All @@ -25,6 +28,7 @@
from unittest.mock import MagicMock
from unittest.mock import Mock
from unittest.mock import patch
import uuid
import warnings

from google.adk.agents.context_cache_config import ContextCacheConfig
Expand Down Expand Up @@ -1224,6 +1228,54 @@ def test_safe_json_serialize_circular_dict_falls_back_to_str():
assert isinstance(_safe_json_serialize(obj), str)


def test_safe_json_serialize_converts_values_json_cannot_encode():
class _Status(enum.Enum):
PAID = "paid"

class _Line(BaseModel):
sku: str
note: str | None = None

payload = {
"due_date": datetime.date(2026, 12, 1),
"total": decimal.Decimal("12.50"),
"status": _Status.PAID,
"id": uuid.UUID("12345678-1234-5678-1234-567812345678"),
"lines": [_Line(sku="A-1")],
"missing": None,
}

assert json.loads(_safe_json_serialize(payload)) == {
"due_date": "2026-12-01",
"total": "12.50",
"status": "paid",
"id": "12345678-1234-5678-1234-567812345678",
"lines": [{"sku": "A-1"}],
"missing": None,
}


@pytest.mark.asyncio
async def test_content_to_message_param_function_response_with_date_is_json():
part = types.Part.from_function_response(
name="extract_invoice",
response={
"due_date": datetime.date(2026, 12, 1),
"total": decimal.Decimal("12.50"),
},
)
part.function_response.id = "tool_call_1"
content = types.Content(role="user", parts=[part])

message = await _content_to_message_param(content)

assert message["role"] == "tool"
assert json.loads(message["content"]) == {
"due_date": "2026-12-01",
"total": "12.50",
}


def test_safe_json_serialize_circular_list_falls_back_to_str():
obj = []
obj.append(obj)
Expand Down
Loading