Skip to content

Commit 31c30d1

Browse files
speedstorm1copybara-github
authored andcommitted
fix: Resolve PEP 563 string annotations when invoking AFC tools
fixes #3156 PiperOrigin-RevId: 1003731945
1 parent 1a64450 commit 31c30d1

4 files changed

Lines changed: 186 additions & 2 deletions

File tree

‎google/genai/_automatic_function_calling_util.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ def parse_function_declaration_json_schema(
347347
behavior: Optional[types.Behavior]
348348
) -> types.FunctionDeclaration:
349349
"""Parse function declaration JSON schema from a callable."""
350-
annotation_under_future = typing.get_type_hints(callable)
350+
annotation_under_future = typing.get_type_hints(callable, include_extras=True)
351351
parameters_properties_json_schema = {}
352352
root_defs = {}
353353

‎google/genai/_extra_utils.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,20 @@ def convert_argument_from_function(
312312
) -> _common.StringDict:
313313
signature = inspect.signature(function)
314314
func_name = function.__name__
315+
# Without include_extras, Annotated[T, ...] unwraps to runtime type T.
316+
try:
317+
type_hints = typing.get_type_hints(function)
318+
except (NameError, TypeError):
319+
type_hints = {}
315320
converted_args = {}
316321
for param_name, param in signature.parameters.items():
317322
if param_name in args:
323+
annotation = param.annotation
324+
if isinstance(annotation, str) and param_name in type_hints:
325+
annotation = type_hints[param_name]
318326
converted_args[param_name] = convert_if_exist_pydantic_model(
319327
args[param_name],
320-
param.annotation,
328+
annotation,
321329
param_name,
322330
func_name,
323331
)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
16+
"""Tests for invoking AFC functions defined under PEP 563 annotations."""
17+
18+
# !Please DO NOT combine the test in this file with other tests. This file is
19+
# used to test the future annotation, which is the first line of import.
20+
from __future__ import annotations
21+
22+
import typing
23+
from typing import Annotated, Optional
24+
25+
import pydantic
26+
import pytest
27+
28+
from ... import _extra_utils
29+
from ... import errors
30+
from ... import types
31+
32+
if typing.TYPE_CHECKING:
33+
from ...client import Client
34+
35+
36+
class Location(pydantic.BaseModel):
37+
city: str
38+
39+
40+
def _response_with_call(name: str, args: dict[str, object]):
41+
return types.GenerateContentResponse(
42+
candidates=[
43+
types.Candidate(
44+
content=types.Content(
45+
parts=[
46+
types.Part(
47+
function_call=types.FunctionCall(name=name, args=args)
48+
)
49+
]
50+
)
51+
)
52+
]
53+
)
54+
55+
56+
def test_invoke_builtin_primitive_types():
57+
"""String annotations of builtin types resolve before the type check."""
58+
59+
def get_weather(city: str, days: int = 3) -> str:
60+
return f'sunny in {city} for {days} days'
61+
62+
result = _extra_utils.invoke_function_from_dict_args(
63+
{'city': 'Paris', 'days': 2}, get_weather
64+
)
65+
66+
assert result == 'sunny in Paris for 2 days'
67+
68+
69+
def test_get_function_response_parts_runs_tool():
70+
"""The tool runs and its result, not a TypeError, goes to the model."""
71+
72+
def get_weather(city: str) -> str:
73+
return f'sunny in {city}'
74+
75+
parts = _extra_utils.get_function_response_parts(
76+
_response_with_call('get_weather', {'city': 'Paris'}),
77+
{'get_weather': get_weather},
78+
)
79+
80+
assert parts[0].function_response.response == {'result': 'sunny in Paris'}
81+
82+
83+
def test_invoke_pydantic_model_and_containers():
84+
"""Pydantic models, lists of them and Optional resolve from strings."""
85+
86+
def find(
87+
location: Location,
88+
locations: list[Location],
89+
days: Optional[int] = None,
90+
) -> list[object]:
91+
return [location, locations, days]
92+
93+
location, locations, days = _extra_utils.invoke_function_from_dict_args(
94+
{
95+
'location': {'city': 'Paris'},
96+
'locations': [{'city': 'Rome'}, {'city': 'Oslo'}],
97+
'days': 2,
98+
},
99+
find,
100+
)
101+
102+
assert location == Location(city='Paris')
103+
assert locations == [Location(city='Rome'), Location(city='Oslo')]
104+
assert days == 2
105+
106+
107+
def test_invoke_annotated_parameter():
108+
"""An Annotated string annotation checks the value against its base type."""
109+
110+
def forecast(days: Annotated[int, pydantic.Field(ge=1, le=7)] = 3) -> int:
111+
return days
112+
113+
assert _extra_utils.invoke_function_from_dict_args({'days': 2}, forecast) == 2
114+
115+
116+
def test_invoke_incompatible_value_raises():
117+
"""A mismatched value raises the same error as with eager annotations."""
118+
119+
def get_weather(city: str) -> str:
120+
return city
121+
122+
with pytest.raises(
123+
errors.UnknownFunctionCallArgumentError, match='not compatible'
124+
):
125+
_extra_utils.invoke_function_from_dict_args({'city': 1}, get_weather)
126+
127+
128+
def test_invoke_unresolvable_hints_falls_back_to_raw_annotations():
129+
"""A TYPE_CHECKING-only name does not raise NameError from the lookup."""
130+
131+
def forecast(days: int = 3, client: Optional[Client] = None) -> int:
132+
del client
133+
return days
134+
135+
assert _extra_utils.invoke_function_from_dict_args({}, forecast) == 3
136+
137+
138+
@pytest.mark.asyncio
139+
async def test_get_function_response_parts_async_runs_tool():
140+
"""The async path runs a coroutine tool defined under PEP 563."""
141+
142+
async def get_weather(city: str) -> str:
143+
return f'sunny in {city}'
144+
145+
parts = await _extra_utils.get_function_response_parts_async(
146+
_response_with_call('get_weather', {'city': 'Paris'}),
147+
{'get_weather': get_weather},
148+
)
149+
150+
assert parts[0].function_response.response == {'result': 'sunny in Paris'}

‎google/genai/tests/types/test_future.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from __future__ import annotations
1919

2020
import copy
21+
from typing import Annotated
22+
2123
import pydantic
2224

2325
from ... import types
@@ -99,3 +101,27 @@ def func_under_test(param_1: ComplexType, param_2: int) -> str:
99101

100102
assert actual_schema_mldev == expected_schema_mldev
101103
assert actual_schema_vertex == expected_schema_vertex
104+
105+
106+
def test_future_annotation_annotated_metadata_json_schema():
107+
"""The JSON schema keeps Annotated metadata from string annotations."""
108+
109+
def func_under_test(
110+
days: Annotated[
111+
int,
112+
pydantic.Field(description='Forecast length in days', ge=1, le=7),
113+
] = 3,
114+
) -> str:
115+
return '123'
116+
117+
actual_schema = types.FunctionDeclaration.from_callable(
118+
client=mldev_client, callable=func_under_test, use_json_schema=True
119+
)
120+
121+
assert actual_schema.parameters_json_schema['properties']['days'] == {
122+
'description': 'Forecast length in days',
123+
'maximum': 7,
124+
'minimum': 1,
125+
'type': 'integer',
126+
'default': 3,
127+
}

0 commit comments

Comments
 (0)