Skip to content

Commit d162b63

Browse files
speedstorm1copybara-github
authored andcommitted
fix: Parse datetime, date, time, timedelta and UUID string arguments in automatic function calling
fixes #3331 PiperOrigin-RevId: 1018011166
1 parent 9a0e077 commit d162b63

3 files changed

Lines changed: 211 additions & 1 deletion

File tree

‎google/genai/_extra_utils.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import asyncio
1919
import dataclasses
20+
import datetime
2021
import inspect
2122
import io
2223
import logging
@@ -25,6 +26,7 @@
2526
import sys
2627
import typing
2728
from typing import Any, Callable, Dict, Optional, Type, TypeVar, Union, get_args, get_origin
29+
import uuid
2830

2931
import pydantic
3032

@@ -245,6 +247,31 @@ def is_annotation_pydantic_model(annotation: Any) -> bool:
245247
return False
246248

247249

250+
# Function declarations describe these as strings with a JSON schema format.
251+
_STRING_ENCODED_TYPES = (
252+
datetime.datetime,
253+
datetime.date,
254+
datetime.time,
255+
datetime.timedelta,
256+
uuid.UUID,
257+
)
258+
259+
260+
def _convert_string_encoded_value(
261+
value: str, annotation: Any, param_name: str, func_name: str
262+
) -> Any:
263+
"""Parses a string argument into one of _STRING_ENCODED_TYPES."""
264+
try:
265+
return pydantic.TypeAdapter(annotation).validate_python(value)
266+
except pydantic.ValidationError as e:
267+
raise errors.UnknownFunctionCallArgumentError(
268+
f'Failed to parse parameter {param_name} for function'
269+
f' {func_name} from function call part because function call argument'
270+
f' value {value} is not compatible with parameter annotation'
271+
f' {annotation}, due to error {e}'
272+
) from e
273+
274+
248275
def convert_if_exist_pydantic_model(
249276
value: Any, annotation: Any, param_name: str, func_name: str
250277
) -> Any:
@@ -274,6 +301,14 @@ def convert_if_exist_pydantic_model(
274301
# example 2: int | float equivalent to UnionType[int, float]
275302
if get_origin(annotation) in (Union, UnionType):
276303
for arg in get_args(annotation):
304+
if isinstance(value, str) and arg in _STRING_ENCODED_TYPES:
305+
try:
306+
return _convert_string_encoded_value(
307+
value, arg, param_name, func_name
308+
)
309+
# do not raise here because a later type in the union can match.
310+
except errors.UnknownFunctionCallArgumentError:
311+
continue
277312
if (
278313
(get_args(arg) and get_origin(arg) is list)
279314
or isinstance(value, arg)
@@ -294,6 +329,10 @@ def convert_if_exist_pydantic_model(
294329
f' value {value} cannot be converted to parameter annotation'
295330
f' {annotation}.'
296331
)
332+
if isinstance(value, str) and annotation in _STRING_ENCODED_TYPES:
333+
return _convert_string_encoded_value(
334+
value, annotation, param_name, func_name
335+
)
297336
# the only exception for value and annotation type to be different is int and
298337
# float. see convert_number_values_for_function_call_args function for context
299338
if isinstance(value, int) and annotation is float:

‎google/genai/tests/afc/test_convert_if_exist_pydantic_model.py‎

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616

1717
"""Test convert_if_exist_pydantic_model."""
1818

19+
import datetime
1920
import inspect
21+
import sys
2022
from typing import Optional, Union
23+
import uuid
2124
import pydantic
2225
import pytest
23-
import sys
2426
from ... import errors
2527
from ..._extra_utils import convert_if_exist_pydantic_model
2628

@@ -307,3 +309,113 @@ def test_incompatible_value_and_annotation():
307309
'param_name',
308310
'func_name',
309311
)
312+
313+
314+
def test_string_encoded_types():
315+
"""Strings for date, time, duration and UUID annotations are parsed."""
316+
assert convert_if_exist_pydantic_model(
317+
'2026-11-10T09:30:00Z', datetime.datetime, 'param_name', 'func_name'
318+
) == datetime.datetime(2026, 11, 10, 9, 30, tzinfo=datetime.timezone.utc)
319+
assert convert_if_exist_pydantic_model(
320+
'2026-11-10T09:30:00', datetime.datetime, 'param_name', 'func_name'
321+
) == datetime.datetime(2026, 11, 10, 9, 30)
322+
assert convert_if_exist_pydantic_model(
323+
'12345678-1234-5678-1234-567812345678',
324+
uuid.UUID,
325+
'param_name',
326+
'func_name',
327+
) == uuid.UUID('12345678-1234-5678-1234-567812345678')
328+
assert convert_if_exist_pydantic_model(
329+
'2026-11-10', datetime.date, 'param_name', 'func_name'
330+
) == datetime.date(2026, 11, 10)
331+
assert convert_if_exist_pydantic_model(
332+
'09:30:00', datetime.time, 'param_name', 'func_name'
333+
) == datetime.time(9, 30)
334+
assert convert_if_exist_pydantic_model(
335+
'PT1H', datetime.timedelta, 'param_name', 'func_name'
336+
) == datetime.timedelta(hours=1)
337+
value = datetime.datetime(2026, 11, 10, 9, 30)
338+
assert (
339+
convert_if_exist_pydantic_model(
340+
value, datetime.datetime, 'param_name', 'func_name'
341+
)
342+
is value
343+
)
344+
345+
346+
def test_string_encoded_types_in_containers_and_unions():
347+
"""Parsing applies inside list, dict and union annotations."""
348+
assert convert_if_exist_pydantic_model(
349+
'2026-11-10T09:30:00Z',
350+
Optional[datetime.datetime],
351+
'param_name',
352+
'func_name',
353+
) == datetime.datetime(2026, 11, 10, 9, 30, tzinfo=datetime.timezone.utc)
354+
assert (
355+
convert_if_exist_pydantic_model(
356+
None, Optional[uuid.UUID], 'param_name', 'func_name'
357+
)
358+
is None
359+
)
360+
if sys.version_info >= (3, 10):
361+
assert convert_if_exist_pydantic_model(
362+
'12345678-1234-5678-1234-567812345678',
363+
uuid.UUID | None,
364+
'param_name',
365+
'func_name',
366+
) == uuid.UUID('12345678-1234-5678-1234-567812345678')
367+
assert convert_if_exist_pydantic_model(
368+
['2026-11-10', '2026-11-11'],
369+
list[datetime.date],
370+
'param_name',
371+
'func_name',
372+
) == [datetime.date(2026, 11, 10), datetime.date(2026, 11, 11)]
373+
assert convert_if_exist_pydantic_model(
374+
{'a': '12345678-1234-5678-1234-567812345678'},
375+
dict[str, uuid.UUID],
376+
'param_name',
377+
'func_name',
378+
) == {'a': uuid.UUID('12345678-1234-5678-1234-567812345678')}
379+
assert (
380+
convert_if_exist_pydantic_model(
381+
'not a date',
382+
Union[datetime.datetime, str],
383+
'param_name',
384+
'func_name',
385+
)
386+
== 'not a date'
387+
)
388+
assert (
389+
convert_if_exist_pydantic_model(
390+
'2026-11-10', Union[str, datetime.date], 'param_name', 'func_name'
391+
)
392+
== '2026-11-10'
393+
)
394+
395+
396+
def test_invalid_string_encoded_value_raises():
397+
"""Unparseable or non-string values for these annotations raise."""
398+
with pytest.raises(
399+
errors.UnknownFunctionCallArgumentError, match='due to error'
400+
):
401+
convert_if_exist_pydantic_model(
402+
'not a date', datetime.datetime, 'param_name', 'func_name'
403+
)
404+
with pytest.raises(
405+
errors.UnknownFunctionCallArgumentError, match='due to error'
406+
):
407+
convert_if_exist_pydantic_model(
408+
'nope', uuid.UUID, 'param_name', 'func_name'
409+
)
410+
with pytest.raises(
411+
errors.UnknownFunctionCallArgumentError, match='not compatible'
412+
):
413+
convert_if_exist_pydantic_model(
414+
1700000000, datetime.datetime, 'param_name', 'func_name'
415+
)
416+
with pytest.raises(
417+
errors.UnknownFunctionCallArgumentError, match='cannot be converted'
418+
):
419+
convert_if_exist_pydantic_model(
420+
'bad', Optional[uuid.UUID], 'param_name', 'func_name'
421+
)

‎google/genai/tests/afc/test_get_function_response_parts.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616

1717
"""Tests for get_function_response_parts."""
1818

19+
import datetime
1920
import typing
2021
from typing import Any
22+
import uuid
2123
import pytest
2224
from ..._extra_utils import get_function_response_parts, get_function_response_parts_async
2325
from ...errors import UnsupportedFunctionError
@@ -160,6 +162,63 @@ def func_under_test(a: str) -> str:
160162
) == expected_part.model_dump_json(exclude_none=True)
161163

