Skip to content

Non-Gemini tool schemas reject None for nullable parameters #7916

Description

@sumedh-aerram

🔴 Required Information

Describe the Bug:

ADK's non-Gemini adapters send nullable: true as if it were a JSON Schema validation keyword. JSON Schema ignores that OpenAPI keyword, so a Python tool parameter that accepts None is advertised to Claude/OpenAI/LiteLLM with a schema that rejects null.

This also affects ordinary FunctionTool declarations when GOOGLE_GENAI_USE_ENTERPRISE=1: the Vertex sanitizer rewrites anyOf[..., {"type": "null"}] into nullable: true, and the non-Gemini adapters do not restore the null branch. Handwritten types.Schema(nullable=True) follows the same conversion path.

Steps to Reproduce:

  1. Use an ADK development checkout with the test dependencies (reproduced against the schema-conversion code unchanged through upstream 9a5117b73).
  2. Run with GOOGLE_GENAI_USE_ENTERPRISE=1:
from typing import Optional
from google.adk.tools import FunctionTool
from google.adk.models.anthropic_llm import function_declaration_to_tool_param
from jsonschema import Draft202012Validator

def weather(city: Optional[str] = None):
    """Look up the weather."""

declaration = FunctionTool(weather)._get_declaration()
schema = function_declaration_to_tool_param(declaration)["input_schema"]
print(schema["properties"]["city"])
print(Draft202012Validator(schema).is_valid({"city": None}))

The private conversion methods are used here to inspect the actual provider payload without credentials or a model request. The same rejection reproduces in the LiteLLM and OpenAI tool adapters.

Expected Behavior:

The provider's JSON Schema accepts both a string and null, matching the Python parameter. Non-null constraints remain effective.

Observed Behavior:

{'type': 'string', 'default': None, 'title': 'City', 'nullable': True}
False

Environment Details:

  • ADK Library Version: development checkout, 2.13.0
  • Desktop OS: macOS
  • Python Version: 3.11

Model Information:

  • Are you using LiteLLM: reproduced in LiteLLM, Anthropic, and OpenAI conversion paths
  • Which model is being used: no live model request; inspecting generated request schemas

🟡 Optional Information

Additional Context:

I am working on a focused fix and would like to contribute it. Would normalizing nullable into a JSON Schema null alternative in the shared schema traversal be an acceptable approach? I plan to cover nested/union/enum schemas, $defs references and strict nullable objects, and preserve the existing Gemini response-schema path. Adjacent PRs #7626, #7591 and #7285 address schema serialization/validation rather than nullable conversion; I inspected their patches before starting.

How often has this issue occurred?:

Always for the reproduced declarations. No claim of live provider acceptance or model behavior is made here.

