From 479f1328077af70462f27a440d5a6e78f942bab2 Mon Sep 17 00:00:00 2001 From: Sumedh Aerram Date: Sun, 1 Nov 2026 14:09:02 -0800 Subject: [PATCH 1/6] fix(models): preserve nullable values in provider JSON schemas GenAI and OpenAPI schemas mark optional values with `nullable: true`, which is not a JSON Schema keyword. The Anthropic, LiteLLM, OpenAI Chat and OpenAI Responses adapters passed it through unchanged, so providers rejected None for nullable fields, including ordinary Optional tool parameters when GOOGLE_GENAI_USE_ENTERPRISE=1. lowercase_schema_types now rewrites a nullable schema as anyOf[, {"type": "null"}]. Definitions, identifiers and annotations stay at their original level, and local JSON Pointer references into the moved constraints are rewritten to their new location. LiteLLM's legacy `parameters` path now goes through the same normalization. Fixes #7916 --- src/google/adk/models/lite_llm.py | 3 +- src/google/adk/utils/_schema_utils.py | 197 ++++++++++++++---- .../integrations/openai/test_openai_llm.py | 51 +++++ .../openai/test_openai_responses_llm.py | 51 +++++ tests/unittests/models/test_anthropic_llm.py | 22 ++ tests/unittests/models/test_litellm.py | 49 +++++ tests/unittests/utils/test_schema_utils.py | 121 +++++++++++ 7 files changed, 450 insertions(+), 44 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index e06eb32248b..6f23b83a869 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -3306,7 +3306,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: @@ -3319,6 +3318,8 @@ def _function_declaration_to_tool_param( "properties": {}, } + lowercase_schema_types(parameters) + tool_params: dict[str, Any] = { "type": "function", "function": { diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index 5bebe3a3f69..0a47d7cb42a 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -30,8 +30,13 @@ from typing import cast from typing import get_args from typing import get_origin +from typing import Iterator from typing import Optional from typing import Union +from urllib.parse import quote +from urllib.parse import unquote +from urllib.parse import urldefrag +from urllib.parse import urljoin from google.genai import types from pydantic import BaseModel @@ -133,35 +138,31 @@ 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. - - ``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. - - Args: - value: A JSON Schema dict, or a list of them. Mutated in place. - """ - if isinstance(value, list): - for item in value: - lowercase_schema_types(item) - 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 ( +_SCHEMA_METADATA_KEYS = frozenset({ + "$defs", + "definitions", + "defs", + "$id", + "$schema", + "$anchor", + "$dynamicAnchor", + "$comment", + "title", + "description", + "default", + "examples", + "example", + "deprecated", + "readOnly", + "writeOnly", +}) + + +def _schema_children( + schema: dict[str, Any], +) -> Iterator[tuple[tuple[str, ...], Any]]: + """Yields schema locations, excluding annotation and instance data.""" + for key in ( "$defs", "definitions", "defs", @@ -169,12 +170,11 @@ def lowercase_schema_types(value: object) -> None: "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 = schema.get(key) + if isinstance(children, dict): + for name, child in children.items(): + yield (key, str(name)), child + for key in ( "additionalItems", "additionalProperties", "additional_properties", @@ -188,11 +188,13 @@ def lowercase_schema_types(value: object) -> None: "unevaluatedItems", "unevaluatedProperties", ): - child_value = value.get(single_key) - if isinstance(child_value, (dict, list)): - lowercase_schema_types(child_value) - - for list_key in ( + child = schema.get(key) + if isinstance(child, dict): + yield (key,), child + elif isinstance(child, list): + for index, item in enumerate(child): + yield (key, str(index)), item + for key in ( "allOf", "all_of", "anyOf", @@ -201,9 +203,118 @@ 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) + children = schema.get(key) + if isinstance(children, list): + for index, child in enumerate(children): + yield (key, str(index)), child + + +def _resolve_schema_uri(base: str, reference: str) -> str: + # urljoin does not retain opaque resource identifiers such as urn:example + # when joining a fragment-only reference. + if reference.startswith("#"): + return urldefrag(base)[0] + reference + return urljoin(base, reference) + + +def lowercase_schema_types(value: object) -> None: + """Normalizes GenAI type names and nullability for JSON Schema, in place. + + ``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. + + GenAI's ``nullable`` keyword is not a JSON Schema constraint. A nullable + schema becomes a union of the original constraints and null. Definitions + and identifiers stay at their original location, and JSON Pointer + references into the moved constraints are rewritten to their new location. + + Args: + value: A JSON Schema dict, or a list of them. Mutated in place. + """ + locations: dict[tuple[str, tuple[str, ...]], tuple[str, ...]] = {} + references: list[tuple[dict[str, Any], str, str, str]] = [] + + def visit( + node: Any, scopes: list[tuple[str, tuple[str, ...], tuple[str, ...]]] + ) -> None: + for uri, old_path, new_path in scopes: + locations[uri, old_path] = new_path + if isinstance(node, list): + for index, item in enumerate(node): + visit( + item, + [ + (uri, old + (str(index),), new + (str(index),)) + for uri, old, new in scopes + ], + ) + return + if not isinstance(node, dict): + return + + schema_id = node.get("$id") + if isinstance(schema_id, str) and not urldefrag(schema_id)[1]: + uri = _resolve_schema_uri(scopes[-1][0], schema_id) + scopes = scopes + [(uri, (), ())] + locations[uri, ()] = () + + children = list(_schema_children(node)) + schema_type = node.get("type") + if isinstance(schema_type, str): + node["type"] = schema_type.lower() + elif isinstance(schema_type, list): + node["type"] = [ + item.lower() if isinstance(item, str) else item + for item in schema_type + ] + + nullable = node.pop("nullable", False) is True + constraints = node + if nullable: + constraints = { + key: node.pop(key) + for key in tuple(node) + if key not in _SCHEMA_METADATA_KEYS + } + if constraints: + node["anyOf"] = [constraints, {"type": "null"}] + for keyword in ("$ref", "$dynamicRef"): + reference = constraints.get(keyword) + if isinstance(reference, str): + references.append((constraints, keyword, scopes[-1][0], reference)) + for path, child in children: + new_path = ( + ("anyOf", "0") + path + if nullable and path[0] not in _SCHEMA_METADATA_KEYS + else path + ) + visit( + child, [(uri, old + path, new + new_path) for uri, old, new in scopes] + ) + + visit(value, [("", (), ())]) + for node, keyword, base, reference in references: + uri, fragment = urldefrag(_resolve_schema_uri(base, reference)) + pointer = unquote(fragment) + if not pointer.startswith("/"): + continue + old_path = tuple( + part.replace("~1", "/").replace("~0", "~") + for part in pointer[1:].split("/") + ) + new_path = locations.get((uri, old_path)) + if new_path is not None and new_path != old_path: + pointer = "/" + "/".join( + part.replace("~", "~0").replace("/", "~1") for part in new_path + ) + node[keyword] = ( + reference.split("#", 1)[0] + + "#" + + quote(pointer, safe="/~!$&'()*+,;=:@") + ) # Anchored, linear-time removal of an optional leading language tag diff --git a/tests/unittests/integrations/openai/test_openai_llm.py b/tests/unittests/integrations/openai/test_openai_llm.py index 1badc484925..6fa8d2654d6 100644 --- a/tests/unittests/integrations/openai/test_openai_llm.py +++ b/tests/unittests/integrations/openai/test_openai_llm.py @@ -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 diff --git a/tests/unittests/integrations/openai/test_openai_responses_llm.py b/tests/unittests/integrations/openai/test_openai_responses_llm.py index f555e46ffcb..95a70a46dbc 100644 --- a/tests/unittests/integrations/openai/test_openai_responses_llm.py +++ b/tests/unittests/integrations/openai/test_openai_responses_llm.py @@ -36,6 +36,7 @@ from google.adk.integrations.openai._openai_responses_llm import _function_declaration_to_response_tool from google.adk.integrations.openai._openai_responses_llm import _inline_data_part_to_response_content from google.adk.integrations.openai._openai_responses_llm import _loads_json_object +from google.adk.integrations.openai._openai_responses_llm import _response_text_config from google.adk.integrations.openai._openai_responses_llm import _response_to_llm_response from google.adk.integrations.openai._openai_responses_llm import _serialize_system_instruction from google.adk.integrations.openai._openai_responses_llm import _tool_choice @@ -43,6 +44,7 @@ from google.adk.integrations.openai._openai_responses_llm import OpenAIResponsesLlm from google.adk.integrations.openai._openai_schema import enforce_strict_openai_schema from google.adk.models.llm_request import LlmRequest +from jsonschema import Draft202012Validator from openai import AsyncOpenAI from openai.types.responses import EasyInputMessageParam from openai.types.responses import FunctionToolParam @@ -96,6 +98,55 @@ def __init__(self, response): self.responses = _CaptureResponses(response) +@pytest.mark.parametrize('use_json_schema', [False, True]) +def test_tool_parameters_preserve_nullable_values(use_json_schema): + """Null remains valid in the Responses API 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_response_tool(declaration) + + validator = Draft202012Validator(tool['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_keep_strict_response_constraints(): + """Strict Responses schemas validate complete objects and null values.""" + config = types.GenerateContentConfig( + response_schema=types.Schema( + type=types.Type.OBJECT, + properties={ + 'details': types.Schema( + type=types.Type.OBJECT, + nullable=True, + properties={'name': types.Schema(type=types.Type.STRING)}, + ) + }, + ) + ) + + text_config = _response_text_config(config) + + validator = Draft202012Validator(text_config['format']['schema']) + assert validator.is_valid({'details': None}) + assert validator.is_valid({'details': {'name': 'Paris'}}) + assert not validator.is_valid({'details': {}}) + assert not validator.is_valid({'details': {'name': 'Paris', 'extra': 1}}) + + def test_openai_responses_package_exports_required_types(): """The supported OpenAI SDK range exposes the Responses API types we use.""" assert EasyInputMessageParam diff --git a/tests/unittests/models/test_anthropic_llm.py b/tests/unittests/models/test_anthropic_llm.py index 10b263bf6de..6aa05e879c9 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -45,6 +45,7 @@ from google.genai.types import Content from google.genai.types import Part import httpx +from jsonschema import Draft202012Validator from pydantic import BaseModel import pytest @@ -723,6 +724,27 @@ def test_function_declaration_to_tool_param( ) +def test_vertex_optional_tool_parameters_still_accept_null(monkeypatch): + """Vertex-sanitized declarations keep null valid in the Anthropic payload.""" + monkeypatch.setenv("GOOGLE_GENAI_USE_ENTERPRISE", "1") + + def weather(city: str | None = None): + """Look up the weather.""" + + declaration = FunctionTool(weather)._get_declaration() + + tool = function_declaration_to_tool_param(declaration) + + validator = Draft202012Validator(tool["input_schema"]) + assert validator.is_valid({"city": None}) + assert validator.is_valid({"city": "Paris"}) + assert not validator.is_valid({"city": 7}) + assert ( + declaration.parameters_json_schema["properties"]["city"]["nullable"] + is True + ) + + @pytest.mark.parametrize("keyword", ["additionalItems", "unevaluatedItems"]) def test_tool_array_tail_schema_types_are_lowercased(keyword): """Array-tail schemas use JSON Schema types without changing default data.""" diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 34c17bc9c0b..87c1449264e 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -80,6 +80,7 @@ from google.adk.models.llm_request import LlmRequest from google.adk.models.llm_response import LlmResponse from google.genai import types +from jsonschema import Draft202012Validator import litellm from litellm import ChatCompletionAssistantMessage from litellm import ChatCompletionMessageToolCall @@ -329,6 +330,54 @@ def test_to_litellm_response_format_handles_model_dump_object(): assert formatted["response_schema"] == schema_obj.model_dump() +def test_nullable_object_response_schema_keeps_strict_validation(): + """Nullable objects retain strict object constraints in the non-null branch.""" + schema = types.Schema( + type=types.Type.OBJECT, + properties={ + "details": types.Schema( + type=types.Type.OBJECT, + nullable=True, + properties={"name": types.Schema(type=types.Type.STRING)}, + ) + }, + ) + + formatted = _to_litellm_response_format(schema, model="gpt-4o-mini") + + validator = Draft202012Validator(formatted["json_schema"]["schema"]) + assert validator.is_valid({"details": None}) + assert validator.is_valid({"details": {"name": "Paris"}}) + assert not validator.is_valid({"details": {}}) + assert not validator.is_valid({"details": {"name": "Paris", "extra": 1}}) + assert schema.properties["details"].nullable is True + + +@pytest.mark.parametrize("use_json_schema", [False, True]) +def test_tool_parameters_preserve_nullable_values(use_json_schema): + """Both declaration formats advertise null as a valid tool argument.""" + 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_tool_param(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_to_litellm_response_format_handles_genai_schema_instance(): schema_instance = types.Schema( type=types.Type.OBJECT, diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index 719c404eb2c..48b1ad97ba4 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -34,6 +34,7 @@ from google.adk.utils._schema_utils import validate_node_data from google.adk.utils._schema_utils import validate_schema from google.genai import types +from jsonschema import Draft202012Validator from pydantic import BaseModel from pydantic import Field from pydantic import ValidationError @@ -470,6 +471,126 @@ def test_list_of_basemodel_schema_becomes_an_array_of_objects(self): class TestLowercaseSchemaTypes: """Tests for lowercase_schema_types function.""" + @pytest.mark.parametrize( + "schema, valid, invalid", + [ + ({"type": "STRING", "nullable": True}, "hello", 7), + ( + {"type": "STRING", "enum": ["red"], "nullable": True}, + "red", + "blue", + ), + ( + { + "anyOf": [{"type": "STRING"}, {"type": "INTEGER"}], + "nullable": True, + }, + 7, + [], + ), + ({"type": ["STRING", "INTEGER"], "nullable": True}, 7, {}), + ( + {"allOf": [{"type": "STRING", "minLength": 2}], "nullable": True}, + "hello", + "x", + ), + ({"const": "red", "nullable": True}, "red", "blue"), + ({"const": None, "nullable": True}, None, "blue"), + ( + { + "oneOf": [{"type": "number"}, {"type": "integer"}], + "nullable": True, + }, + 1.5, + 1, + ), + ({"not": {"type": "STRING"}, "nullable": True}, 7, "hello"), + ( + { + "if": {"type": "STRING"}, + "then": {"minLength": 2}, + "else": {"const": 3}, + "nullable": True, + }, + 3, + 4, + ), + ({"allOf": [False], "nullable": True}, None, "hello"), + ( + { + "$defs": {"Color": {"type": "STRING", "enum": ["red"]}}, + "$ref": "#/$defs/Color", + "nullable": True, + }, + "red", + "blue", + ), + ], + ) + def test_nullable_schemas_accept_null_and_preserve_constraints( + self, schema, valid, invalid + ): + """Null is accepted without weakening validation of non-null values.""" + lowercase_schema_types(schema) + + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + assert validator.is_valid(None) + assert validator.is_valid(valid) + assert not validator.is_valid(invalid) + + @pytest.mark.parametrize("nested_definition", [False, True]) + def test_nullable_ancestor_preserves_pointer_targets(self, nested_definition): + """References into properties and descendant definitions keep resolving.""" + name_schema = {"type": "STRING"} + if nested_definition: + name_schema = { + "$defs": {"Text": {"type": "STRING"}}, + "$ref": "#/properties/details/properties/name/$defs/Text", + } + schema = { + "type": "OBJECT", + "properties": { + "details": { + "type": "OBJECT", + "nullable": True, + "properties": {"name": name_schema}, + "required": ["name"], + }, + "copy": {"$ref": "#/properties/details/properties/name"}, + }, + } + + lowercase_schema_types(schema) + + if nested_definition: + assert name_schema["$ref"] == ( + "#/properties/details/anyOf/0/properties/name/$defs/Text" + ) + validator = Draft202012Validator(schema) + 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_nullable_annotations_and_default_data_are_preserved(self): + default = {"type": "USER_DATA", "nullable": True} + schema = { + "type": "OBJECT", + "nullable": True, + "title": "Options", + "default": default, + "properties": {"name": {"type": "STRING", "nullable": True}}, + } + + lowercase_schema_types(schema) + + assert schema["title"] == "Options" + assert schema["default"] == {"type": "USER_DATA", "nullable": True} + validator = Draft202012Validator(schema) + assert validator.is_valid({"name": None}) + assert validator.is_valid(None) + assert not validator.is_valid({"name": 7}) + def test_properties_and_items_are_lowercased(self): schema = { "type": "OBJECT", From bb646904ac7c0e67986172fbeb4fabd93a248d42 Mon Sep 17 00:00:00 2001 From: Sumedh Aerram Date: Mon, 2 Nov 2026 11:02:50 -0800 Subject: [PATCH 2/6] refactor(models): express nullable as JSON Schema type lists Keep typed nullable constraints and JSON Pointer targets in place. Use a union for applicators that can reject null, and preserve strict object constraints for type lists. Refs #7916 --- .../adk/integrations/openai/_openai_schema.py | 6 +- src/google/adk/models/lite_llm.py | 6 +- src/google/adk/utils/_schema_utils.py | 210 +++++++----------- .../openai/test_openai_responses_llm.py | 9 +- tests/unittests/models/test_litellm.py | 18 ++ tests/unittests/utils/test_schema_utils.py | 97 +++++++- 6 files changed, 212 insertions(+), 134 deletions(-) diff --git a/src/google/adk/integrations/openai/_openai_schema.py b/src/google/adk/integrations/openai/_openai_schema.py index 862915dddeb..8128e702053 100644 --- a/src/google/adk/integrations/openai/_openai_schema.py +++ b/src/google/adk/integrations/openai/_openai_schema.py @@ -28,7 +28,11 @@ def enforce_strict_openai_schema(schema: dict[str, Any]) -> None: if key != '$ref': del schema[key] return - 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()) for defn in schema.get('$defs', {}).values(): diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 6f23b83a869..837e930e66d 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -3793,7 +3793,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()) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index 0a47d7cb42a..4e1d95ac315 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -30,13 +30,8 @@ from typing import cast from typing import get_args from typing import get_origin -from typing import Iterator from typing import Optional from typing import Union -from urllib.parse import quote -from urllib.parse import unquote -from urllib.parse import urldefrag -from urllib.parse import urljoin from google.genai import types from pydantic import BaseModel @@ -158,10 +153,23 @@ def schema_to_json_schema(schema: SchemaType) -> dict[str, Any]: }) -def _schema_children( - schema: dict[str, Any], -) -> Iterator[tuple[tuple[str, ...], Any]]: - """Yields schema locations, excluding annotation and instance data.""" +def lowercase_schema_types(value: object) -> 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. + """ + if isinstance(value, list): + for item in value: + lowercase_schema_types(item) + return + if not isinstance(value, dict): + return + for key in ( "$defs", "definitions", @@ -170,10 +178,10 @@ def _schema_children( "patternProperties", "properties", ): - children = schema.get(key) + children = value.get(key) if isinstance(children, dict): - for name, child in children.items(): - yield (key, str(name)), child + for child in children.values(): + lowercase_schema_types(child) for key in ( "additionalItems", "additionalProperties", @@ -187,14 +195,6 @@ def _schema_children( "then", "unevaluatedItems", "unevaluatedProperties", - ): - child = schema.get(key) - if isinstance(child, dict): - yield (key,), child - elif isinstance(child, list): - for index, item in enumerate(child): - yield (key, str(index)), item - for key in ( "allOf", "all_of", "anyOf", @@ -203,118 +203,68 @@ def _schema_children( "one_of", "prefixItems", ): - children = schema.get(key) - if isinstance(children, list): - for index, child in enumerate(children): - yield (key, str(index)), child - - -def _resolve_schema_uri(base: str, reference: str) -> str: - # urljoin does not retain opaque resource identifiers such as urn:example - # when joining a fragment-only reference. - if reference.startswith("#"): - return urldefrag(base)[0] + reference - return urljoin(base, reference) - - -def lowercase_schema_types(value: object) -> None: - """Normalizes GenAI type names and nullability for JSON Schema, in place. - - ``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. - - GenAI's ``nullable`` keyword is not a JSON Schema constraint. A nullable - schema becomes a union of the original constraints and null. Definitions - and identifiers stay at their original location, and JSON Pointer - references into the moved constraints are rewritten to their new location. - - Args: - value: A JSON Schema dict, or a list of them. Mutated in place. - """ - locations: dict[tuple[str, tuple[str, ...]], tuple[str, ...]] = {} - references: list[tuple[dict[str, Any], str, str, str]] = [] - - def visit( - node: Any, scopes: list[tuple[str, tuple[str, ...], tuple[str, ...]]] - ) -> None: - for uri, old_path, new_path in scopes: - locations[uri, old_path] = new_path - if isinstance(node, list): - for index, item in enumerate(node): - visit( - item, - [ - (uri, old + (str(index),), new + (str(index),)) - for uri, old, new in scopes - ], - ) - return - if not isinstance(node, dict): - return - - schema_id = node.get("$id") - if isinstance(schema_id, str) and not urldefrag(schema_id)[1]: - uri = _resolve_schema_uri(scopes[-1][0], schema_id) - scopes = scopes + [(uri, (), ())] - locations[uri, ()] = () - - children = list(_schema_children(node)) - schema_type = node.get("type") - if isinstance(schema_type, str): - node["type"] = schema_type.lower() - elif isinstance(schema_type, list): - node["type"] = [ - item.lower() if isinstance(item, str) else item - for item in schema_type - ] - - nullable = node.pop("nullable", False) is True - constraints = node - if nullable: - constraints = { - key: node.pop(key) - for key in tuple(node) - if key not in _SCHEMA_METADATA_KEYS - } - if constraints: - node["anyOf"] = [constraints, {"type": "null"}] - for keyword in ("$ref", "$dynamicRef"): - reference = constraints.get(keyword) - if isinstance(reference, str): - references.append((constraints, keyword, scopes[-1][0], reference)) - for path, child in children: - new_path = ( - ("anyOf", "0") + path - if nullable and path[0] not in _SCHEMA_METADATA_KEYS - else path - ) - visit( - child, [(uri, old + path, new + new_path) for uri, old, new in scopes] - ) - - visit(value, [("", (), ())]) - for node, keyword, base, reference in references: - uri, fragment = urldefrag(_resolve_schema_uri(base, reference)) - pointer = unquote(fragment) - if not pointer.startswith("/"): - continue - old_path = tuple( - part.replace("~1", "/").replace("~0", "~") - for part in pointer[1:].split("/") - ) - new_path = locations.get((uri, old_path)) - if new_path is not None and new_path != old_path: - pointer = "/" + "/".join( - part.replace("~", "~0").replace("/", "~1") for part in new_path + lowercase_schema_types(value.get(key)) + + 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 value.pop("nullable", False) is not True: + return + + # Conservatively wrap applicators/references: widening type alone cannot + # ensure that these constraints accept null (even allOf: [False] can occur). + needs_union = any( + key in value + for key in ( + "$ref", + "$dynamicRef", + "allOf", + "all_of", + "oneOf", + "one_of", + "not", + "if", + "then", + "else", ) - node[keyword] = ( - reference.split("#", 1)[0] - + "#" - + quote(pointer, safe="/~!$&'()*+,;=:@") + ) or ("type" in value and any(k in value for k in ("anyOf", "any_of"))) + if not needs_union: + 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 constant in value["enum"] else [] ) + if "enum" in value and None not in value["enum"]: + value["enum"].append(None) + if "type" in value: + return + for key in ("anyOf", "any_of"): + if key in value: + value[key].append({"type": "null"}) + return + + # Rare untyped constrained schemas (and null-rejecting applicators above) + # still need a union. Metadata and definitions stay at the original node. + constraints = { + key: value.pop(key) + for key in tuple(value) + if key not in _SCHEMA_METADATA_KEYS + } + if constraints: + value["anyOf"] = [constraints, {"type": "null"}] # Anchored, linear-time removal of an optional leading language tag diff --git a/tests/unittests/integrations/openai/test_openai_responses_llm.py b/tests/unittests/integrations/openai/test_openai_responses_llm.py index 95a70a46dbc..619baae7f97 100644 --- a/tests/unittests/integrations/openai/test_openai_responses_llm.py +++ b/tests/unittests/integrations/openai/test_openai_responses_llm.py @@ -140,7 +140,14 @@ def test_nullable_objects_keep_strict_response_constraints(): text_config = _response_text_config(config) - validator = Draft202012Validator(text_config['format']['schema']) + output_schema = text_config['format']['schema'] + assert output_schema['required'] == ['details'] + assert output_schema['additionalProperties'] is False + details = output_schema['properties']['details'] + assert details['type'] == ['object', 'null'] + assert details['required'] == ['name'] + assert details['additionalProperties'] is False + validator = Draft202012Validator(output_schema) assert validator.is_valid({'details': None}) assert validator.is_valid({'details': {'name': 'Paris'}}) assert not validator.is_valid({'details': {}}) diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 87c1449264e..3fbb71fcfc5 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -14072,3 +14072,21 @@ def test_strict_json_does_not_log_warning(self, caplog): with caplog.at_level(logging.WARNING, logger="google_adk"): _parse_tool_call_arguments('{"a": 1}', function_name="test_fn") assert "Repaired" not in caplog.text + + +def test_strict_nullable_object_type_list_requires_all_properties(): + """Nullable objects retain strict constraints without moving properties.""" + schema = { + "type": ["object", "null"], + "properties": {"name": {"type": ["string", "null"]}}, + } + + _enforce_strict_openai_schema(schema) + + assert schema["required"] == ["name"] + assert schema["additionalProperties"] is False + validator = Draft202012Validator(schema) + assert validator.is_valid(None) + assert validator.is_valid({"name": None}) + assert not validator.is_valid({}) + assert not validator.is_valid({"name": "Paris", "extra": 1}) diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index 48b1ad97ba4..acae98aaf5c 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -539,6 +539,97 @@ def test_nullable_schemas_accept_null_and_preserve_constraints( assert validator.is_valid(valid) assert not validator.is_valid(invalid) + @pytest.mark.parametrize( + "schema, expected", + [ + ({"type": "STRING"}, {"type": ["string", "null"]}), + ({"type": ["STRING"]}, {"type": ["string", "null"]}), + ({"type": ["STRING", "NULL"]}, {"type": ["string", "null"]}), + ( + {"type": "STRING", "enum": ["red"]}, + {"type": ["string", "null"], "enum": ["red", None]}, + ), + ( + {"type": "STRING", "const": "red"}, + {"type": ["string", "null"], "enum": ["red", None]}, + ), + ( + {"type": "NULL", "const": None}, + {"type": ["null"], "enum": [None]}, + ), + ( + {"type": "STRING", "const": "red", "enum": ["blue"]}, + {"type": ["string", "null"], "enum": [None]}, + ), + ( + {"anyOf": [{"type": "STRING"}]}, + {"anyOf": [{"type": "string"}, {"type": "null"}]}, + ), + ( + {"any_of": [{"type": "STRING"}]}, + {"any_of": [{"type": "string"}, {"type": "null"}]}, + ), + ({}, {}), + ], + ) + def test_nullable_schema_keeps_keywords_in_place(self, schema, expected): + schema["nullable"] = True + + lowercase_schema_types(schema) + + assert schema == expected + + @pytest.mark.parametrize( + "constraints, valid, invalid", + [ + ({"oneOf": [{"type": "number"}, {"type": "integer"}]}, 1.5, 1), + ({"not": {"type": "null"}}, "hello", []), + ({"if": {"type": "null"}, "then": False}, "hello", []), + ({"allOf": [False]}, None, "hello"), + ({"anyOf": [{"const": "red"}]}, "red", "blue"), + ({"$ref": "#/$defs/Text"}, "hello", []), + ], + ) + def test_nullable_type_with_applicators_accepts_null( + self, constraints, valid, invalid + ): + schema = { + "type": ["STRING", "NUMBER"], + "nullable": True, + "$defs": {"Text": {"type": "STRING"}}, + **constraints, + } + + lowercase_schema_types(schema) + + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + assert validator.is_valid(None) + assert validator.is_valid(valid) + assert not validator.is_valid(invalid) + + def test_nullable_object_preserves_property_json_pointer(self): + name = {"type": "STRING"} + schema = { + "type": "OBJECT", + "nullable": True, + "properties": {"name": name}, + "$defs": {"Name": {"$ref": "#/properties/name"}}, + } + + lowercase_schema_types(schema) + + assert schema["type"] == ["object", "null"] + assert schema["properties"]["name"] is name + assert schema["$defs"]["Name"]["$ref"] == "#/properties/name" + validator = Draft202012Validator(schema) + assert validator.is_valid({"name": "Paris"}) + assert validator.is_valid(None) + assert not validator.is_valid({"name": 7}) + name_validator = validator.evolve(schema=schema["$defs"]["Name"]) + assert name_validator.is_valid("Paris") + assert not name_validator.is_valid(7) + @pytest.mark.parametrize("nested_definition", [False, True]) def test_nullable_ancestor_preserves_pointer_targets(self, nested_definition): """References into properties and descendant definitions keep resolving.""" @@ -563,9 +654,13 @@ def test_nullable_ancestor_preserves_pointer_targets(self, nested_definition): lowercase_schema_types(schema) + assert schema["properties"]["details"]["properties"]["name"] is name_schema + assert schema["properties"]["copy"]["$ref"] == ( + "#/properties/details/properties/name" + ) if nested_definition: assert name_schema["$ref"] == ( - "#/properties/details/anyOf/0/properties/name/$defs/Text" + "#/properties/details/properties/name/$defs/Text" ) validator = Draft202012Validator(schema) assert validator.is_valid({"details": {"name": "Paris"}, "copy": "Paris"}) From ce7b0f2f60a25a7243a11617c9df95492e6b95ad Mon Sep 17 00:00:00 2001 From: Sumedh Aerram Date: Mon, 2 Nov 2026 11:07:41 -0800 Subject: [PATCH 3/6] fix(models): keep untyped nullable schemas in place Preserve descendant JSON Pointers by leaving untyped nullable constraints in place. Compare const and enum values using JSON equality, distinguishing booleans from numbers recursively. Refs #7916 --- src/google/adk/utils/_schema_utils.py | 32 ++++++++-- tests/unittests/utils/test_schema_utils.py | 70 ++++++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index 4e1d95ac315..ffa0a21a89a 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -153,6 +153,21 @@ def schema_to_json_schema(schema: SchemaType) -> dict[str, Any]: }) +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 + + def lowercase_schema_types(value: object) -> None: """Normalizes GenAI type names and nullability for JSON Schema, in place. @@ -245,19 +260,26 @@ def lowercase_schema_types(value: object) -> None: if "const" in value: constant = value.pop("const") value["enum"] = ( - [constant] if "enum" not in value or constant in value["enum"] else [] + [constant] + if "enum" not in value + or any(_json_schema_equal(constant, item) for item in value["enum"]) + else [] ) - if "enum" in value and None not in value["enum"]: + if "enum" in value and not any( + _json_schema_equal(None, item) for item in value["enum"] + ): value["enum"].append(None) if "type" in value: return for key in ("anyOf", "any_of"): if key in value: value[key].append({"type": "null"}) - return + # Untyped type-specific keywords already accept null. Keep their children + # at the original paths so JSON Pointer references continue to resolve. + return - # Rare untyped constrained schemas (and null-rejecting applicators above) - # still need a union. Metadata and definitions stay at the original node. + # Null-rejecting applicators need a union. Metadata and definitions stay at + # the original node. constraints = { key: value.pop(key) for key in tuple(value) diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index acae98aaf5c..30e53d7b206 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -539,6 +539,76 @@ def test_nullable_schemas_accept_null_and_preserve_constraints( assert validator.is_valid(valid) assert not validator.is_valid(invalid) + @pytest.mark.parametrize( + "constant, enum, valid, invalid", + [ + (0, [False], None, 0), + (False, [0.0], None, False), + ([0], [[False]], None, [0]), + ({"n": 0}, [{"n": False}], None, {"n": 0}), + (1, [1.0], 1, False), + ([1], [[1.0]], [1], [False]), + ({"n": 1}, [{"n": 1.0}], {"n": 1}, {"n": False}), + ], + ) + def test_nullable_const_enum_uses_json_equality( + self, constant, enum, valid, invalid + ): + schema = {"const": constant, "enum": enum, "nullable": True} + + lowercase_schema_types(schema) + + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + assert validator.is_valid(None) + assert validator.is_valid(valid) + assert not validator.is_valid(invalid) + + @pytest.mark.parametrize( + "valid, invalid", + [({"n": "x"}, {"n": 5}), (2, 1)], + ) + def test_untyped_nullable_preserves_type_specific_constraints( + self, valid, invalid + ): + schema = { + "nullable": True, + "properties": {"n": {"type": "STRING"}}, + "minimum": 2, + } + + lowercase_schema_types(schema) + + assert schema == { + "properties": {"n": {"type": "string"}}, + "minimum": 2, + } + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + assert validator.is_valid(None) + assert validator.is_valid(valid) + assert not validator.is_valid(invalid) + + def test_untyped_nullable_preserves_descendant_json_pointer(self): + schema = { + "type": "OBJECT", + "properties": { + "u": { + "nullable": True, + "properties": {"n": {"type": "STRING"}}, + }, + "r": {"$ref": "#/properties/u/properties/n"}, + }, + } + + lowercase_schema_types(schema) + + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + assert validator.is_valid({"r": "x"}) + assert validator.is_valid({"u": None, "r": "x"}) + assert not validator.is_valid({"r": 5}) + @pytest.mark.parametrize( "schema, expected", [ From 9725a51f3f0096b297ab1bfbe0a006228cf99b6c Mon Sep 17 00:00:00 2001 From: Sumedh Aerram Date: Wed, 4 Nov 2026 14:08:49 -0800 Subject: [PATCH 4/6] fix(models): preserve Gemini nullable schemas Keep nullable and enum constraints intact through LiteLLM Gemini conversion. Preserve upstream NodeTool declarations and apply JSON Schema null unions only in provider adapters that need them. Refs #7916 --- src/google/adk/models/lite_llm.py | 9 ++- src/google/adk/tools/_node_tool.py | 2 +- src/google/adk/utils/_schema_utils.py | 14 +++-- tests/unittests/models/test_litellm.py | 65 ++++++++++++++++++++++ tests/unittests/workflow/test_node_tool.py | 21 +++++++ 5 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 837e930e66d..a1d0088c2f3 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -3292,11 +3292,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. @@ -3318,7 +3321,9 @@ def _function_declaration_to_tool_param( "properties": {}, } - lowercase_schema_types(parameters) + lowercase_schema_types( + parameters, convert_nullable=not _is_litellm_gemini_model(model) + ) tool_params: dict[str, Any] = { "type": "function", @@ -4021,7 +4026,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: diff --git a/src/google/adk/tools/_node_tool.py b/src/google/adk/tools/_node_tool.py index bba584185a0..ca681cedd06 100644 --- a/src/google/adk/tools/_node_tool.py +++ b/src/google/adk/tools/_node_tool.py @@ -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) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index ffa0a21a89a..c310f80a766 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -168,7 +168,9 @@ def _json_schema_equal(left: object, right: object) -> bool: return left == right -def lowercase_schema_types(value: object) -> None: +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. @@ -177,10 +179,12 @@ def lowercase_schema_types(value: object) -> None: 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 @@ -196,7 +200,7 @@ def lowercase_schema_types(value: object) -> None: children = value.get(key) if isinstance(children, dict): for child in children.values(): - lowercase_schema_types(child) + lowercase_schema_types(child, convert_nullable=convert_nullable) for key in ( "additionalItems", "additionalProperties", @@ -218,7 +222,7 @@ def lowercase_schema_types(value: object) -> None: "one_of", "prefixItems", ): - lowercase_schema_types(value.get(key)) + lowercase_schema_types(value.get(key), convert_nullable=convert_nullable) schema_type = value.get("type") if isinstance(schema_type, str): @@ -228,7 +232,7 @@ def lowercase_schema_types(value: object) -> None: item.lower() if isinstance(item, str) else item for item in schema_type ] - if value.pop("nullable", False) is not True: + if not convert_nullable or value.pop("nullable", False) is not True: return # Conservatively wrap applicators/references: widening type alone cannot diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 3fbb71fcfc5..efc5a8cd94f 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -14090,3 +14090,68 @@ def test_strict_nullable_object_type_list_requires_all_properties(): assert validator.is_valid({"name": None}) assert not validator.is_valid({}) assert not validator.is_valid({"name": "Paris", "extra": 1}) + + +@pytest.mark.parametrize( + "model", + [ + "gemini/gemini-2.5-flash", + "vertex_ai/gemini-2.5-flash", + "litellm_proxy/vertex_ai/gemini-2.5-flash", + "openai/gpt-4o-mini", + ], +) +@pytest.mark.asyncio +async def test_nullable_provider_payload(model, mock_client): + """Gemini preserves nullable enums through LiteLLM's own transformation.""" + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig + + schema = types.Schema( + type=types.Type.OBJECT, + properties={ + "city": types.Schema(type=types.Type.STRING, nullable=True), + "choice": types.Schema( + type=types.Type.STRING, nullable=True, enum=["x", "y"] + ), + }, + ) + request = LlmRequest( + config=types.GenerateContentConfig( + tools=[ + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="lookup", + parameters=schema, + ) + ] + ) + ], + response_schema=schema, + ) + ) + client = LiteLlm(model=model, llm_client=mock_client) + + async for _ in client.generate_content_async(request): + pass + + payload = mock_client.acompletion_mock.call_args.kwargs + parameters = payload["tools"][0]["function"]["parameters"] + if model.startswith("openai/"): + response = payload["response_format"]["json_schema"]["schema"] + for result in (parameters, response): + assert result["properties"]["city"]["type"] == ["string", "null"] + assert result["properties"]["choice"]["enum"] == ["x", "y", None] + else: + config = VertexGeminiConfig() + tools = config._map_function(payload["tools"], {}) + response = config._map_response_schema( + payload["response_format"]["response_schema"] + ) + for result in ( + tools[0]["function_declarations"][0]["parameters"], + response, + ): + assert result["properties"]["city"]["nullable"] is True + assert result["properties"]["choice"]["nullable"] is True + assert result["properties"]["choice"]["enum"] == ["x", "y"] diff --git a/tests/unittests/workflow/test_node_tool.py b/tests/unittests/workflow/test_node_tool.py index 20ab96bff5b..b52db27dc9d 100644 --- a/tests/unittests/workflow/test_node_tool.py +++ b/tests/unittests/workflow/test_node_tool.py @@ -2717,3 +2717,24 @@ def on_tool_error(tool, args, tool_context, error): assert len(seen_errors) == 1 assert _function_responses(events) == [{'handled_internal_error': True}] + + +def test_node_tool_nullable_declaration_matches_gemini_schema(): + """The shared declaration retains main's Gemini nullable/enum payload.""" + schema = types.Schema( + type=types.Type.OBJECT, + properties={ + 'choice': types.Schema( + type=types.Type.STRING, nullable=True, enum=['x', 'y'] + ), + }, + ) + tool = NodeTool(node=Workflow(name='lookup', edges=[], input_schema=schema)) + + declaration = tool._get_declaration() + + assert declaration.model_dump_json(by_alias=True, exclude_none=True) == ( + '{"description":"Executes the node: lookup","name":"lookup",' + '"parametersJsonSchema":{"properties":{"choice":{"enum":["x","y"],' + '"nullable":true,"type":"string"}},"type":"object"}}' + ) From 89a8e412c7e023df921c0e18d611a8f1beae1bb1 Mon Sep 17 00:00:00 2001 From: Sumedh Aerram Date: Sat, 7 Nov 2026 18:30:46 -0800 Subject: [PATCH 5/6] fix(models): preserve nullable applicator reference targets --- src/google/adk/utils/_schema_utils.py | 120 +++++++++------------ tests/unittests/utils/test_schema_utils.py | 30 ++++-- 2 files changed, 69 insertions(+), 81 deletions(-) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index 37f9caa5944..4c980a63d9c 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -133,26 +133,6 @@ def schema_to_json_schema(schema: SchemaType) -> dict[str, Any]: return TypeAdapter(schema).json_schema() -_SCHEMA_METADATA_KEYS = frozenset({ - "$defs", - "definitions", - "defs", - "$id", - "$schema", - "$anchor", - "$dynamicAnchor", - "$comment", - "title", - "description", - "default", - "examples", - "example", - "deprecated", - "readOnly", - "writeOnly", -}) - - 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): @@ -235,62 +215,58 @@ def lowercase_schema_types( if not convert_nullable or value.pop("nullable", False) is not True: return - # Conservatively wrap applicators/references: widening type alone cannot - # ensure that these constraints accept null (even allOf: [False] can occur). - needs_union = any( - key in value - for key in ( - "$ref", - "$dynamicRef", - "allOf", - "all_of", - "oneOf", - "one_of", - "not", - "if", - "then", - "else", - ) - ) or ("type" in value and any(k in value for k in ("anyOf", "any_of"))) - if not needs_union: - 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 [] + # 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"] ) - if "enum" in value and not any( - _json_schema_equal(None, item) for item in value["enum"] - ): - value["enum"].append(None) - if "type" in value: - return + 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. Keep their children - # at the original paths so JSON Pointer references continue to resolve. - return - - # Null-rejecting applicators need a union. Metadata and definitions stay at - # the original node. - constraints = { - key: value.pop(key) - for key in tuple(value) - if key not in _SCHEMA_METADATA_KEYS - } - if constraints: - value["anyOf"] = [constraints, {"type": "null"}] + # Untyped type-specific keywords already accept null. def enforce_strict_openai_schema(schema: dict[str, Any]) -> None: diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index 30e53d7b206..dbf31293f0e 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -700,30 +700,46 @@ def test_nullable_object_preserves_property_json_pointer(self): assert name_validator.is_valid("Paris") assert not name_validator.is_valid(7) + @pytest.mark.parametrize("applicator", [None, "allOf", "anyOf", "oneOf"]) @pytest.mark.parametrize("nested_definition", [False, True]) - def test_nullable_ancestor_preserves_pointer_targets(self, nested_definition): + def test_nullable_ancestor_preserves_pointer_targets( + self, nested_definition, applicator + ): """References into properties and descendant definitions keep resolving.""" - name_schema = {"type": "STRING"} + name_schema = {"type": "string"} if nested_definition: name_schema = { - "$defs": {"Text": {"type": "STRING"}}, + "$defs": {"Text": {"type": "string"}}, "$ref": "#/properties/details/properties/name/$defs/Text", } schema = { - "type": "OBJECT", + "type": "object", "properties": { "details": { - "type": "OBJECT", + "type": "object", "nullable": True, "properties": {"name": name_schema}, "required": ["name"], }, "copy": {"$ref": "#/properties/details/properties/name"}, }, + "required": ["details", "copy"], } + if applicator: + schema["properties"]["details"][applicator] = [{"required": ["name"]}] lowercase_schema_types(schema) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + 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"}) + + assert not validator.is_valid({"details": {}, "copy": "Paris"}) + assert not validator.is_valid({"details": None, "copy": 7}) + assert not validator.is_valid({"details": {"name": "Paris"}, "copy": 7}) + assert not validator.is_valid({"copy": "Paris"}) assert schema["properties"]["details"]["properties"]["name"] is name_schema assert schema["properties"]["copy"]["$ref"] == ( "#/properties/details/properties/name" @@ -732,10 +748,6 @@ def test_nullable_ancestor_preserves_pointer_targets(self, nested_definition): assert name_schema["$ref"] == ( "#/properties/details/properties/name/$defs/Text" ) - validator = Draft202012Validator(schema) - 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_nullable_annotations_and_default_data_are_preserved(self): default = {"type": "USER_DATA", "nullable": True} From bfb80522f5a8968594cd7ee68d36fcb5887db985 Mon Sep 17 00:00:00 2001 From: Sumedh Aerram Date: Tue, 10 Nov 2026 18:36:39 -0800 Subject: [PATCH 6/6] fix(models): preserve nullable schemas across Gemini routes Resolve Gemini routes through LiteLLM when available and fall back to provider prefixes, including vertex_ai_beta and proxied routes. Preserve constructor lazy loading and keep non-Gemini schema conversion unchanged. Fixes #7916 --- src/google/adk/models/lite_llm.py | 30 ++++++++++-- tests/unittests/models/test_litellm.py | 63 ++++++++++++++++++++++---- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index d293db68861..725909c20d0 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -4414,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-") ) @@ -4459,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. diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 8a37606e2bf..e8409eba4ee 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -14489,19 +14489,39 @@ def test_strict_nullable_object_type_list_requires_all_properties(): @pytest.mark.parametrize( - "model", + "model, is_gemini", [ - "gemini/gemini-2.5-flash", - "vertex_ai/gemini-2.5-flash", - "litellm_proxy/vertex_ai/gemini-2.5-flash", - "openai/gpt-4o-mini", + ("gemini/gemini-2.5-flash", True), + ("vertex_ai/gemini-2.5-flash", True), + ("vertex_ai_beta/gemini-2.5-flash", True), + ("litellm_proxy/gemini/gemini-2.5-flash", True), + ("litellm_proxy/vertex_ai/gemini-2.5-flash", True), + ("litellm_proxy/vertex_ai_beta/gemini-2.5-flash", True), + ("openai/gpt-4o-mini", False), + ("anthropic/claude-3-5-sonnet-20241022", False), + ("vertex_ai/claude-3-5-sonnet-v2@20241022", False), + ("openai/gemini-2.5-flash", False), ], ) +@pytest.mark.parametrize( + "provider_resolution", ["available", "raises", "missing"] +) @pytest.mark.asyncio -async def test_nullable_provider_payload(model, mock_client): - """Gemini preserves nullable enums through LiteLLM's own transformation.""" +async def test_nullable_provider_payload( + model, is_gemini, provider_resolution, mock_client, monkeypatch +): + """Routes preserve nullable or type lists through provider transformation.""" from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig + if provider_resolution == "raises": + monkeypatch.setattr( + litellm, + "get_llm_provider", + Mock(side_effect=RuntimeError("unresolved")), + ) + elif provider_resolution == "missing": + monkeypatch.delattr(litellm, "get_llm_provider") + schema = types.Schema( type=types.Type.OBJECT, properties={ @@ -14533,12 +14553,16 @@ async def test_nullable_provider_payload(model, mock_client): payload = mock_client.acompletion_mock.call_args.kwargs parameters = payload["tools"][0]["function"]["parameters"] - if model.startswith("openai/"): + if not is_gemini: response = payload["response_format"]["json_schema"]["schema"] for result in (parameters, response): assert result["properties"]["city"]["type"] == ["string", "null"] + assert "nullable" not in result["properties"]["city"] + assert result["properties"]["choice"]["type"] == ["string", "null"] assert result["properties"]["choice"]["enum"] == ["x", "y", None] else: + assert parameters["properties"]["city"]["nullable"] is True + assert parameters["properties"]["choice"]["nullable"] is True config = VertexGeminiConfig() tools = config._map_function(payload["tools"], {}) response = config._map_response_schema( @@ -14551,3 +14575,26 @@ async def test_nullable_provider_payload(model, mock_client): assert result["properties"]["city"]["nullable"] is True assert result["properties"]["choice"]["nullable"] is True assert result["properties"]["choice"]["enum"] == ["x", "y"] + + +@pytest.mark.parametrize( + "resolved_model, provider, expected", + [ + ("gemini-2.5-flash", "gemini", True), + ("gemini-2.5-flash", "vertex_ai", True), + ("gemini-2.5-flash", "vertex_ai_beta", True), + ("claude-4-sonnet", "vertex_ai", False), + ("gemini-2.5-flash", "openai", False), + ], +) +def test_gemini_detection_uses_resolved_provider( + resolved_model, provider, expected, monkeypatch +): + """Provider resolution identifies aliases without matching other families.""" + monkeypatch.setattr( + litellm, + "get_llm_provider", + Mock(return_value=(resolved_model, provider, None, None)), + ) + + assert _is_litellm_gemini_model("custom-alias") is expected