Files
google--adk-python/tests/unittests/tools/test_from_function_with_options.py
2026-08-06 00:33:59 -07:00

695 lines
24 KiB
Python

# 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.
from collections.abc import Sequence
from typing import Any
from typing import AsyncGenerator
from typing import Dict
from typing import Generator
from unittest import mock
from google.adk.tools import _automatic_function_calling_util
from google.adk.utils.variant_utils import GoogleLLMVariant
from google.genai import types
import pydantic
import pytest
def test_from_function_with_options_no_return_annotation_gemini():
"""Test from_function_with_options with no return annotation for GEMINI_API."""
def test_function(param: str):
"""A test function with no return annotation."""
return None
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.GEMINI_API
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# GEMINI_API should not have response schema
assert declaration.response is None
def test_from_function_with_options_no_return_annotation_vertex():
"""Test from_function_with_options with no return annotation for VERTEX_AI."""
def test_function(param: str):
"""A test function with no return annotation."""
return None
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should have response schema for functions with no return annotation
# Changed: Now uses Any type instead of NULL for no return annotation
assert declaration.response is not None
assert declaration.response.type is None # Any type maps to None in schema
def test_from_function_with_options_explicit_none_return_vertex():
"""Test from_function_with_options with explicit None return for VERTEX_AI."""
def test_function(param: str) -> None:
"""A test function that explicitly returns None."""
pass
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should have response schema for explicit None return
assert declaration.response is not None
assert declaration.response.type == types.Type.NULL
def test_from_function_with_options_explicit_none_return_gemini():
"""Test from_function_with_options with explicit None return for GEMINI_API."""
def test_function(param: str) -> None:
"""A test function that explicitly returns None."""
pass
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.GEMINI_API
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# GEMINI_API should not have response schema
assert declaration.response is None
def test_from_function_with_options_string_return_vertex():
"""Test from_function_with_options with string return for VERTEX_AI."""
def test_function(param: str) -> str:
"""A test function that returns a string."""
return param
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should have response schema for string return
assert declaration.response is not None
assert declaration.response.type == types.Type.STRING
def test_from_function_with_options_dict_return_vertex():
"""Test from_function_with_options with dict return for VERTEX_AI."""
def test_function(param: str) -> Dict[str, str]:
"""A test function that returns a dict."""
return {'result': param}
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should have response schema for dict return
assert declaration.response is not None
assert declaration.response.type == types.Type.OBJECT
def test_from_function_with_options_int_return_vertex():
"""Test from_function_with_options with int return for VERTEX_AI."""
def test_function(param: str) -> int:
"""A test function that returns an int."""
return 42
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should have response schema for int return
assert declaration.response is not None
assert declaration.response.type == types.Type.INTEGER
def test_from_function_with_options_any_annotation_vertex():
"""Test from_function_with_options with Any type annotation for VERTEX_AI."""
def test_function(param: Any) -> Any:
"""A test function that uses Any type annotations."""
return param
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
# Any type should map to None in schema (TYPE_UNSPECIFIED behavior)
assert declaration.parameters.properties['param'].type is None
# VERTEX_AI should have response schema for Any return
assert declaration.response is not None
assert declaration.response.type is None # Any type maps to None in schema
def test_from_function_with_options_no_params():
"""Test from_function_with_options with no parameters."""
def test_function() -> None:
"""A test function with no parameters that returns None."""
pass
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
# No parameters should result in no parameters field or empty parameters
assert (
declaration.parameters is None
or len(declaration.parameters.properties) == 0
)
# VERTEX_AI should have response schema for None return
assert declaration.response is not None
assert declaration.response.type == types.Type.NULL
def test_from_function_with_collections_type_parameter():
"""Test from_function_with_options with collections type parameter."""
def test_function(
artifact_key: str,
input_edit_ids: Sequence[str],
) -> str:
"""Saves a sequence of edit IDs."""
return f'Saved {len(input_edit_ids)} edit IDs for artifact {artifact_key}'
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == types.Type.OBJECT
assert (
declaration.parameters.properties['artifact_key'].type
== types.Type.STRING
)
assert (
declaration.parameters.properties['input_edit_ids'].type
== types.Type.ARRAY
)
assert (
declaration.parameters.properties['input_edit_ids'].items.type
== types.Type.STRING
)
assert declaration.response.type == types.Type.STRING
def test_from_function_with_tuple_type_parameter():
"""Test from_function_with_options with fixed-size homogeneous tuple."""
def test_function(
coordinate: tuple[float, float],
) -> str:
"""Formats a coordinate pair."""
return f'{coordinate[0]}, {coordinate[1]}'
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == types.Type.OBJECT
coordinate_schema = declaration.parameters.properties['coordinate']
assert coordinate_schema.type == types.Type.ARRAY
assert coordinate_schema.items.type == types.Type.NUMBER
# Fixed-size tuples pin the array length so the model emits exactly the
# expected number of items.
assert coordinate_schema.min_items == 2
assert coordinate_schema.max_items == 2
assert declaration.response.type == types.Type.STRING
def test_from_function_with_variadic_tuple_type_parameter():
"""Test from_function_with_options with variable-length homogeneous tuple."""
def test_function(
tags: tuple[str, ...],
) -> str:
"""Joins tags."""
return ', '.join(tags)
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
tags_schema = declaration.parameters.properties['tags']
assert tags_schema.type == types.Type.ARRAY
assert tags_schema.items.type == types.Type.STRING
# Variadic tuples are unbounded, so no size constraints are set.
assert tags_schema.min_items is None
assert tags_schema.max_items is None
def test_from_function_with_collections_return_type():
"""Test from_function_with_options with collections return type."""
def test_function(
names: list[str],
) -> Sequence[str]:
"""Returns a sequence of names."""
return names
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.response.type == types.Type.ARRAY
assert declaration.response.items.type == types.Type.STRING
def test_from_function_with_async_generator_return_vertex():
"""Test from_function_with_options with AsyncGenerator return for VERTEX_AI."""
async def test_function(param: str) -> AsyncGenerator[str, None]:
"""A streaming function that yields strings."""
yield param
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should extract yield type (str) from AsyncGenerator[str, None]
assert declaration.response is not None
assert declaration.response.type == types.Type.STRING
def test_from_function_with_async_generator_return_gemini():
"""Test from_function_with_options with AsyncGenerator return for GEMINI_API."""
async def test_function(param: str) -> AsyncGenerator[str, None]:
"""A streaming function that yields strings."""
yield param
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.GEMINI_API
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# GEMINI_API should not have response schema
assert declaration.response is None
def test_from_function_with_generator_return_vertex():
"""Test from_function_with_options with Generator return for VERTEX_AI."""
def test_function(param: str) -> Generator[int, None, None]:
"""A streaming function that yields integers."""
yield 42
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should extract yield type (int) from Generator[int, None, None]
assert declaration.response is not None
assert declaration.response.type == types.Type.INTEGER
def test_from_function_with_async_generator_complex_yield_type_vertex():
"""Test from_function_with_options with AsyncGenerator yielding dict."""
async def test_function(param: str) -> AsyncGenerator[Dict[str, str], None]:
"""A streaming function that yields dicts."""
yield {'result': param}
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
# VERTEX_AI should extract yield type (Dict[str, str]) from AsyncGenerator
assert declaration.response is not None
assert declaration.response.type == types.Type.OBJECT
def test_required_fields_set_with_optional_tuple_parameter():
"""Test that required fields are populated with optional tuple parameters."""
def complex_tool(
query: str,
mode: str = 'default',
tags: tuple[str, ...] | None = None,
) -> str:
"""A tool where one param has a complex union type."""
return query
declaration = _automatic_function_calling_util.from_function_with_options(
complex_tool, GoogleLLMVariant.GEMINI_API
)
assert declaration.name == 'complex_tool'
assert declaration.parameters == types.Schema(
type=types.Type.OBJECT,
required=['query'],
properties={
'query': types.Schema(type=types.Type.STRING),
'mode': types.Schema(type=types.Type.STRING, default='default'),
'tags': types.Schema(
items=types.Schema(type=types.Type.STRING),
nullable=True,
type=types.Type.ARRAY,
),
},
)
def test_required_fields_set_in_json_schema_fallback():
"""Required fields are populated when the json_schema fallback path is used.
A parameter whose type `_parse_schema_from_parameter` cannot handle (here
`Sequence[str]`) forces from_function_with_options onto the pydantic
json_schema fallback branch. This verifies that branch still derives required
fields correctly: parameters without defaults are required, parameters with
defaults are not.
"""
def complex_tool(
query: str,
items: Sequence[str],
mode: str = 'default',
) -> str:
return query
declaration = _automatic_function_calling_util.from_function_with_options(
complex_tool, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'complex_tool'
assert declaration.parameters.type == types.Type.OBJECT
# query and items have no defaults -> required; mode has a default -> not.
assert set(declaration.parameters.required) == {'query', 'items'}
assert declaration.parameters.properties['items'].type == types.Type.ARRAY
assert declaration.parameters.properties['mode'].default == 'default'
def test_schema_sanitization_for_complex_union_type():
"""Test schema is sanitized for complex union type."""
def complex_tool(
query: str,
mode: str = 'default',
tags: dict[str, str] | None = None,
) -> str:
return query
declaration = _automatic_function_calling_util.from_function_with_options(
complex_tool, GoogleLLMVariant.GEMINI_API
)
assert declaration.parameters.properties['tags'] == types.Schema(
type=types.Type.OBJECT,
nullable=True,
additional_properties=types.Schema(type=types.Type.STRING),
)
def test_format_preservation_for_vertex_fallback():
"""Test that format is preserved for VERTEX_AI variant in fallback path."""
class ComplexModel(pydantic.BaseModel):
# Field with format that would be stripped by Gemini sanitization
email: str = pydantic.Field(json_schema_extra={'format': 'email'})
# Complex field to trigger fallback (Sequence is not handled by
# _parse_schema_from_parameter)
complex_field: Sequence[str]
def my_tool(param: ComplexModel) -> str:
return f'ok {param}'
# Run with VERTEX_AI, should preserve format
declaration_vertex = (
_automatic_function_calling_util.from_function_with_options(
my_tool, GoogleLLMVariant.VERTEX_AI
)
)
# Check that format is preserved
param_schema_vertex = declaration_vertex.parameters.properties['param']
assert param_schema_vertex.properties['email'].format == 'email'
# Run with GEMINI_API, should strip format (current behavior)
declaration_gemini = (
_automatic_function_calling_util.from_function_with_options(
my_tool, GoogleLLMVariant.GEMINI_API
)
)
param_schema_gemini = declaration_gemini.parameters.properties['param']
assert param_schema_gemini.properties['email'].format is None
def test_tuple_types_work_in_json_schema_fallback() -> None:
"""Test that tuple schemas work in json schema fallback."""
def generate_image(
prompt: str,
input_bytes: list[tuple[bytes, str]] | None = None,
) -> dict[str, str]:
"""Generate an image from a prompt."""
del input_bytes
return {'status': prompt}
declaration = _automatic_function_calling_util.from_function_with_options(
generate_image, GoogleLLMVariant.GEMINI_API
)
assert declaration.parameters is not None
assert declaration.parameters.required == ['prompt']
input_bytes_schema = declaration.parameters.properties['input_bytes']
assert input_bytes_schema.nullable
assert input_bytes_schema.any_of is not None
array_schema = next(
schema
for schema in input_bytes_schema.any_of
if schema.type == types.Type.ARRAY
)
assert array_schema.items is not None
assert array_schema.items.type == types.Type.ARRAY
assert array_schema.items.max_items == 2
assert array_schema.items.min_items == 2
assert array_schema.items.items is not None
assert array_schema.items.items.any_of is not None
assert len(array_schema.items.items.any_of) == 2
assert array_schema.items.items.any_of[0].type == types.Type.STRING
assert array_schema.items.items.any_of[0].format is None
assert array_schema.items.items.any_of[1].type == types.Type.STRING
def test_from_function_with_options_any_type_with_default_value():
"""Test that typing.Any with a default value works and doesn't crash."""
def my_tool(param: Any = 'default_string') -> str:
return f'ok {param}'
declaration = _automatic_function_calling_util.from_function_with_options(
my_tool, GoogleLLMVariant.GEMINI_API
)
assert declaration.parameters is not None
assert declaration.parameters.properties['param'].default == 'default_string'
# Any type maps to None (no type) in schema
assert declaration.parameters.properties['param'].type is None
class _UnserializableReturn:
"""A plain class that has no genai/JSON schema representation."""
def test_from_function_with_options_unserializable_return_vertex_degrades_gracefully():
"""VERTEX_AI omits the response schema instead of raising when it can't be derived."""
def test_function(param: str) -> _UnserializableReturn:
"""A function whose return type cannot be turned into a schema."""
return _UnserializableReturn()
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
# Parameters are still populated; only the return schema is dropped.
assert declaration.parameters.type == 'OBJECT'
assert declaration.parameters.properties['param'].type == 'STRING'
assert declaration.response is None
def test_from_function_with_options_logs_warning_on_return_schema_failure(
caplog,
):
"""A warning naming the function is emitted when the return schema is dropped."""
def test_function(param: str) -> _UnserializableReturn:
"""A function whose return type cannot be turned into a schema."""
return _UnserializableReturn()
with caplog.at_level(
'WARNING',
logger='google_adk.google.adk.tools._automatic_function_calling_util',
):
_automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
warnings = [r for r in caplog.records if r.levelname == 'WARNING']
assert len(warnings) == 1
assert 'test_function' in warnings[0].getMessage()
def test_from_function_with_options_valid_pydantic_return_still_gets_schema_vertex():
"""A serializable pydantic return type keeps producing a response schema."""
class MyModel(pydantic.BaseModel):
result: str
def test_function(param: str) -> MyModel:
"""A function that returns a valid pydantic model."""
return MyModel(result=param)
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.response is not None
assert declaration.response.type == types.Type.OBJECT
def test_from_function_with_options_non_value_error_return_degrades_gracefully(
monkeypatch,
):
"""A non-ValueError from schema parsing is caught (not propagated) and degrades."""
parse_util = _automatic_function_calling_util._function_parameter_parse_util
original_parse = parse_util._parse_schema_from_parameter
def _raise_type_error_for_return(variant, param, func_name):
# Only the return-schema parse should raise; leave parameter parsing intact.
if param.name == 'return_value':
raise TypeError('simulated non-ValueError from schema parsing')
return original_parse(variant, param, func_name)
monkeypatch.setattr(
parse_util,
'_parse_schema_from_parameter',
_raise_type_error_for_return,
)
def test_function(param: str) -> _UnserializableReturn:
"""A function whose return schema parsing raises a non-ValueError."""
return _UnserializableReturn()
declaration = _automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
assert declaration.name == 'test_function'
assert declaration.response is None
def test_from_function_with_options_warning_includes_original_error(caplog):
"""The warning names both the fallback and the original parsing error."""
def test_function(param: str) -> _UnserializableReturn:
"""A function whose return type cannot be turned into a schema."""
return _UnserializableReturn()
with caplog.at_level(
'WARNING',
logger='google_adk.google.adk.tools._automatic_function_calling_util',
):
_automatic_function_calling_util.from_function_with_options(
test_function, GoogleLLMVariant.VERTEX_AI
)
warnings = [r for r in caplog.records if r.levelname == 'WARNING']
assert len(warnings) == 1
message = warnings[0].getMessage()
assert 'Fallback error:' in message
assert 'Original error:' in message
def test_optional_arg_does_not_double_serialize_for_dedup():
"""Each union member is serialized at most once during any_of deduplication.
The dedup loop previously called `Schema.model_dump_json` twice per union
member (once for the membership check, once for the set add). For `T | None`
(a 2-member union that always reduces to a single any_of entry) that doubled
the cost on every parse.
"""
def tool_with_optionals(
a: str | None = None,
b: int | None = None,
c: str | int = 'x',
) -> str:
"""A tool whose params exercise the optional/union dedup path."""
return f'{a}{b}{c}'
call_count = 0
real_dump = types.Schema.model_dump_json
def counting_dump(self, *args, **kwargs):
nonlocal call_count
call_count += 1
return real_dump(self, *args, **kwargs)
with mock.patch.object(types.Schema, 'model_dump_json', counting_dump):
_automatic_function_calling_util.from_function_with_options(
tool_with_optionals, GoogleLLMVariant.GEMINI_API
)
# 2 `| None` args (1 non-None member) + 1 union arg (2 non-None members)
# = 4 calls after the fix. Before, this was 8 (every member was serialized
# twice — once for the membership check, once for the set add).
assert (
call_count == 4
), f'expected 4 model_dump_json calls during dedup, got {call_count}'