diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index ad7bc63b81e..725909c20d0 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -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. @@ -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: @@ -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": { @@ -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: @@ -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-") ) @@ -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. 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 466c40164f0..eed94340391 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -137,35 +137,43 @@ 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", @@ -173,12 +181,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 = 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", @@ -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", @@ -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: @@ -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()) 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 b72be4748e2..494499e2ef8 100644 --- a/tests/unittests/integrations/openai/test_openai_responses_llm.py +++ b/tests/unittests/integrations/openai/test_openai_responses_llm.py @@ -37,6 +37,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 @@ -44,6 +45,7 @@ from google.adk.integrations.openai._openai_responses_llm import OpenAIResponsesLlm from google.adk.models.llm_request import LlmRequest from google.adk.utils._schema_utils import enforce_strict_openai_schema +from jsonschema import Draft202012Validator from openai import AsyncOpenAI from openai.types.responses import EasyInputMessageParam from openai.types.responses import FunctionToolParam @@ -97,6 +99,62 @@ 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) + + 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': {}}) + 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 b0538eeb2a8..8b7c75b1577 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -48,6 +48,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 @@ -726,6 +727,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 18176f313e4..e8409eba4ee 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -81,6 +81,7 @@ from google.adk.models.llm_response import LlmResponse from google.adk.utils._schema_utils import enforce_strict_openai_schema from google.genai import types +from jsonschema import Draft202012Validator import litellm from litellm import ChatCompletionAssistantMessage from litellm import ChatCompletionMessageToolCall @@ -330,6 +331,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, @@ -14419,3 +14468,133 @@ 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}) + + +@pytest.mark.parametrize( + "model, is_gemini", + [ + ("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, 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={ + "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 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( + 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"] + + +@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 diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index 5b480fd9f7e..eccca0bb783 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 ConfigDict from pydantic import Field @@ -483,6 +484,303 @@ 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( + "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", + [ + ({"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("applicator", [None, "allOf", "anyOf", "oneOf"]) + @pytest.mark.parametrize("nested_definition", [False, True]) + def test_nullable_ancestor_preserves_pointer_targets( + self, nested_definition, applicator + ): + """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"}, + }, + "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" + ) + if nested_definition: + assert name_schema["$ref"] == ( + "#/properties/details/properties/name/$defs/Text" + ) + + 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", diff --git a/tests/unittests/workflow/test_node_tool.py b/tests/unittests/workflow/test_node_tool.py index 82745d25d80..d9af19a8ad0 100644 --- a/tests/unittests/workflow/test_node_tool.py +++ b/tests/unittests/workflow/test_node_tool.py @@ -2811,3 +2811,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"}}' + )