mirror of
https://github.com/EvolutionAPI/adk-python.git
synced 2025-07-13 15:14:50 -06:00
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:
parent
f7cb66620b
commit
5a67a946d2
130
src/google/adk/tools/_gemini_schema_util.py
Normal file
130
src/google/adk/tools/_gemini_schema_util.py
Normal 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(),
|
||||
)
|
@ -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(
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
|
@ -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.)."""
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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(
|
||||
|
@ -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"
|
||||
|
424
tests/unittests/tools/test_gemini_schema_utils.py
Normal file
424
tests/unittests/tools/test_gemini_schema_utils.py
Normal 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
|
Loading…
Reference in New Issue
Block a user