Skip to content
40 changes: 34 additions & 6 deletions src/google/adk/models/lite_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3361,11 +3361,14 @@ def _append_response_schema_to_description(

def _function_declaration_to_tool_param(
function_declaration: types.FunctionDeclaration,
*,
model: str = "",
) -> dict[str, Any]:
"""Converts a types.FunctionDeclaration to an openapi spec dictionary.

Args:
function_declaration: The function declaration to convert.
model: The LiteLLM model used to select the schema dialect.

Returns:
The openapi spec dictionary representation of the function declaration.
Expand All @@ -3375,7 +3378,6 @@ def _function_declaration_to_tool_param(

if function_declaration.parameters_json_schema:
parameters = copy.deepcopy(function_declaration.parameters_json_schema)
lowercase_schema_types(parameters)
elif function_declaration.parameters:
parameters = _schema_to_dict(function_declaration.parameters)
if "type" not in parameters:
Expand All @@ -3388,6 +3390,10 @@ def _function_declaration_to_tool_param(
"properties": {},
}

lowercase_schema_types(
parameters, convert_nullable=not _is_litellm_gemini_model(model)
)

tool_params: dict[str, Any] = {
"type": "function",
"function": {
Expand Down Expand Up @@ -4043,7 +4049,7 @@ async def _get_completion_inputs(
continue
if tool.function_declarations:
tools.extend(
_function_declaration_to_tool_param(func_decl)
_function_declaration_to_tool_param(func_decl, model=model)
for func_decl in tool.function_declarations
)
else:
Expand Down Expand Up @@ -4408,18 +4414,40 @@ def _is_litellm_vertex_model(model_string: str) -> bool:
return _strip_proxy_prefix(model_string).startswith("vertex_ai/")


def _is_litellm_gemini_model(model_string: str) -> bool:
def _is_litellm_gemini_model(
model_string: str, *, resolve_provider: bool = True
) -> bool:
"""Check if the model is a Gemini model accessed via LiteLLM.

Args:
model_string: A LiteLLM model string (e.g., "gemini/gemini-2.5-pro" or
"vertex_ai/gemini-2.5-flash")
resolve_provider: Use LiteLLM resolution when already imported. Disable for
constructor warnings to avoid provider lookups during initialization.

Returns:
True if it's a Gemini model accessed via LiteLLM, False otherwise
"""
return _strip_proxy_prefix(model_string).startswith(
("gemini/gemini-", "vertex_ai/gemini-")
model = _strip_proxy_prefix(model_string)
if resolve_provider and litellm is not None:
try:
# LiteLLM resolves provider aliases locally, without making an API request.
resolved = litellm.get_llm_provider(model)
if (
isinstance(resolved, (tuple, list))
and len(resolved) > 1
and isinstance(resolved[0], str)
and isinstance(resolved[1], str)
):
return resolved[1] in {"gemini", "vertex_ai", "vertex_ai_beta"} and (
resolved[0].startswith("gemini-")
)
except Exception as e:
logger.debug("Failed to resolve Gemini route for %s: %s", model, e)
# Older LiteLLM versions may not resolve every supported provider prefix.
provider, _, model_name = model.partition("/")
return provider in {"gemini", "vertex_ai", "vertex_ai_beta"} and (
model_name.startswith("gemini-")
)


Expand Down Expand Up @@ -4453,7 +4481,7 @@ def _warn_gemini_via_litellm(
model_string: The LiteLLM model string to check
completion_args: Optional completion arguments to check for proxy settings.
"""
if not _is_litellm_gemini_model(model_string):
if not _is_litellm_gemini_model(model_string, resolve_provider=False):
return

# Do not warn if using a proxy, as native Gemini client might not support it.
Expand Down
2 changes: 1 addition & 1 deletion src/google/adk/tools/_node_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def _to_json_schema(schema: SchemaType) -> dict[str, Any]:
return schema
if isinstance(schema, types.Schema):
json_schema = schema.model_dump(by_alias=True, exclude_none=True)
lowercase_schema_types(json_schema)
lowercase_schema_types(json_schema, convert_nullable=False)
return json_schema
return schema_to_json_schema(schema)

Expand Down
135 changes: 101 additions & 34 deletions src/google/adk/utils/_schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,48 +137,55 @@ def schema_to_json_schema(schema: SchemaType) -> dict[str, Any]:
return TypeAdapter(schema).json_schema()


def lowercase_schema_types(value: object) -> None:
"""Lowercases the JSON Schema ``type`` strings in a schema, in place.
def _json_schema_equal(left: object, right: object) -> bool:
"""Compares JSON values, keeping booleans distinct from numbers."""
if isinstance(left, bool) or isinstance(right, bool):
return type(left) is type(right) and left == right
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(
_json_schema_equal(a, b) for a, b in zip(left, right)
)
if isinstance(left, dict) and isinstance(right, dict):
return left.keys() == right.keys() and all(
_json_schema_equal(left[key], right[key]) for key in left
)
return left == right


``types.Schema`` serializes its type as the uppercase enum name (``STRING``),
while JSON Schema and the model providers that consume it expect ``string``.
A type may also be a list of names, as in ``["STRING", "NULL"]``. Nested
subschemas are reached through the schema keywords only, so a ``type`` key
inside a ``default`` or ``example`` value is left untouched.
def lowercase_schema_types(
value: object, *, convert_nullable: bool = True
) -> None:
"""Normalizes GenAI type names and nullability for JSON Schema, in place.

Only schema keywords are traversed; default and example values remain data.
Nullable typed schemas use type lists, keeping subschemas and references at
their original paths. Applicators that can reject null need a union instead.

Args:
value: A JSON Schema dict, or a list of them. Mutated in place.
convert_nullable: Whether to convert nullable to JSON Schema unions. Set to
False for Gemini schemas, which natively support nullable.
"""
if isinstance(value, list):
for item in value:
lowercase_schema_types(item)
lowercase_schema_types(item, convert_nullable=convert_nullable)
return

if not isinstance(value, dict):
return

schema_type = value.get("type")
if isinstance(schema_type, str):
value["type"] = schema_type.lower()
elif isinstance(schema_type, list):
value["type"] = [
item.lower() if isinstance(item, str) else item for item in schema_type
]

for dict_key in (
for key in (
"$defs",
"definitions",
"defs",
"dependentSchemas",
"patternProperties",
"properties",
):
child_dict = value.get(dict_key)
if isinstance(child_dict, dict):
for child_value in child_dict.values():
lowercase_schema_types(child_value)

for single_key in (
children = value.get(key)
if isinstance(children, dict):
for child in children.values():
lowercase_schema_types(child, convert_nullable=convert_nullable)
for key in (
"additionalItems",
"additionalProperties",
"additional_properties",
Expand All @@ -191,12 +198,6 @@ def lowercase_schema_types(value: object) -> None:
"then",
"unevaluatedItems",
"unevaluatedProperties",
):
child_value = value.get(single_key)
if isinstance(child_value, (dict, list)):
lowercase_schema_types(child_value)

for list_key in (
"allOf",
"all_of",
"anyOf",
Expand All @@ -205,9 +206,71 @@ def lowercase_schema_types(value: object) -> None:
"one_of",
"prefixItems",
):
child_list = value.get(list_key)
if isinstance(child_list, list):
lowercase_schema_types(child_list)
lowercase_schema_types(value.get(key), convert_nullable=convert_nullable)

schema_type = value.get("type")
if isinstance(schema_type, str):
value["type"] = schema_type.lower()
elif isinstance(schema_type, list):
value["type"] = [
item.lower() if isinstance(item, str) else item for item in schema_type
]

if not convert_nullable or value.pop("nullable", False) is not True:
return

# Widen type/enum in place, preserving properties, items and their descendants
# as JSON Pointer targets even when applicators also need a null alternative.
if "type" in value:
schema_type = value["type"]
if isinstance(schema_type, str):
value["type"] = (
[schema_type, "null"] if schema_type != "null" else ["null"]
)
elif isinstance(schema_type, list) and "null" not in schema_type:
schema_type.append("null")
if "const" in value:
constant = value.pop("const")
value["enum"] = (
[constant]
if "enum" not in value
or any(_json_schema_equal(constant, item) for item in value["enum"])
else []
)
if "enum" in value and not any(
_json_schema_equal(None, item) for item in value["enum"]
):
value["enum"].append(None)

# Only applicators/references can still reject null. Move those constraints
# into a union, leaving all other keywords and subschemas at their old paths.
union_keys = (
"$ref",
"$dynamicRef",
"allOf",
"all_of",
"oneOf",
"one_of",
"not",
"if",
"then",
"else",
)
needs_union = any(key in value for key in union_keys) or (
"type" in value and any(key in value for key in ("anyOf", "any_of"))
)
if needs_union:
constraints = {
key: value.pop(key)
for key in (*union_keys, "anyOf", "any_of")
if key in value
}
value["anyOf"] = [constraints, {"type": "null"}]
elif "type" not in value:
for key in ("anyOf", "any_of"):
if key in value:
value[key].append({"type": "null"})
# Untyped type-specific keywords already accept null.


def enforce_strict_openai_schema(schema: dict[str, Any]) -> None:
Expand Down Expand Up @@ -254,7 +317,11 @@ def enforce_strict_openai_schema(schema: dict[str, Any]) -> None:

# Ensure all object schemas have additionalProperties: false and list every
# property as required.
if schema.get("type") == "object" and "properties" in schema:
schema_type = schema.get("type")
is_object = schema_type == "object" or (
isinstance(schema_type, list) and "object" in schema_type
)
if is_object and "properties" in schema:
schema["additionalProperties"] = False
schema["required"] = sorted(schema["properties"].keys())

Expand Down
51 changes: 51 additions & 0 deletions tests/unittests/integrations/openai/test_openai_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,61 @@
from google.genai import types
from google.genai.types import Content
from google.genai.types import Part
from jsonschema import Draft202012Validator
from openai import AsyncOpenAI
import pytest


@pytest.mark.parametrize("use_json_schema", [False, True])
def test_tool_parameters_preserve_nullable_values(use_json_schema):
"""Null remains valid in the Chat Completions tool schema."""
schema = types.Schema(
type=types.Type.OBJECT,
properties={"city": types.Schema(type=types.Type.STRING, nullable=True)},
required=["city"],
)
declaration = types.FunctionDeclaration(name="weather")
if use_json_schema:
declaration.parameters_json_schema = schema.model_dump(
by_alias=True, exclude_none=True, mode="json"
)
else:
declaration.parameters = schema

tool = _function_declaration_to_openai_tool(declaration)

validator = Draft202012Validator(tool["function"]["parameters"])
assert validator.is_valid({"city": None})
assert validator.is_valid({"city": "Paris"})
assert not validator.is_valid({"city": 7})
assert schema.properties["city"].nullable is True


def test_nullable_objects_preserve_property_reference_targets():
"""References into nullable objects remain valid in the emitted payload."""
declaration = types.FunctionDeclaration(
name="weather",
parameters_json_schema={
"type": "object",
"properties": {
"details": {
"type": "object",
"nullable": True,
"properties": {"name": {"type": "string"}},
},
"copy": {"$ref": "#/properties/details/properties/name"},
},
},
)

tool = _function_declaration_to_openai_tool(declaration)

validator = Draft202012Validator(tool["function"]["parameters"])
assert validator.is_valid({"details": {"name": "Paris"}, "copy": "Paris"})
assert validator.is_valid({"details": None, "copy": "Paris"})
assert not validator.is_valid({"details": {"name": 7}, "copy": "Paris"})


def test_supported_models():
models = OpenAILlm.supported_models()
assert len(models) == 2
Expand Down
Loading
Loading