162164

165+
def _datetime_and_uuid_response() -> GenerateContentResponse:
166+
return GenerateContentResponse(
167+
candidates=[
168+
Candidate(
169+
content=Content(
170+
parts=[
171+
Part(
172+
function_call=FunctionCall(
173+
name='func_under_test',
174+
args={
175+
'when': '2026-11-10T09:30:00Z',
176+
'job_id': (
177+
'12345678-1234-5678-1234-567812345678'
178+
),
179+
},
180+
)
181+
)
182+
]
183+
)
184+
)
185+
]
186+
)
187+
188+
189+
def _datetime_and_uuid_func(when: datetime.datetime, job_id: uuid.UUID) -> str:
190+
assert isinstance(when, datetime.datetime)
191+
assert isinstance(job_id, uuid.UUID)
192+
return f'{when.isoformat()} {job_id}'
193+
194+
195+
def test_datetime_and_uuid_values():
196+
"""The tool receives datetime and UUID objects parsed from JSON strings."""
197+
function_map = {'func_under_test': _datetime_and_uuid_func}
198+
actual_parts = get_function_response_parts(
199+
_datetime_and_uuid_response(), function_map
200+
)
201+
202+
assert len(actual_parts) == 1
203+
assert actual_parts[0].function_response.response == {
204+
'result': '2026-11-10T09:30:00+00:00 12345678-1234-5678-1234-567812345678'
205+
}
206+
207+
208+
@pytest.mark.asyncio
209+
async def test_datetime_and_uuid_values_async():
210+
"""The async path parses datetime and UUID strings the same way."""
211+
function_map = {'func_under_test': _datetime_and_uuid_func}
212+
actual_parts = await get_function_response_parts_async(
213+
_datetime_and_uuid_response(), function_map
214+
)
215+
216+
assert len(actual_parts) == 1
217+
assert actual_parts[0].function_response.response == {
218+
'result': '2026-11-10T09:30:00+00:00 12345678-1234-5678-1234-567812345678'
219+
}
220+
221+
163222
@pytest.mark.asyncio
164223
async def test_mcp_tool():
165224
if not _is_mcp_imported:

0 commit comments

Comments
 (0)