Skip to content

fix(models): preserve nullable values in provider JSON schemas - #7925

Open
sumedh-aerram wants to merge 8 commits into
google:mainfrom
sumedh-aerram:fix/provider-nullable-json-schema
Open

sumedh-aerram wants to merge 8 commits into
google:mainfrom
sumedh-aerram:fix/provider-nullable-json-schema

Conversation

@sumedh-aerram

@sumedh-aerram sumedh-aerram commented Nov 1, 2026 •

Copy link
Copy Markdown

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

Problem:
GenAI/OpenAPI schemas express optional values with nullable: true, which is not a JSON Schema keyword. The Anthropic, LiteLLM, OpenAI Chat and OpenAI Responses adapters forwarded it unchanged, so a field declared nullable still rejected None under JSON Schema validation. This affects ordinary Optional[...] tool parameters when GOOGLE_GENAI_USE_ENTERPRISE=1, where the declaration is built as a types.Schema with nullable=True.

Solution:
Following the approach suggested in #7916, lowercase_schema_types (already applied to provider schemas) lowers nullable: true in place, without moving subschemas:

  • A typed schema gets "null" added to its type: "string" becomes ["string", "null"].
  • enum gets None appended. const becomes an enum of that value plus None. Comparisons use JSON equality, so 0 and False stay distinct.
  • An untyped schema with anyOf gets a {"type": "null"} branch appended. Other untyped keywords (properties, minimum, ...) already accept null, so they are left alone.
  • Only nodes whose applicators could still reject null ($ref/$dynamicRef, allOf, oneOf, not, if/then/else, or anyOf next to type) are wrapped as anyOf: [<constraints>, {"type": "null"}], with metadata and $defs kept at the node.
  • So properties, items and $defs keep their paths, and JSON Pointer $refs into nullable schemas keep resolving without any rewriting.
  • The OpenAI strict-schema helpers (lite_llm._enforce_strict_openai_schema and integrations/openai/_openai_schema.py) treat a type list containing "object" as an object, so nullable objects still get additionalProperties: false and every property required.
  • Only schema keywords are traversed; default/example data is untouched.
  • LiteLLM's legacy parameters path now goes through the same normalization as parameters_json_schema. The Gemini response-schema path is unchanged.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally. (See below: 27 pre-existing environment-dependent failures, identical with and without this change.)

New tests validate the converted schemas with jsonschema.Draft202012Validator in test_schema_utils.py, and through each adapter in test_anthropic_llm.py, test_litellm.py, test_openai_llm.py and test_openai_responses_llm.py. They cover nullable scalars, enums, const (including 0 vs False), type lists, untyped anyOf/properties/minimum, allOf/oneOf/not/if-then-else, $ref siblings, and strict OpenAI objects. They also check that JSON Pointer references into nullable ancestors, untyped nullable nodes and descendant $defs resolve at their original paths. The new regression cases fail on unchanged main and pass with the fix.

macOS:

  • Affected suites (tests/unittests/utils/test_schema_utils.py tests/unittests/integrations/openai tests/unittests/models/test_anthropic_llm.py tests/unittests/models/test_litellm.py): 1406 passed on each of Python 3.11, 3.12, 3.13 and 3.14.
  • Full pytest tests/unittests -n auto on Python 3.11 (run on the first revision of this PR): 22552 passed, 27 failed, 99 skipped, 12 xfailed, 2 xpassed. The 27 failures are the same test IDs that fail on unchanged main in this environment: 13 telemetry golden tests, 1 A2A custom-factory test, and 13 VMaaS tests on macOS. None of them touch schema conversion.
  • pre-commit on the changed files: clean. mypy on the changed modules: no new errors.

Manual End-to-End (E2E) Tests:

Live (run, gpt-4o-mini, OpenAI strict structured output, this PR head vs unchanged main): all 4 requests per side (Responses API and LiteLlm("openai/..."), GenAI types.Schema and $ref-heavy JSON schema) were accepted. With this PR the model returned null for the nullable fields it was asked to null and every reply validated (Draft 2020-12) against the schema ADK sent; on main no nullable field could be null.