Activity

  1. added theissue type on Nov 2, 2026
  2. surajksharma07 commented on Nov 2, 2026

    @surajksharma07
    Collaborator

    Thanks @sumedh-aerram Reproduced this on 2.13.0 and on current main with GOOGLE_GENAI_USE_ENTERPRISE=1: the Anthropic, LiteLLM and OpenAI schemas all keep nullable: true and reject {"city": None}, same for a handwritten Schema(nullable=True).

    As a lighter workaround, turning nullable into a type list after lowercase_schema_types (e.g. "string" -> ["string", "null"], adding None to enums, and anyOf with a null branch only when there’s no type) fixed all of them for me. It kept the other constraints working and the adapter tests stayed green. It also leaves $ref paths where they are, which could keep #7925 smaller.

    Could you check if that works on your side? Either way, before #7925 goes for review please try it once against a live provider (Claude on Vertex or OpenAI strict mode) and make sure the $ref-heavy cases still validate.

  3. sumedh-aerram commented on Nov 2, 2026

    @sumedh-aerram
    Author

    Thanks @surajksharma07, that works and it's simpler. #7925 now uses it:

    • Typed schemas get "null" added to their type. enum gets None, and const becomes an enum with None, compared with JSON equality so 0 and False stay distinct.
    • Untyped schemas get a null branch appended to an existing anyOf. Otherwise they are left alone, since properties, minimum and similar keywords already accept null.
    • The anyOf wrap is kept only where an applicator could still reject null: $ref, allOf, oneOf, not, if/then/else, or anyOf next to type.
    • The JSON Pointer rewriting is gone. Refs into nullable ancestors, untyped nullable nodes and descendant $defs validate at their original paths, and there are tests for each case.
    • The OpenAI strict helpers now treat ["object", "null"] as an object, so nullable objects keep additionalProperties: false and all properties required.
    • Production code is now +126/-34, down from +156/-44. The adapter tests (1406) pass on Python 3.11 through 3.14.

    The live provider check is next. I'll post the OpenAI strict-mode and Claude results here before asking for review.

  4. sumedh-aerram commented on Nov 2, 2026

    @sumedh-aerram
    Author

    @surajksharma07 I ran the live check against OpenAI strict mode (gpt-4o-mini, structured output with strict: true). I ran it on this PR's head and on unchanged main, through both OpenAIResponsesLlm and LiteLlm("openai/...").

    There were two schemas, and the prompt asked for every nullable field to be null:

    • a GenAI types.Schema with a nullable string and a nullable object;
    • a $ref-heavy JSON schema: a nullable field inside a $defs entry, $refs to #/$defs/..., a nullable object, and a nullable string.
    Case This PR main
    Responses API, GenAI schema accepted; city = null (the model filled details with an object, which is also valid) accepted; nothing null
    Responses API, $ref schema accepted; city, details, item.note all null accepted; nothing null
    LiteLLM, GenAI schema accepted; city, details null accepted; nothing null
    LiteLLM, $ref schema accepted; city, details, item.note all null accepted; nothing null

    On main, OpenAI ignores nullable, so strict mode forces the model to make up values. With this PR, every request was accepted, and every reply validated (jsonschema Draft 2020-12) against the schema ADK sent. The model could return null wherever it was asked to. I didn't test Claude on Vertex, because I don't have Vertex access set up.

  5. surajksharma07 commented on Nov 4, 2026

    @surajksharma07
    Collaborator

    Thanks @sumedh-aerram, the OpenAI strict-mode results look great and the type-list version of #7925 reads much cleaner. Main and 2.13.0 still have the old behaviour so this is still needed.

    Two things worth a quick check before review. tools/_node_tool.py also calls lowercase_schema_types so NodeTool schemas that go to Gemini will now carry ["string", "null"] too. And the LiteLLM path now always emits type lists, so one run with LiteLlm("gemini/...") would confirm LiteLLM’s Gemini conversion handles them.

    If you can’t get Vertex access, Claude via the direct Anthropic API or LiteLlm("anthropic/...") would cover the same input_schema path.

    Once those check out, could you add the live results to the PR’s E2E section (it still says “Not run”)? Then we can get CI approved and the review going.

  6. sumedh-aerram commented on Nov 5, 2026

    @sumedh-aerram
    Author

    Thanks @surajksharma07, both checks were worth it. The LiteLLM one found a real problem.

    1. LiteLlm("gemini/..."): litellm 1.96.2's own Gemini transform turned ["string", "null"] into anyOf without nullable and dropped enum on nullable enum fields. Before this PR, nullable: true passed through, so this was a regression. Fixed in fix(models): preserve nullable values in provider JSON schemas #7925: Gemini and Vertex Gemini routes in LiteLlm keep the nullable form. A test runs litellm's own Gemini conversion on the actual payload and checks that nullable and the enum survive.
    2. NodeTool -> Gemini: rather than rely on Gemini accepting type arrays in parametersJsonSchema, NodeTool declarations now stay byte-identical to main. A test compares the serialized JSON. The type-list conversion only happens in the OpenAI and Anthropic adapters.

    Live Gemini (gemini-3.5-flash), on this PR's head: all 5 requests were accepted. That covers LiteLlm("gemini/...") structured output and function calling, and a NodeTool through native Gemini, each with a nullable string and a nullable enum. The model returned null where asked, every reply validated, and the enums were kept. I don't have Anthropic credentials, so Claude isn't covered, but the OpenAI strict-mode run exercises the same type-list form. The PR's E2E section now has the Gemini and OpenAI results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions