Skip to content
Merged
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
2 changes: 1 addition & 1 deletion google/genai/_automatic_function_calling_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ def parse_function_declaration_json_schema(
behavior: Optional[types.Behavior]
) -> types.FunctionDeclaration:
"""Parse function declaration JSON schema from a callable."""
annotation_under_future = typing.get_type_hints(callable)
annotation_under_future = typing.get_type_hints(callable, include_extras=True)
parameters_properties_json_schema = {}
root_defs = {}

Expand Down
10 changes: 9 additions & 1 deletion google/genai/_extra_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,20 @@ def convert_argument_from_function(
) -> _common.StringDict:
signature = inspect.signature(function)
func_name = function.__name__
# Without include_extras, Annotated[T, ...] unwraps to runtime type T.
try:
type_hints = typing.get_type_hints(function)
except (NameError, TypeError):
type_hints = {}
converted_args = {}
for param_name, param in signature.parameters.items():
if param_name in args:
annotation = param.annotation
if isinstance(annotation, str) and param_name in type_hints:
annotation = type_hints[param_name]
converted_args[param_name] = convert_if_exist_pydantic_model(
args[param_name],
param.annotation,
annotation,
param_name,
func_name,
)
Expand Down
150 changes: 150 additions & 0 deletions google/genai/tests/afc/test_future_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Tests for invoking AFC functions defined under PEP 563 annotations."""

# !Please DO NOT combine the test in this file with other tests. This file is
# used to test the future annotation, which is the first line of import.
from __future__ import annotations

import typing
from typing import Annotated, Optional

import pydantic
import pytest

from ... import _extra_utils
from ... import errors
from ... import types

if typing.TYPE_CHECKING:
from ...client import Client


class Location(pydantic.BaseModel):
city: str


def _response_with_call(name: str, args: dict[str, object]):
return types.GenerateContentResponse(
candidates=[
types.Candidate(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(name=name, args=args)
)
]
)
)
]
)


def test_invoke_builtin_primitive_types():
"""String annotations of builtin types resolve before the type check."""

def get_weather(city: str, days: int = 3) -> str:
return f'sunny in {city} for {days} days'

result = _extra_utils.invoke_function_from_dict_args(
{'city': 'Paris', 'days': 2}, get_weather
)

assert result == 'sunny in Paris for 2 days'


def test_get_function_response_parts_runs_tool():
"""The tool runs and its result, not a TypeError, goes to the model."""

def get_weather(city: str) -> str:
return f'sunny in {city}'

parts = _extra_utils.get_function_response_parts(
_response_with_call('get_weather', {'city': 'Paris'}),
{'get_weather': get_weather},
)

assert parts[0].function_response.response == {'result': 'sunny in Paris'}


def test_invoke_pydantic_model_and_containers():
"""Pydantic models, lists of them and Optional resolve from strings."""

def find(
location: Location,
locations: list[Location],
days: Optional[int] = None,
) -> list[object]:
return [location, locations, days]

location, locations, days = _extra_utils.invoke_function_from_dict_args(
{
'location': {'city': 'Paris'},
'locations': [{'city': 'Rome'}, {'city': 'Oslo'}],
'days': 2,
},
find,
)

assert location == Location(city='Paris')
assert locations == [Location(city='Rome'), Location(city='Oslo')]
assert days == 2


def test_invoke_annotated_parameter():
"""An Annotated string annotation checks the value against its base type."""

def forecast(days: Annotated[int, pydantic.Field(ge=1, le=7)] = 3) -> int:
return days

assert _extra_utils.invoke_function_from_dict_args({'days': 2}, forecast) == 2


def test_invoke_incompatible_value_raises():
"""A mismatched value raises the same error as with eager annotations."""

def get_weather(city: str) -> str:
return city

with pytest.raises(
errors.UnknownFunctionCallArgumentError, match='not compatible'
):
_extra_utils.invoke_function_from_dict_args({'city': 1}, get_weather)


def test_invoke_unresolvable_hints_falls_back_to_raw_annotations():
"""A TYPE_CHECKING-only name does not raise NameError from the lookup."""

def forecast(days: int = 3, client: Optional[Client] = None) -> int:
del client
return days

assert _extra_utils.invoke_function_from_dict_args({}, forecast) == 3


@pytest.mark.asyncio
async def test_get_function_response_parts_async_runs_tool():
"""The async path runs a coroutine tool defined under PEP 563."""

async def get_weather(city: str) -> str:
return f'sunny in {city}'

parts = await _extra_utils.get_function_response_parts_async(
_response_with_call('get_weather', {'city': 'Paris'}),
{'get_weather': get_weather},
)

assert parts[0].function_response.response == {'result': 'sunny in Paris'}
26 changes: 26 additions & 0 deletions google/genai/tests/types/test_future.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from __future__ import annotations

import copy
from typing import Annotated

import pydantic

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

assert actual_schema_mldev == expected_schema_mldev
assert actual_schema_vertex == expected_schema_vertex


def test_future_annotation_annotated_metadata_json_schema():
"""The JSON schema keeps Annotated metadata from string annotations."""

def func_under_test(
days: Annotated[
int,
pydantic.Field(description='Forecast length in days', ge=1, le=7),
] = 3,
) -> str:
return '123'

actual_schema = types.FunctionDeclaration.from_callable(
client=mldev_client, callable=func_under_test, use_json_schema=True
)

assert actual_schema.parameters_json_schema['properties']['days'] == {
'description': 'Forecast length in days',
'maximum': 7,
'minimum': 1,
'type': 'integer',
'default': 3,
}
Loading