fix: fix parameter schema generation for gemini

this fixes https://github.com/google/adk-python/issues/1055
and https://github.com/google/adk-python/issues/881

PiperOrigin-RevId: 766288394
This commit is contained in:
Xiang (Sean) Zhou 2025-06-02 12:02:26 -07:00 committed by Copybara-Service
parent f7cb66620b
commit 5a67a946d2
12 changed files with 582 additions and 457 deletions

View File

@ -0,0 +1,130 @@
# Copyright 2025 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 __future__ import annotations
import re
from typing import Any
from typing import Optional
from google.genai.types import JSONSchema
from google.genai.types import Schema
from pydantic import Field
from ..utils.variant_utils import get_google_llm_variant
class _ExtendedJSONSchema(JSONSchema):
property_ordering: Optional[list[str]] = Field(
default=None,
description="""Optional. The order of the properties. Not a standard field in open api spec. Only used to support the order of the properties.""",
)
def _to_snake_case(text: str) -> str:
"""Converts a string into snake_case.
Handles lowerCamelCase, UpperCamelCase, or space-separated case, acronyms
(e.g., "REST API") and consecutive uppercase letters correctly. Also handles
mixed cases with and without spaces.
Examples:
```
to_snake_case('camelCase') -> 'camel_case'
to_snake_case('UpperCamelCase') -> 'upper_camel_case'
to_snake_case('space separated') -> 'space_separated'
```
Args:
text: The input string.
Returns:
The snake_case version of the string.
"""
# Handle spaces and non-alphanumeric characters (replace with underscores)
text = re.sub(r"[^a-zA-Z0-9]+", "_", text)
# Insert underscores before uppercase letters (handling both CamelCases)
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text) # lowerCamelCase
text = re.sub(
r"([A-Z]+)([A-Z][a-z])", r"\1_\2", text
) # UpperCamelCase and acronyms
# Convert to lowercase
text = text.lower()
# Remove consecutive underscores (clean up extra underscores)
text = re.sub(r"_+", "_", text)
# Remove leading and trailing underscores
text = text.strip("_")
return text
def _sanitize_schema_formats_for_gemini(schema_node: Any) -> Any:
"""Helper function to sanitize schema formats for Gemini compatibility"""
if isinstance(schema_node, dict):
new_node = {}
current_type = schema_node.get("type")
for key, value in schema_node.items():
key = _to_snake_case(key)
# special handle of format field
if key == "format":
current_format = value
format_to_keep = None
if current_format:
if current_type == "integer" or current_type == "number":
if current_format in ("int32", "int64"):
format_to_keep = current_format
elif current_type == "string":
# only 'enum' and 'date-time' are supported for STRING type"
if current_format in ("date-time", "enum"):
format_to_keep = current_format
# For any other type or unhandled format
# the 'format' key will be effectively removed for that node.
if format_to_keep:
new_node[key] = format_to_keep
continue
# don't change property name
if key == "properties":
new_node[key] = {
k: _sanitize_schema_formats_for_gemini(v) for k, v in value.items()
}
continue
# Recursively sanitize other parts of the schema
new_node[key] = _sanitize_schema_formats_for_gemini(value)
return new_node
elif isinstance(schema_node, list):
return [_sanitize_schema_formats_for_gemini(item) for item in schema_node]
else:
return schema_node
def _to_gemini_schema(openapi_schema: dict[str, Any]) -> Schema:
"""Converts an OpenAPI schema dictionary to a Gemini Schema object."""
if openapi_schema is None:
return None
if not isinstance(openapi_schema, dict):
raise TypeError("openapi_schema must be a dictionary")
openapi_schema = _sanitize_schema_formats_for_gemini(openapi_schema)
return Schema.from_json_schema(
json_schema=_ExtendedJSONSchema.model_validate(openapi_schema),
api_option=get_google_llm_variant(),
)

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
from typing import List from typing import List
from typing import Optional from typing import Optional
@ -23,9 +24,9 @@ import yaml
from ...agents.readonly_context import ReadonlyContext from ...agents.readonly_context import ReadonlyContext
from ...auth.auth_credential import AuthCredential from ...auth.auth_credential import AuthCredential
from ...auth.auth_schemes import AuthScheme from ...auth.auth_schemes import AuthScheme
from .._gemini_schema_util import _to_snake_case
from ..base_toolset import BaseToolset from ..base_toolset import BaseToolset
from ..base_toolset import ToolPredicate from ..base_toolset import ToolPredicate
from ..openapi_tool.common.common import to_snake_case
from ..openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset from ..openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset
from ..openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool from ..openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
from .clients.apihub_client import APIHubClient from .clients.apihub_client import APIHubClient
@ -171,7 +172,7 @@ class APIHubToolset(BaseToolset):
if not spec_dict: if not spec_dict:
return return
self.name = self.name or to_snake_case( self.name = self.name or _to_snake_case(
spec_dict.get('info', {}).get('title', 'unnamed') spec_dict.get('info', {}).get('title', 'unnamed')
) )
self.description = self.description or spec_dict.get('info', {}).get( self.description = self.description or spec_dict.get('info', {}).get(

View File

@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from typing import Dict from typing import Dict
@ -24,8 +26,8 @@ from typing_extensions import override
from .. import BaseTool from .. import BaseTool
from ...auth.auth_credential import AuthCredential from ...auth.auth_credential import AuthCredential
from ...auth.auth_schemes import AuthScheme from ...auth.auth_schemes import AuthScheme
from .._gemini_schema_util import _to_gemini_schema
from ..openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool from ..openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
from ..openapi_tool.openapi_spec_parser.rest_api_tool import to_gemini_schema
from ..openapi_tool.openapi_spec_parser.tool_auth_handler import ToolAuthHandler from ..openapi_tool.openapi_spec_parser.tool_auth_handler import ToolAuthHandler
from ..tool_context import ToolContext from ..tool_context import ToolContext
@ -128,7 +130,7 @@ class IntegrationConnectorTool(BaseTool):
if field in schema_dict['required']: if field in schema_dict['required']:
schema_dict['required'].remove(field) schema_dict['required'].remove(field)
parameters = to_gemini_schema(schema_dict) parameters = _to_gemini_schema(schema_dict)
function_decl = FunctionDeclaration( function_decl = FunctionDeclaration(
name=self.name, description=self.description, parameters=parameters name=self.name, description=self.description, parameters=parameters
) )

View File

@ -20,6 +20,7 @@ from typing import Optional
from google.genai.types import FunctionDeclaration from google.genai.types import FunctionDeclaration
from typing_extensions import override from typing_extensions import override
from .._gemini_schema_util import _to_gemini_schema
from .mcp_session_manager import MCPSessionManager from .mcp_session_manager import MCPSessionManager
from .mcp_session_manager import retry_on_closed_resource from .mcp_session_manager import retry_on_closed_resource
@ -42,7 +43,6 @@ except ImportError as e:
from ...auth.auth_credential import AuthCredential from ...auth.auth_credential import AuthCredential
from ...auth.auth_schemes import AuthScheme from ...auth.auth_schemes import AuthScheme
from ..base_tool import BaseTool from ..base_tool import BaseTool
from ..openapi_tool.openapi_spec_parser.rest_api_tool import to_gemini_schema
from ..tool_context import ToolContext from ..tool_context import ToolContext
logger = logging.getLogger("google_adk." + __name__) logger = logging.getLogger("google_adk." + __name__)
@ -99,7 +99,7 @@ class MCPTool(BaseTool):
FunctionDeclaration: The Gemini function declaration for the tool. FunctionDeclaration: The Gemini function declaration for the tool.
""" """
schema_dict = self._mcp_tool.inputSchema schema_dict = self._mcp_tool.inputSchema
parameters = to_gemini_schema(schema_dict) parameters = _to_gemini_schema(schema_dict)
function_decl = FunctionDeclaration( function_decl = FunctionDeclaration(
name=self.name, description=self.description, parameters=parameters name=self.name, description=self.description, parameters=parameters
) )

View File

@ -12,8 +12,9 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import keyword import keyword
import re
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -26,47 +27,7 @@ from pydantic import BaseModel
from pydantic import Field from pydantic import Field
from pydantic import model_serializer from pydantic import model_serializer
from ..._gemini_schema_util import _to_snake_case
def to_snake_case(text: str) -> str:
"""Converts a string into snake_case.
Handles lowerCamelCase, UpperCamelCase, or space-separated case, acronyms
(e.g., "REST API") and consecutive uppercase letters correctly. Also handles
mixed cases with and without spaces.
Examples:
```
to_snake_case('camelCase') -> 'camel_case'
to_snake_case('UpperCamelCase') -> 'upper_camel_case'
to_snake_case('space separated') -> 'space_separated'
```
Args:
text: The input string.
Returns:
The snake_case version of the string.
"""
# Handle spaces and non-alphanumeric characters (replace with underscores)
text = re.sub(r'[^a-zA-Z0-9]+', '_', text)
# Insert underscores before uppercase letters (handling both CamelCases)
text = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', text) # lowerCamelCase
text = re.sub(
r'([A-Z]+)([A-Z][a-z])', r'\1_\2', text
) # UpperCamelCase and acronyms
# Convert to lowercase
text = text.lower()
# Remove consecutive underscores (clean up extra underscores)
text = re.sub(r'_+', '_', text)
# Remove leading and trailing underscores
text = text.strip('_')
return text
def rename_python_keywords(s: str, prefix: str = 'param_') -> str: def rename_python_keywords(s: str, prefix: str = 'param_') -> str:
@ -106,7 +67,7 @@ class ApiParameter(BaseModel):
self.py_name = ( self.py_name = (
self.py_name self.py_name
if self.py_name if self.py_name
else rename_python_keywords(to_snake_case(self.original_name)) else rename_python_keywords(_to_snake_case(self.original_name))
) )
if isinstance(self.param_schema, str): if isinstance(self.param_schema, str):
self.param_schema = Schema.model_validate_json(self.param_schema) self.param_schema = Schema.model_validate_json(self.param_schema)

View File

@ -20,7 +20,6 @@ from .operation_parser import OperationParser
from .rest_api_tool import AuthPreparationState from .rest_api_tool import AuthPreparationState
from .rest_api_tool import RestApiTool from .rest_api_tool import RestApiTool
from .rest_api_tool import snake_to_lower_camel from .rest_api_tool import snake_to_lower_camel
from .rest_api_tool import to_gemini_schema
from .tool_auth_handler import ToolAuthHandler from .tool_auth_handler import ToolAuthHandler
__all__ = [ __all__ = [
@ -30,7 +29,6 @@ __all__ = [
'OpenAPIToolset', 'OpenAPIToolset',
'OperationParser', 'OperationParser',
'RestApiTool', 'RestApiTool',
'to_gemini_schema',
'snake_to_lower_camel', 'snake_to_lower_camel',
'AuthPreparationState', 'AuthPreparationState',
'ToolAuthHandler', 'ToolAuthHandler',

View File

@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import copy import copy
from typing import Any from typing import Any
from typing import Dict from typing import Dict
@ -23,8 +25,8 @@ from pydantic import BaseModel
from ....auth.auth_credential import AuthCredential from ....auth.auth_credential import AuthCredential
from ....auth.auth_schemes import AuthScheme from ....auth.auth_schemes import AuthScheme
from ..._gemini_schema_util import _to_snake_case
from ..common.common import ApiParameter from ..common.common import ApiParameter
from ..common.common import to_snake_case
from .operation_parser import OperationParser from .operation_parser import OperationParser
@ -112,7 +114,7 @@ class OpenApiSpecParser:
# If operation ID is missing, assign an operation id based on path # If operation ID is missing, assign an operation id based on path
# and method # and method
if "operationId" not in operation_dict: if "operationId" not in operation_dict:
temp_id = to_snake_case(f"{path}_{method}") temp_id = _to_snake_case(f"{path}_{method}")
operation_dict["operationId"] = temp_id operation_dict["operationId"] = temp_id
url = OperationEndpoint(base_url=base_url, path=path, method=method) url = OperationEndpoint(base_url=base_url, path=path, method=method)

View File

@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import inspect import inspect
from textwrap import dedent from textwrap import dedent
from typing import Any from typing import Any
@ -25,9 +27,9 @@ from fastapi.openapi.models import Operation
from fastapi.openapi.models import Parameter from fastapi.openapi.models import Parameter
from fastapi.openapi.models import Schema from fastapi.openapi.models import Schema
from ..._gemini_schema_util import _to_snake_case
from ..common.common import ApiParameter from ..common.common import ApiParameter
from ..common.common import PydocHelper from ..common.common import PydocHelper
from ..common.common import to_snake_case
class OperationParser: class OperationParser:
@ -189,7 +191,7 @@ class OperationParser:
operation_id = self._operation.operationId operation_id = self._operation.operationId
if not operation_id: if not operation_id:
raise ValueError('Operation ID is missing') raise ValueError('Operation ID is missing')
return to_snake_case(operation_id)[:60] return _to_snake_case(operation_id)[:60]
def get_return_type_hint(self) -> str: def get_return_type_hint(self) -> str:
"""Returns the return type hint string (like 'str', 'int', etc.).""" """Returns the return type hint string (like 'str', 'int', etc.)."""

View File

@ -12,45 +12,36 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Literal from typing import Literal
from typing import Optional from typing import Optional
from typing import Sequence
from typing import Tuple from typing import Tuple
from typing import Union from typing import Union
from fastapi.openapi.models import Operation from fastapi.openapi.models import Operation
from google.genai.types import FunctionDeclaration from google.genai.types import FunctionDeclaration
from google.genai.types import Schema
import requests import requests
from typing_extensions import override from typing_extensions import override
from ....auth.auth_credential import AuthCredential from ....auth.auth_credential import AuthCredential
from ....auth.auth_schemes import AuthScheme from ....auth.auth_schemes import AuthScheme
from ....tools.base_tool import BaseTool from ..._gemini_schema_util import _to_gemini_schema
from ..._gemini_schema_util import _to_snake_case
from ...base_tool import BaseTool
from ...tool_context import ToolContext from ...tool_context import ToolContext
from ..auth.auth_helpers import credential_to_param from ..auth.auth_helpers import credential_to_param
from ..auth.auth_helpers import dict_to_auth_scheme from ..auth.auth_helpers import dict_to_auth_scheme
from ..auth.credential_exchangers.auto_auth_credential_exchanger import AutoAuthCredentialExchanger from ..auth.credential_exchangers.auto_auth_credential_exchanger import AutoAuthCredentialExchanger
from ..common.common import ApiParameter from ..common.common import ApiParameter
from ..common.common import to_snake_case
from .openapi_spec_parser import OperationEndpoint from .openapi_spec_parser import OperationEndpoint
from .openapi_spec_parser import ParsedOperation from .openapi_spec_parser import ParsedOperation
from .operation_parser import OperationParser from .operation_parser import OperationParser
from .tool_auth_handler import ToolAuthHandler from .tool_auth_handler import ToolAuthHandler
# Not supported by the Gemini API
_OPENAPI_SCHEMA_IGNORE_FIELDS = (
"title",
"default",
"format",
"additional_properties",
"ref",
"def",
)
def snake_to_lower_camel(snake_case_string: str): def snake_to_lower_camel(snake_case_string: str):
"""Converts a snake_case string to a lower_camel_case string. """Converts a snake_case string to a lower_camel_case string.
@ -70,117 +61,6 @@ def snake_to_lower_camel(snake_case_string: str):
]) ])
# TODO: Switch to Gemini `from_json_schema` util when it is released
# in Gemini SDK.
def normalize_json_schema_type(
json_schema_type: Optional[Union[str, Sequence[str]]],
) -> tuple[Optional[str], bool]:
"""Converts a JSON Schema Type into Gemini Schema type.
Adopted and modified from Gemini SDK. This gets the first available schema
type from JSON Schema, and use it to mark Gemini schema type. If JSON Schema
contains a list of types, the first non null type is used.
Remove this after switching to Gemini `from_json_schema`.
"""
if json_schema_type is None:
return None, False
if isinstance(json_schema_type, str):
if json_schema_type == "null":
return None, True
return json_schema_type, False
non_null_types = []
nullable = False
# If json schema type is an array, pick the first non null type.
for type_value in json_schema_type:
if type_value == "null":
nullable = True
else:
non_null_types.append(type_value)
non_null_type = non_null_types[0] if non_null_types else None
return non_null_type, nullable
# TODO: Switch to Gemini `from_json_schema` util when it is released
# in Gemini SDK.
def to_gemini_schema(openapi_schema: Optional[Dict[str, Any]] = None) -> Schema:
"""Converts an OpenAPI schema dictionary to a Gemini Schema object.
Args:
openapi_schema: The OpenAPI schema dictionary.
Returns:
A Pydantic Schema object. Returns None if input is None.
Raises TypeError if input is not a dict.
"""
if openapi_schema is None:
return None
if not isinstance(openapi_schema, dict):
raise TypeError("openapi_schema must be a dictionary")
pydantic_schema_data = {}
# Adding this to force adding a type to an empty dict
# This avoid "... one_of or any_of must specify a type" error
if not openapi_schema.get("type"):
openapi_schema["type"] = "object"
for key, value in openapi_schema.items():
snake_case_key = to_snake_case(key)
# Check if the snake_case_key exists in the Schema model's fields.
if snake_case_key in Schema.model_fields:
if snake_case_key in _OPENAPI_SCHEMA_IGNORE_FIELDS:
# Ignore these fields as Gemini backend doesn't recognize them, and will
# throw exception if they appear in the schema.
# Format: properties[expiration].format: only 'enum' and 'date-time' are
# supported for STRING type
continue
elif snake_case_key == "type":
schema_type, nullable = normalize_json_schema_type(
openapi_schema.get("type", None)
)
# Adding this to force adding a type to an empty dict
# This avoid "... one_of or any_of must specify a type" error
pydantic_schema_data["type"] = schema_type if schema_type else "object"
pydantic_schema_data["type"] = pydantic_schema_data["type"].upper()
if nullable:
pydantic_schema_data["nullable"] = True
elif snake_case_key == "properties" and isinstance(value, dict):
pydantic_schema_data[snake_case_key] = {
k: to_gemini_schema(v) for k, v in value.items()
}
elif snake_case_key == "items" and isinstance(value, dict):
pydantic_schema_data[snake_case_key] = to_gemini_schema(value)
elif snake_case_key == "any_of" and isinstance(value, list):
pydantic_schema_data[snake_case_key] = [
to_gemini_schema(item) for item in value
]
# Important: Handle cases where the OpenAPI schema might contain lists
# or other structures that need to be recursively processed.
elif isinstance(value, list) and snake_case_key not in (
"enum",
"required",
"property_ordering",
):
new_list = []
for item in value:
if isinstance(item, dict):
new_list.append(to_gemini_schema(item))
else:
new_list.append(item)
pydantic_schema_data[snake_case_key] = new_list
elif isinstance(value, dict) and snake_case_key not in ("properties"):
# Handle dictionary which is neither properties or items
pydantic_schema_data[snake_case_key] = to_gemini_schema(value)
else:
# Simple value assignment (int, str, bool, etc.)
pydantic_schema_data[snake_case_key] = value
return Schema(**pydantic_schema_data)
AuthPreparationState = Literal["pending", "done"] AuthPreparationState = Literal["pending", "done"]
@ -273,7 +153,7 @@ class RestApiTool(BaseTool):
parsed.operation, parsed.parameters, parsed.return_value parsed.operation, parsed.parameters, parsed.return_value
) )
tool_name = to_snake_case(operation_parser.get_function_name()) tool_name = _to_snake_case(operation_parser.get_function_name())
generated = cls( generated = cls(
name=tool_name, name=tool_name,
description=parsed.operation.description description=parsed.operation.description
@ -306,7 +186,7 @@ class RestApiTool(BaseTool):
def _get_declaration(self) -> FunctionDeclaration: def _get_declaration(self) -> FunctionDeclaration:
"""Returns the function declaration in the Gemini Schema format.""" """Returns the function declaration in the Gemini Schema format."""
schema_dict = self._operation_parser.get_json_schema() schema_dict = self._operation_parser.get_json_schema()
parameters = to_gemini_schema(schema_dict) parameters = _to_gemini_schema(schema_dict)
function_decl = FunctionDeclaration( function_decl = FunctionDeclaration(
name=self.name, description=self.description, parameters=parameters name=self.name, description=self.description, parameters=parameters
) )

View File

@ -21,7 +21,6 @@ from fastapi.openapi.models import Schema
from google.adk.tools.openapi_tool.common.common import ApiParameter from google.adk.tools.openapi_tool.common.common import ApiParameter
from google.adk.tools.openapi_tool.common.common import PydocHelper from google.adk.tools.openapi_tool.common.common import PydocHelper
from google.adk.tools.openapi_tool.common.common import rename_python_keywords from google.adk.tools.openapi_tool.common.common import rename_python_keywords
from google.adk.tools.openapi_tool.common.common import to_snake_case
from google.adk.tools.openapi_tool.common.common import TypeHintHelper from google.adk.tools.openapi_tool.common.common import TypeHintHelper
import pytest import pytest
@ -30,47 +29,6 @@ def dict_to_responses(input: Dict[str, Any]) -> Dict[str, Response]:
return {k: Response.model_validate(input[k]) for k in input} return {k: Response.model_validate(input[k]) for k in input}
class TestToSnakeCase:
@pytest.mark.parametrize(
'input_str, expected_output',
[
('lowerCamelCase', 'lower_camel_case'),
('UpperCamelCase', 'upper_camel_case'),
('space separated', 'space_separated'),
('REST API', 'rest_api'),
('Mixed_CASE with_Spaces', 'mixed_case_with_spaces'),
('__init__', 'init'),
('APIKey', 'api_key'),
('SomeLongURL', 'some_long_url'),
('CONSTANT_CASE', 'constant_case'),
('already_snake_case', 'already_snake_case'),
('single', 'single'),
('', ''),
(' spaced ', 'spaced'),
('with123numbers', 'with123numbers'),
('With_Mixed_123_and_SPACES', 'with_mixed_123_and_spaces'),
('HTMLParser', 'html_parser'),
('HTTPResponseCode', 'http_response_code'),
('a_b_c', 'a_b_c'),
('A_B_C', 'a_b_c'),
('fromAtoB', 'from_ato_b'),
('XMLHTTPRequest', 'xmlhttp_request'),
('_leading', 'leading'),
('trailing_', 'trailing'),
(' leading_and_trailing_ ', 'leading_and_trailing'),
('Multiple___Underscores', 'multiple_underscores'),
(' spaces_and___underscores ', 'spaces_and_underscores'),
(' _mixed_Case ', 'mixed_case'),
('123Start', '123_start'),
('End123', 'end123'),
('Mid123dle', 'mid123dle'),
],
)
def test_to_snake_case(self, input_str, expected_output):
assert to_snake_case(input_str) == expected_output
class TestRenamePythonKeywords: class TestRenamePythonKeywords:
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -29,11 +29,9 @@ from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser impor
from google.adk.tools.openapi_tool.openapi_spec_parser.operation_parser import OperationParser from google.adk.tools.openapi_tool.openapi_spec_parser.operation_parser import OperationParser
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import snake_to_lower_camel from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import snake_to_lower_camel
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import to_gemini_schema
from google.adk.tools.tool_context import ToolContext from google.adk.tools.tool_context import ToolContext
from google.genai.types import FunctionDeclaration from google.genai.types import FunctionDeclaration
from google.genai.types import Schema from google.genai.types import Schema
from google.genai.types import Type
import pytest import pytest
@ -777,237 +775,6 @@ class TestRestApiTool:
assert "empty_param" not in request_params["params"] assert "empty_param" not in request_params["params"]
class TestToGeminiSchema:
def test_to_gemini_schema_none(self):
assert to_gemini_schema(None) is None
def test_to_gemini_schema_not_dict(self):
with pytest.raises(TypeError, match="openapi_schema must be a dictionary"):
to_gemini_schema("not a dict")
def test_to_gemini_schema_empty_dict(self):
result = to_gemini_schema({})
assert isinstance(result, Schema)
assert result.type == Type.OBJECT
assert result.properties is None
def test_to_gemini_schema_dict_with_only_object_type(self):
result = to_gemini_schema({"type": "object"})
assert isinstance(result, Schema)
assert result.type == Type.OBJECT
assert result.properties is None
def test_to_gemini_schema_basic_types(self):
openapi_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"is_active": {"type": "boolean"},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert isinstance(gemini_schema, Schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["name"].type == Type.STRING
assert gemini_schema.properties["age"].type == Type.INTEGER
assert gemini_schema.properties["is_active"].type == Type.BOOLEAN
def test_to_gemini_schema_array_string_types(self):
openapi_schema = {
"type": "object",
"properties": {
"boolean_field": {"type": "boolean"},
"nonnullable_string": {"type": ["string"]},
"nullable_string": {"type": ["string", "null"]},
"nullable_number": {"type": ["null", "integer"]},
"object_nullable": {"type": "null"},
"multi_types_nullable": {"type": ["string", "null", "integer"]},
"empty_default_object": {},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert isinstance(gemini_schema, Schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["boolean_field"].type == Type.BOOLEAN
assert gemini_schema.properties["nonnullable_string"].type == Type.STRING
assert not gemini_schema.properties["nonnullable_string"].nullable
assert gemini_schema.properties["nullable_string"].type == Type.STRING
assert gemini_schema.properties["nullable_string"].nullable
assert gemini_schema.properties["nullable_number"].type == Type.INTEGER
assert gemini_schema.properties["nullable_number"].nullable
assert gemini_schema.properties["object_nullable"].type == Type.OBJECT
assert gemini_schema.properties["object_nullable"].nullable
assert gemini_schema.properties["multi_types_nullable"].type == Type.STRING
assert gemini_schema.properties["multi_types_nullable"].nullable
assert gemini_schema.properties["empty_default_object"].type == Type.OBJECT
assert not gemini_schema.properties["empty_default_object"].nullable
def test_to_gemini_schema_nested_objects(self):
openapi_schema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"},
},
}
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.properties["address"].type == Type.OBJECT
assert (
gemini_schema.properties["address"].properties["street"].type
== Type.STRING
)
assert (
gemini_schema.properties["address"].properties["city"].type
== Type.STRING
)
def test_to_gemini_schema_array(self):
openapi_schema = {
"type": "array",
"items": {"type": "string"},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.ARRAY
assert gemini_schema.items.type == Type.STRING
def test_to_gemini_schema_nested_array(self):
openapi_schema = {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.items.properties["name"].type == Type.STRING
def test_to_gemini_schema_any_of(self):
openapi_schema = {
"anyOf": [{"type": "string"}, {"type": "integer"}],
}
gemini_schema = to_gemini_schema(openapi_schema)
assert len(gemini_schema.any_of) == 2
assert gemini_schema.any_of[0].type == Type.STRING
assert gemini_schema.any_of[1].type == Type.INTEGER
def test_to_gemini_schema_general_list(self):
openapi_schema = {
"type": "array",
"properties": {
"list_field": {"type": "array", "items": {"type": "string"}},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.properties["list_field"].type == Type.ARRAY
assert gemini_schema.properties["list_field"].items.type == Type.STRING
def test_to_gemini_schema_enum(self):
openapi_schema = {"type": "string", "enum": ["a", "b", "c"]}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.enum == ["a", "b", "c"]
def test_to_gemini_schema_required(self):
openapi_schema = {
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string"}},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.required == ["name"]
def test_to_gemini_schema_nested_dict(self):
openapi_schema = {
"type": "object",
"properties": {
"metadata": {
"type": "object",
"properties": {
"key1": {"type": "object"},
"key2": {"type": "string"},
},
}
},
}
gemini_schema = to_gemini_schema(openapi_schema)
# Since metadata is not properties nor item, it will call to_gemini_schema recursively.
assert isinstance(gemini_schema.properties["metadata"], Schema)
assert (
gemini_schema.properties["metadata"].type == Type.OBJECT
) # add object type by default
assert len(gemini_schema.properties["metadata"].properties) == 2
assert (
gemini_schema.properties["metadata"].properties["key1"].type
== Type.OBJECT
)
assert (
gemini_schema.properties["metadata"].properties["key2"].type
== Type.STRING
)
def test_to_gemini_schema_ignore_title_default_format(self):
openapi_schema = {
"type": "string",
"title": "Test Title",
"default": "default_value",
"format": "date",
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.title is None
assert gemini_schema.default is None
assert gemini_schema.format is None
def test_to_gemini_schema_property_ordering(self):
openapi_schema = {
"type": "object",
"propertyOrdering": ["name", "age"],
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.property_ordering == ["name", "age"]
def test_to_gemini_schema_converts_property_dict(self):
openapi_schema = {
"properties": {
"name": {"type": "string", "description": "The property key"},
"value": {"type": "string", "description": "The property value"},
},
"type": "object",
"description": "A single property entry in the Properties message.",
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["name"].type == Type.STRING
assert gemini_schema.properties["value"].type == Type.STRING
def test_to_gemini_schema_remove_unrecognized_fields(self):
openapi_schema = {
"type": "string",
"description": "A single date string.",
"format": "date",
}
gemini_schema = to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.STRING
assert not gemini_schema.format
def test_snake_to_lower_camel(): def test_snake_to_lower_camel():
assert snake_to_lower_camel("single") == "single" assert snake_to_lower_camel("single") == "single"
assert snake_to_lower_camel("two_words") == "twoWords" assert snake_to_lower_camel("two_words") == "twoWords"

View File

@ -0,0 +1,424 @@
# Copyright 2025 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 google.adk.tools._gemini_schema_util import _to_gemini_schema
from google.adk.tools._gemini_schema_util import _to_snake_case
from google.genai.types import Schema
from google.genai.types import Type
import pytest
class TestToGeminiSchema:
def test_to_gemini_schema_none(self):
assert _to_gemini_schema(None) is None
def test_to_gemini_schema_not_dict(self):
with pytest.raises(TypeError, match="openapi_schema must be a dictionary"):
_to_gemini_schema("not a dict")
def test_to_gemini_schema_empty_dict(self):
result = _to_gemini_schema({})
assert isinstance(result, Schema)
assert result.type is None
assert result.properties is None
def test_to_gemini_schema_dict_with_only_object_type(self):
result = _to_gemini_schema({"type": "object"})
assert isinstance(result, Schema)
assert result.type == Type.OBJECT
assert result.properties is None
def test_to_gemini_schema_basic_types(self):
openapi_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"is_active": {"type": "boolean"},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert isinstance(gemini_schema, Schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["name"].type == Type.STRING
assert gemini_schema.properties["age"].type == Type.INTEGER
assert gemini_schema.properties["is_active"].type == Type.BOOLEAN
def test_to_gemini_schema_array_string_types(self):
openapi_schema = {
"type": "object",
"properties": {
"boolean_field": {"type": "boolean"},
"nonnullable_string": {"type": ["string"]},
"nullable_string": {"type": ["string", "null"]},
"nullable_number": {"type": ["null", "integer"]},
"object_nullable": {"type": "null"}, # invalid
"multi_types_nullable": {
"type": ["string", "null", "integer"]
}, # invalid
"empty_default_object": {},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert isinstance(gemini_schema, Schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["boolean_field"].type == Type.BOOLEAN
assert gemini_schema.properties["nonnullable_string"].type == Type.STRING
assert not gemini_schema.properties["nonnullable_string"].nullable
assert gemini_schema.properties["nullable_string"].type == Type.STRING
assert gemini_schema.properties["nullable_string"].nullable
assert gemini_schema.properties["nullable_number"].type == Type.INTEGER
assert gemini_schema.properties["nullable_number"].nullable
assert gemini_schema.properties["object_nullable"].type is None
assert gemini_schema.properties["object_nullable"].nullable
assert gemini_schema.properties["multi_types_nullable"].type is None
assert gemini_schema.properties["multi_types_nullable"].nullable
assert gemini_schema.properties["empty_default_object"].type is None
assert not gemini_schema.properties["empty_default_object"].nullable
def test_to_gemini_schema_nested_objects(self):
openapi_schema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"},
},
}
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.properties["address"].type == Type.OBJECT
assert (
gemini_schema.properties["address"].properties["street"].type
== Type.STRING
)
assert (
gemini_schema.properties["address"].properties["city"].type
== Type.STRING
)
def test_to_gemini_schema_array(self):
openapi_schema = {
"type": "array",
"items": {"type": "string"},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.ARRAY
assert gemini_schema.items.type == Type.STRING
def test_to_gemini_schema_nested_array(self):
openapi_schema = {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.items.properties["name"].type == Type.STRING
def test_to_gemini_schema_any_of(self):
openapi_schema = {
"anyOf": [{"type": "string"}, {"type": "integer"}],
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert len(gemini_schema.any_of) == 2
assert gemini_schema.any_of[0].type == Type.STRING
assert gemini_schema.any_of[1].type == Type.INTEGER
def test_to_gemini_schema_general_list(self):
openapi_schema = {
"type": "array",
"properties": {
"list_field": {"type": "array", "items": {"type": "string"}},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.properties["list_field"].type == Type.ARRAY
assert gemini_schema.properties["list_field"].items.type == Type.STRING
def test_to_gemini_schema_enum(self):
openapi_schema = {"type": "string", "enum": ["a", "b", "c"]}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.enum == ["a", "b", "c"]
def test_to_gemini_schema_required(self):
openapi_schema = {
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string"}},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.required == ["name"]
def test_to_gemini_schema_nested_dict(self):
openapi_schema = {
"type": "object",
"properties": {
"metadata": {
"type": "object",
"properties": {
"key1": {"type": "object"},
"key2": {"type": "string"},
},
}
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
# Since metadata is not properties nor item, it will call to_gemini_schema recursively.
assert isinstance(gemini_schema.properties["metadata"], Schema)
assert (
gemini_schema.properties["metadata"].type == Type.OBJECT
) # add object type by default
assert len(gemini_schema.properties["metadata"].properties) == 2
assert (
gemini_schema.properties["metadata"].properties["key1"].type
== Type.OBJECT
)
assert (
gemini_schema.properties["metadata"].properties["key2"].type
== Type.STRING
)
def test_to_gemini_schema_converts_property_dict(self):
openapi_schema = {
"properties": {
"name": {"type": "string", "description": "The property key"},
"value": {"type": "string", "description": "The property value"},
},
"type": "object",
"description": "A single property entry in the Properties message.",
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.OBJECT
assert gemini_schema.properties["name"].type == Type.STRING
assert gemini_schema.properties["value"].type == Type.STRING
def test_to_gemini_schema_remove_unrecognized_fields(self):
openapi_schema = {
"type": "string",
"description": "A single date string.",
"format": "date",
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.type == Type.STRING
assert not gemini_schema.format
def test_sanitize_integer_formats(self):
"""Test that int32 and int64 formats are preserved for integer types"""
openapi_schema = {
"type": "object",
"properties": {
"int32_field": {"type": "integer", "format": "int32"},
"int64_field": {"type": "integer", "format": "int64"},
"invalid_int_format": {"type": "integer", "format": "unsigned"},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
# int32 and int64 should be preserved
assert gemini_schema.properties["int32_field"].format == "int32"
assert gemini_schema.properties["int64_field"].format == "int64"
# Invalid format should be removed
assert gemini_schema.properties["invalid_int_format"].format is None
def test_sanitize_string_formats(self):
"""Test that only date-time and enum formats are preserved for string types"""
openapi_schema = {
"type": "object",
"properties": {
"datetime_field": {"type": "string", "format": "date-time"},
"enum_field": {
"type": "string",
"format": "enum",
"enum": ["a", "b"],
},
"date_field": {"type": "string", "format": "date"},
"email_field": {"type": "string", "format": "email"},
"byte_field": {"type": "string", "format": "byte"},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
# date-time and enum should be preserved
assert gemini_schema.properties["datetime_field"].format == "date-time"
assert gemini_schema.properties["enum_field"].format == "enum"
# Other formats should be removed
assert gemini_schema.properties["date_field"].format is None
assert gemini_schema.properties["email_field"].format is None
assert gemini_schema.properties["byte_field"].format is None
def test_sanitize_number_formats(self):
"""Test format handling for number types"""
openapi_schema = {
"type": "object",
"properties": {
"float_field": {"type": "number", "format": "float"},
"double_field": {"type": "number", "format": "double"},
"int32_number": {"type": "number", "format": "int32"},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
# float and double should be removed for number type
assert gemini_schema.properties["float_field"].format is None
assert gemini_schema.properties["double_field"].format is None
# int32 should be preserved even for number type
assert gemini_schema.properties["int32_number"].format == "int32"
def test_sanitize_nested_formats(self):
"""Test format sanitization in nested structures"""
openapi_schema = {
"type": "object",
"properties": {
"nested": {
"type": "object",
"properties": {
"date_str": {"type": "string", "format": "date"},
"int_field": {"type": "integer", "format": "int64"},
},
},
"array_field": {
"type": "array",
"items": {"type": "string", "format": "uri"},
},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
# Check nested object
assert (
gemini_schema.properties["nested"].properties["date_str"].format is None
)
assert (
gemini_schema.properties["nested"].properties["int_field"].format
== "int64"
)
# Check array items
assert gemini_schema.properties["array_field"].items.format is None
def test_sanitize_anyof_formats(self):
"""Test format sanitization in anyOf structures"""
openapi_schema = {
"anyOf": [
{"type": "string", "format": "email"},
{"type": "integer", "format": "int32"},
{"type": "string", "format": "date-time"},
],
}
gemini_schema = _to_gemini_schema(openapi_schema)
# First anyOf should have format removed (email)
assert gemini_schema.any_of[0].format is None
# Second anyOf should preserve int32
assert gemini_schema.any_of[1].format == "int32"
# Third anyOf should preserve date-time
assert gemini_schema.any_of[2].format == "date-time"
def test_camel_case_to_snake_case_conversion(self):
"""Test that camelCase keys are converted to snake_case"""
openapi_schema = {
"type": "object",
"minProperties": 1,
"maxProperties": 10,
"properties": {
"firstName": {"type": "string", "minLength": 1, "maxLength": 50},
"lastName": {"type": "string", "minLength": 1, "maxLength": 50},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
# Check snake_case conversion
assert gemini_schema.min_properties == 1
assert gemini_schema.max_properties == 10
assert gemini_schema.properties["firstName"].min_length == 1
assert gemini_schema.properties["firstName"].max_length == 50
def test_preserve_valid_formats_without_type(self):
"""Test behavior when format is specified but type is missing"""
openapi_schema = {
"format": "date-time", # No type specified
"properties": {
"field1": {"format": "int32"}, # No type
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
# Format should be removed when type is not specified
assert gemini_schema.format is None
assert gemini_schema.properties["field1"].format is None
def test_to_gemini_schema_property_ordering(self):
openapi_schema = {
"type": "object",
"propertyOrdering": ["name", "age"],
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
}
gemini_schema = _to_gemini_schema(openapi_schema)
assert gemini_schema.property_ordering == ["name", "age"]
class TestToSnakeCase:
@pytest.mark.parametrize(
"input_str, expected_output",
[
("lowerCamelCase", "lower_camel_case"),
("UpperCamelCase", "upper_camel_case"),
("space separated", "space_separated"),
("REST API", "rest_api"),
("Mixed_CASE with_Spaces", "mixed_case_with_spaces"),
("__init__", "init"),
("APIKey", "api_key"),
("SomeLongURL", "some_long_url"),
("CONSTANT_CASE", "constant_case"),
("already_snake_case", "already_snake_case"),
("single", "single"),
("", ""),
(" spaced ", "spaced"),
("with123numbers", "with123numbers"),
("With_Mixed_123_and_SPACES", "with_mixed_123_and_spaces"),
("HTMLParser", "html_parser"),
("HTTPResponseCode", "http_response_code"),
("a_b_c", "a_b_c"),
("A_B_C", "a_b_c"),
("fromAtoB", "from_ato_b"),
("XMLHTTPRequest", "xmlhttp_request"),
("_leading", "leading"),
("trailing_", "trailing"),
(" leading_and_trailing_ ", "leading_and_trailing"),
("Multiple___Underscores", "multiple_underscores"),
(" spaces_and___underscores ", "spaces_and_underscores"),
(" _mixed_Case ", "mixed_case"),
("123Start", "123_start"),
("End123", "end123"),
("Mid123dle", "mid123dle"),
],
)
def test_to_snake_case(self, input_str, expected_output):
assert _to_snake_case(input_str) == expected_output