diff --git a/src/google/adk/tools/_gemini_schema_util.py b/src/google/adk/tools/_gemini_schema_util.py new file mode 100644 index 0000000..92bf770 --- /dev/null +++ b/src/google/adk/tools/_gemini_schema_util.py @@ -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(), + ) diff --git a/src/google/adk/tools/apihub_tool/apihub_toolset.py b/src/google/adk/tools/apihub_tool/apihub_toolset.py index 62c183a..747650b 100644 --- a/src/google/adk/tools/apihub_tool/apihub_toolset.py +++ b/src/google/adk/tools/apihub_tool/apihub_toolset.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations from typing import List from typing import Optional @@ -23,9 +24,9 @@ import yaml from ...agents.readonly_context import ReadonlyContext from ...auth.auth_credential import AuthCredential from ...auth.auth_schemes import AuthScheme +from .._gemini_schema_util import _to_snake_case from ..base_toolset import BaseToolset 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.rest_api_tool import RestApiTool from .clients.apihub_client import APIHubClient @@ -171,7 +172,7 @@ class APIHubToolset(BaseToolset): if not spec_dict: return - self.name = self.name or to_snake_case( + self.name = self.name or _to_snake_case( spec_dict.get('info', {}).get('title', 'unnamed') ) self.description = self.description or spec_dict.get('info', {}).get( diff --git a/src/google/adk/tools/application_integration_tool/integration_connector_tool.py b/src/google/adk/tools/application_integration_tool/integration_connector_tool.py index 47084cf..b3bb32a 100644 --- a/src/google/adk/tools/application_integration_tool/integration_connector_tool.py +++ b/src/google/adk/tools/application_integration_tool/integration_connector_tool.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import logging from typing import Any from typing import Dict @@ -24,8 +26,8 @@ from typing_extensions import override from .. import BaseTool from ...auth.auth_credential import AuthCredential 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 to_gemini_schema from ..openapi_tool.openapi_spec_parser.tool_auth_handler import ToolAuthHandler from ..tool_context import ToolContext @@ -128,7 +130,7 @@ class IntegrationConnectorTool(BaseTool): if field in schema_dict['required']: schema_dict['required'].remove(field) - parameters = to_gemini_schema(schema_dict) + parameters = _to_gemini_schema(schema_dict) function_decl = FunctionDeclaration( name=self.name, description=self.description, parameters=parameters ) diff --git a/src/google/adk/tools/mcp_tool/mcp_tool.py b/src/google/adk/tools/mcp_tool/mcp_tool.py index 40fadfb..463202b 100644 --- a/src/google/adk/tools/mcp_tool/mcp_tool.py +++ b/src/google/adk/tools/mcp_tool/mcp_tool.py @@ -20,6 +20,7 @@ from typing import Optional from google.genai.types import FunctionDeclaration from typing_extensions import override +from .._gemini_schema_util import _to_gemini_schema from .mcp_session_manager import MCPSessionManager 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_schemes import AuthScheme from ..base_tool import BaseTool -from ..openapi_tool.openapi_spec_parser.rest_api_tool import to_gemini_schema from ..tool_context import ToolContext logger = logging.getLogger("google_adk." + __name__) @@ -99,7 +99,7 @@ class MCPTool(BaseTool): FunctionDeclaration: The Gemini function declaration for the tool. """ schema_dict = self._mcp_tool.inputSchema - parameters = to_gemini_schema(schema_dict) + parameters = _to_gemini_schema(schema_dict) function_decl = FunctionDeclaration( name=self.name, description=self.description, parameters=parameters ) diff --git a/src/google/adk/tools/openapi_tool/common/common.py b/src/google/adk/tools/openapi_tool/common/common.py index 8aa7cc4..7187b1b 100644 --- a/src/google/adk/tools/openapi_tool/common/common.py +++ b/src/google/adk/tools/openapi_tool/common/common.py @@ -12,8 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import keyword -import re from typing import Any from typing import Dict from typing import List @@ -26,47 +27,7 @@ from pydantic import BaseModel from pydantic import Field from pydantic import model_serializer - -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 +from ..._gemini_schema_util import _to_snake_case def rename_python_keywords(s: str, prefix: str = 'param_') -> str: @@ -106,7 +67,7 @@ class ApiParameter(BaseModel): self.py_name = ( 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): self.param_schema = Schema.model_validate_json(self.param_schema) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/__init__.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/__init__.py index 68f877c..f743e74 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/__init__.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/__init__.py @@ -20,7 +20,6 @@ from .operation_parser import OperationParser from .rest_api_tool import AuthPreparationState from .rest_api_tool import RestApiTool from .rest_api_tool import snake_to_lower_camel -from .rest_api_tool import to_gemini_schema from .tool_auth_handler import ToolAuthHandler __all__ = [ @@ -30,7 +29,6 @@ __all__ = [ 'OpenAPIToolset', 'OperationParser', 'RestApiTool', - 'to_gemini_schema', 'snake_to_lower_camel', 'AuthPreparationState', 'ToolAuthHandler', diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py index 9535953..ac86cd0 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import copy from typing import Any from typing import Dict @@ -23,8 +25,8 @@ from pydantic import BaseModel from ....auth.auth_credential import AuthCredential from ....auth.auth_schemes import AuthScheme +from ..._gemini_schema_util import _to_snake_case from ..common.common import ApiParameter -from ..common.common import to_snake_case from .operation_parser import OperationParser @@ -112,7 +114,7 @@ class OpenApiSpecParser: # If operation ID is missing, assign an operation id based on path # and method 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 url = OperationEndpoint(base_url=base_url, path=path, method=method) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py index 73e8ed6..f7a577a 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import inspect from textwrap import dedent 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 Schema +from ..._gemini_schema_util import _to_snake_case from ..common.common import ApiParameter from ..common.common import PydocHelper -from ..common.common import to_snake_case class OperationParser: @@ -189,7 +191,7 @@ class OperationParser: operation_id = self._operation.operationId if not operation_id: 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: """Returns the return type hint string (like 'str', 'int', etc.).""" diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index 0491805..1e451fe 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -12,45 +12,36 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from typing import Any from typing import Dict from typing import List from typing import Literal from typing import Optional -from typing import Sequence from typing import Tuple from typing import Union from fastapi.openapi.models import Operation from google.genai.types import FunctionDeclaration -from google.genai.types import Schema import requests from typing_extensions import override from ....auth.auth_credential import AuthCredential 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 ..auth.auth_helpers import credential_to_param from ..auth.auth_helpers import dict_to_auth_scheme from ..auth.credential_exchangers.auto_auth_credential_exchanger import AutoAuthCredentialExchanger from ..common.common import ApiParameter -from ..common.common import to_snake_case from .openapi_spec_parser import OperationEndpoint from .openapi_spec_parser import ParsedOperation from .operation_parser import OperationParser 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): """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"] @@ -273,7 +153,7 @@ class RestApiTool(BaseTool): 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( name=tool_name, description=parsed.operation.description @@ -306,7 +186,7 @@ class RestApiTool(BaseTool): def _get_declaration(self) -> FunctionDeclaration: """Returns the function declaration in the Gemini Schema format.""" schema_dict = self._operation_parser.get_json_schema() - parameters = to_gemini_schema(schema_dict) + parameters = _to_gemini_schema(schema_dict) function_decl = FunctionDeclaration( name=self.name, description=self.description, parameters=parameters ) diff --git a/tests/unittests/tools/openapi_tool/common/test_common.py b/tests/unittests/tools/openapi_tool/common/test_common.py index c188367..5dc8578 100644 --- a/tests/unittests/tools/openapi_tool/common/test_common.py +++ b/tests/unittests/tools/openapi_tool/common/test_common.py @@ -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 PydocHelper 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 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} -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: @pytest.mark.parametrize( diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py index adc6ba0..303dda6 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py @@ -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.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 to_gemini_schema from google.adk.tools.tool_context import ToolContext from google.genai.types import FunctionDeclaration from google.genai.types import Schema -from google.genai.types import Type import pytest @@ -777,237 +775,6 @@ class TestRestApiTool: 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(): assert snake_to_lower_camel("single") == "single" assert snake_to_lower_camel("two_words") == "twoWords" diff --git a/tests/unittests/tools/test_gemini_schema_utils.py b/tests/unittests/tools/test_gemini_schema_utils.py new file mode 100644 index 0000000..56aeca1 --- /dev/null +++ b/tests/unittests/tools/test_gemini_schema_utils.py @@ -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