Repository navigation
fix(models): preserve nullable values in provider JSON schemas - #7925
sumedh-aerram wants to merge 8 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
|
Thanks @BlueRaddish, confirmed and fixed in 89a8e41. When a nullable schema also carries an applicator, I added your exact case and the descendant |
…le-json-schema-v2
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
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 rejectedNoneunder JSON Schema validation. This affects ordinaryOptional[...]tool parameters whenGOOGLE_GENAI_USE_ENTERPRISE=1, where the declaration is built as atypes.Schemawithnullable=True.Solution:
Following the approach suggested in #7916,
lowercase_schema_types(already applied to provider schemas) lowersnullable: truein place, without moving subschemas:"null"added to its type:"string"becomes["string", "null"].enumgetsNoneappended.constbecomes anenumof that value plusNone. Comparisons use JSON equality, so0andFalsestay distinct.anyOfgets a{"type": "null"}branch appended. Other untyped keywords (properties,minimum, ...) already accept null, so they are left alone.$ref/$dynamicRef,allOf,oneOf,not,if/then/else, oranyOfnext totype) are wrapped asanyOf: [<constraints>, {"type": "null"}], with metadata and$defskept at the node.properties,itemsand$defskeep their paths, and JSON Pointer$refs into nullable schemas keep resolving without any rewriting.lite_llm._enforce_strict_openai_schemaandintegrations/openai/_openai_schema.py) treat a type list containing"object"as an object, so nullable objects still getadditionalProperties: falseand every property required.default/exampledata is untouched.parameterspath now goes through the same normalization asparameters_json_schema. The Gemini response-schema path is unchanged.Testing Plan
Unit Tests:
New tests validate the converted schemas with
jsonschema.Draft202012Validatorintest_schema_utils.py, and through each adapter intest_anthropic_llm.py,test_litellm.py,test_openai_llm.pyandtest_openai_responses_llm.py. They cover nullable scalars, enums,const(including0vsFalse), type lists, untypedanyOf/properties/minimum,allOf/oneOf/not/if-then-else,$refsiblings, and strict OpenAI objects. They also check that JSON Pointer references into nullable ancestors, untyped nullable nodes and descendant$defsresolve at their original paths. The new regression cases fail on unchangedmainand pass with the fix.macOS:
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.pytest tests/unittests -n autoon 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 unchangedmainin this environment: 13 telemetry golden tests, 1 A2A custom-factory test, and 13 VMaaS tests on macOS. None of them touch schema conversion.pre-commiton the changed files: clean.mypyon 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 unchangedmain): all 4 requests per side (Responses API andLiteLlm("openai/..."), GenAItypes.Schemaand$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; onmainno 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 aNodeToolthrough nativeGemini, each with a nullable string and a nullable enum (plus an enum-value control). The model returnednullwhere asked, every reply validated against the intended schema, and the enum was kept. TheNodeTooldeclarations and outgoing payloads are identical tomain. (Onmain, 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 tomainby 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"]intoanyOfwithoutnullableand droppedenumon nullable enum fields. So Gemini-bound schemas now keep exactly whatmainsends:LiteLlm("gemini/...")andvertex_ai/Gemini routes keepnullable: trueand their enums (tests run litellm's own_map_function/_map_response_schemaon the actual payload).NodeTooldeclarations sent to Gemini are byte-identical tomain(test compares the serialized JSON).Not run: live Claude/Anthropic (no credentials).
Reproduce manually:
Checklist
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.