Live (run, Gemini gemini-3.5-flash, LiteLLM 1.96.2, google-genai 2.34.0, this PR head): all 5 requests were accepted. Covered: LiteLlm("gemini/...") structured output and function calling, and a NodeTool through native Gemini, each with a nullable string and a nullable enum (plus an enum-value control). The model returned null where asked, every reply validated against the intended schema, and the enum was kept. The NodeTool declarations and outgoing payloads are identical to main. (On main, the two LiteLLM calls hit the free-tier per-minute limit, so there is no live A/B for those; the PR keeps Gemini payloads identical to main by design.)

Gemini paths (fixed after review): checking the two paths raised on #7916 showed that litellm 1.96.2's own Gemini transform turned ["string", "null"] into anyOf without nullable and dropped enum on nullable enum fields. So Gemini-bound schemas now keep exactly what main sends:

  • LiteLlm("gemini/...") and vertex_ai/ Gemini routes keep nullable: true and their enums (tests run litellm's own _map_function / _map_response_schema on the actual payload).
  • NodeTool declarations sent to Gemini are byte-identical to main (test compares the serialized JSON).
  • OpenAI and Anthropic adapters still get type lists.

Not run: live Claude/Anthropic (no credentials).

Reproduce manually:

from google.genai import types
from google.adk.utils._schema_utils import lowercase_schema_types
from jsonschema import Draft202012Validator

schema = types.Schema(
    type="OBJECT",
    properties={"city": types.Schema(type="STRING", nullable=True)},
).model_dump(exclude_none=True, mode="json")
lowercase_schema_types(schema)
assert Draft202012Validator(schema).is_valid({"city": None})  # fails before this change

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes. (New and affected tests pass; see the pre-existing failures above.)
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Related but separate: #7904 changes which Optional parameters are listed in required. This PR only changes how nullability is expressed, so the two don't overlap.

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[<original constraints>, {"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 google#7916
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 google#7916
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 google#7916
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 google#7916
…le-json-schema-v2

# Conflicts:
#	src/google/adk/integrations/openai/_openai_schema.py
#	src/google/adk/models/lite_llm.py
#	tests/unittests/integrations/openai/test_openai_responses_llm.py

@BlueRaddish BlueRaddish left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new applicator branch still moves JSON Pointer targets inside a nullable ancestor.

For example, these parameters are valid for {"details": {"name": "Paris"}, "copy": "Paris"} before normalization:

{
  "type": "object",
  "properties": {
    "details": {
      "type": "object",
      "nullable": true,
      "properties": {"name": {"type": "string"}},
      "required": ["name"],
      "allOf": [{"required": ["name"]}]
    },
    "copy": {"$ref": "#/properties/details/properties/name"}
  },
  "required": ["details", "copy"]
}

At head fd74915771abc607b31b9fbdf940bbf54593e0e4, needs_union moves details.properties under details.anyOf[0], while the copy reference remains #/properties/details/properties/name. Running the resulting LiteLLM tool parameters through Draft202012Validator(...).is_valid(...) raises PointerToNowhere instead of accepting that ordinary non-null input. A descendant $defs reference at that location breaks as well. The same inputs validate against the parent's normalized schemas, and removing just allOf makes both cases work on this head.

Please preserve these target locations when wrapping an applicator-bearing nullable schema, or update affected references consistently. The new pointer-preservation tests cover nullable ancestors without an applicator; adding this combination would catch the remaining regression.

Verification: two failing pointer cases, parent valid/invalid controls for each, and six passing head controls (ordinary/null/invalid inputs without the applicator), using real jsonschema 4.26.0 on Windows/Python 3.13.14. I executed the exact selected parent/head normalization definitions and the current LiteLLM function-declaration converter plus its called helpers. The declaration's attributes were supplied by a small stand-in; this does not exercise full ADK/LiteLLM initialization, the GenAI model constructor, a live provider, or the owning/full tox suites. No product source was changed. Analysis and reproduction were AI-assisted.

@sumedh-aerram

Copy link
Copy Markdown
Author

Thanks @BlueRaddish, confirmed and fixed in 89a8e41. When a nullable schema also carries an applicator, properties, items, and descendant $defs now stay at their original locations; only the applicators and references that would reject null move into the union. Existing JSON Pointers keep resolving.

I added your exact case and the descendant $defs variant. Both failed on fd74915 and pass now, with valid, null and invalid controls for allOf, anyOf, and oneOf. Gemini-bound payloads are still identical to main.

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 google#7916
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-Gemini tool schemas reject None for nullable parameters

3 participants