Skip to content

Commit 642439c

Browse files
ShauryaaSharmacopybara-github
authored andcommitted
fix: sanitize anyOf schemas for Vertex AI function declarations
Merge #6381 Flatten the common Optional[X] -> anyOf: [X, {type: null}] pattern into X + nullable: true for parameters_json_schema and response_json_schema when targeting Vertex AI, gated on GoogleLLMVariant.VERTEX_AI. Closes: #6373 PiperOrigin-RevId: 971577962
1 parent 5d214ba commit 642439c

3 files changed

Lines changed: 184 additions & 1 deletion

File tree

src/google/adk/cli/conformance/_conformance_test_google_llm.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ def _resolve_refs(data: Any, defs: dict[str, Any]) -> Any:
6161
if ref_path.startswith('#/$defs/'):
6262
def_name = ref_path.split('/')[-1]
6363
if def_name in defs:
64-
return _resolve_refs(defs[def_name], defs)
64+
resolved = _resolve_refs(defs[def_name], defs)
65+
if isinstance(resolved, dict):
66+
ref_copy = data.copy()
67+
del ref_copy['$ref']
68+
resolved_copy = resolved.copy()
69+
resolved_copy.update(ref_copy)
70+
return resolved_copy
71+
return resolved
6572
return {k: _resolve_refs(v, defs) for k, v in data.items()}
6673
elif isinstance(data, list):
6774
return [_resolve_refs(x, defs) for x in data]

src/google/adk/tools/_function_tool_declarations.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
from pydantic import create_model
4141
from pydantic import fields as pydantic_fields
4242

43+
from ..utils.variant_utils import get_google_llm_variant
44+
from ..utils.variant_utils import GoogleLLMVariant
45+
4346

4447
def _get_function_fields(
4548
func: Callable[..., Any],
@@ -114,6 +117,77 @@ def get_callable_name(func: Callable[..., Any]) -> str:
114117
return getattr(func, '__name__', None) or func.__class__.__name__
115118

116119

120+
def _flatten_optional_any_of(schema: dict[str, Any]) -> dict[str, Any]:
121+
"""Flattens `Optional[X]`-style `anyOf` schemas for Vertex AI.
122+
123+
Pydantic serializes `Optional[X]` fields as
124+
`{"anyOf": [<X schema>, {"type": "null"}], ...}`. Vertex AI rejects such
125+
schemas because the wrapping schema itself has no top-level `type` field
126+
(see https://github.com/googleapis/python-genai/issues/1807), even though
127+
the Gemini Developer API (AI Studio) accepts them as-is. This merges the
128+
non-null branch into the parent schema and marks it `nullable` instead,
129+
which Vertex AI accepts.
130+
131+
True unions with more than one non-null variant (e.g. `Union[int, str]`)
132+
can't be losslessly flattened this way and are left untouched.
133+
"""
134+
any_of = schema.get('anyOf')
135+
if not isinstance(any_of, list):
136+
return schema
137+
138+
# We need at least one null variant and at least one non-null variant
139+
# to flatten an optional/nullable schema.
140+
if len(any_of) < 2:
141+
return schema
142+
143+
null_variants = [
144+
variant
145+
for variant in any_of
146+
if isinstance(variant, dict) and variant.get('type') == 'null'
147+
]
148+
# We only flatten if there is exactly one null variant (i.e. it is optional).
149+
if len(null_variants) != 1:
150+
return schema
151+
152+
non_null_variants = [v for v in any_of if v not in null_variants]
153+
if len(non_null_variants) == 1:
154+
# Optional[X] -> X + nullable: true
155+
flattened = dict(non_null_variants[0])
156+
for key, value in schema.items():
157+
if key != 'anyOf':
158+
flattened.setdefault(key, value)
159+
flattened['nullable'] = True
160+
return flattened
161+
else:
162+
# Optional[Union[A, B, ...]] -> Union[A, B, ...] + nullable: true
163+
# We keep the anyOf but remove the null variant.
164+
flattened = dict(schema)
165+
flattened['anyOf'] = non_null_variants
166+
flattened['nullable'] = True
167+
return flattened
168+
169+
170+
def _sanitize_json_schema_for_vertex(schema: Any) -> Any:
171+
"""Recursively rewrites `Optional[X]` `anyOf` schemas for Vertex AI.
172+
173+
Vertex AI's schema validator requires every (sub)schema to declare a
174+
top-level `type`, which Pydantic's `anyOf`-based representation of
175+
`Optional`/`Union` fields does not provide. This is only applied for the
176+
Vertex AI backend since the Gemini Developer API (AI Studio) already
177+
accepts the unmodified Pydantic schema.
178+
"""
179+
if isinstance(schema, list):
180+
return [_sanitize_json_schema_for_vertex(item) for item in schema]
181+
if not isinstance(schema, dict):
182+
return schema
183+
184+
sanitized = {
185+
key: _sanitize_json_schema_for_vertex(value)
186+
for key, value in schema.items()
187+
}
188+
return _flatten_optional_any_of(sanitized)
189+
190+
117191
def _build_parameters_json_schema(
118192
func: Callable[..., Any],
119193
ignore_params: Optional[list[str]] = None,
@@ -245,9 +319,13 @@ def build_function_declaration_with_json_schema(
245319
>>> decl.name
246320
'paint_room'
247321
"""
322+
is_vertex_ai = get_google_llm_variant() == GoogleLLMVariant.VERTEX_AI
323+
248324
# Handle Pydantic BaseModel classes
249325
if isinstance(func, type) and issubclass(func, pydantic.BaseModel):
250326
schema = func.model_json_schema()
327+
if is_vertex_ai:
328+
schema = _sanitize_json_schema_for_vertex(schema)
251329
description = inspect.cleandoc(func.__doc__) if func.__doc__ else None
252330
return types.FunctionDeclaration(
253331
name=func.__name__,
@@ -265,10 +343,14 @@ def build_function_declaration_with_json_schema(
265343

266344
parameters_schema = _build_parameters_json_schema(func, ignore_params)
267345
if parameters_schema:
346+
if is_vertex_ai:
347+
parameters_schema = _sanitize_json_schema_for_vertex(parameters_schema)
268348
declaration.parameters_json_schema = parameters_schema
269349

270350
response_schema = _build_response_json_schema(func)
271351
if response_schema:
352+
if is_vertex_ai:
353+
response_schema = _sanitize_json_schema_for_vertex(response_schema)
272354
declaration.response_json_schema = response_schema
273355

274356
return declaration

tests/unittests/tools/test_function_tool_declarations.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,21 @@
2323
from collections.abc import Sequence
2424
import dataclasses
2525
from enum import Enum
26+
import os
2627
from typing import Any
2728
from typing import AsyncGenerator
2829
from typing import Generator
2930
from typing import Literal
3031
from typing import Optional
32+
from unittest import mock
3133

3234
from absl.testing import parameterized
3335
from google.adk.tools._function_tool_declarations import build_function_declaration_with_json_schema
3436
from google.adk.tools.tool_context import ToolContext
3537
from pydantic import BaseModel
3638
from pydantic import Field
3739
from pydantic.dataclasses import dataclass as pyd_dataclass
40+
import pytest
3841

3942

4043
class Color(Enum):
@@ -837,6 +840,97 @@ def complex_fn(
837840
)
838841

839842

843+
@mock.patch.dict(os.environ, {"GOOGLE_GENAI_USE_ENTERPRISE": "true"})
844+
class TestVertexAiAnyOfSanitization(parameterized.TestCase):
845+
"""Tests that `Optional`/`Union` `anyOf` schemas are made Vertex-safe.
846+
847+
Vertex AI rejects schemas where an `anyOf` wrapper has no top-level `type`
848+
(see https://github.com/googleapis/python-genai/issues/1807), while AI
849+
Studio accepts them unmodified. This is only exercised when the
850+
enterprise/Vertex variant is active.
851+
"""
852+
853+
def test_optional_field_flattened_to_nullable(self):
854+
"""Optional[list[str]] should become a plain array schema + nullable."""
855+
856+
def search(query: str, sources: Optional[list[str]] = None) -> str:
857+
"""Search using optional sources."""
858+
return query
859+
860+
decl = build_function_declaration_with_json_schema(search)
861+
schema = decl.parameters_json_schema
862+
863+
sources_schema = schema["properties"]["sources"]
864+
self.assertNotIn("anyOf", sources_schema)
865+
self.assertEqual(sources_schema["type"], "array")
866+
self.assertEqual(sources_schema["items"]["type"], "string")
867+
self.assertTrue(sources_schema["nullable"])
868+
self.assertIsNone(sources_schema["default"])
869+
870+
def test_optional_pydantic_model_field_flattened(self):
871+
"""Optional[BaseModel] fields should also be flattened, not just primitives."""
872+
873+
def save_address(address: Optional[Address] = None) -> str:
874+
"""Save an optional address."""
875+
return "ok"
876+
877+
decl = build_function_declaration_with_json_schema(save_address)
878+
schema = decl.parameters_json_schema
879+
880+
address_schema = schema["properties"]["address"]
881+
self.assertNotIn("anyOf", address_schema)
882+
self.assertIn("$ref", address_schema)
883+
self.assertTrue(address_schema["nullable"])
884+
885+
def test_output_schema_pydantic_model_flattened(self):
886+
"""Optional fields on a BaseModel passed directly should be flattened."""
887+
888+
class CoordinatorResponse(BaseModel):
889+
"""A coordinator response."""
890+
891+
answer: str
892+
sources: Optional[list[str]] = None
893+
894+
decl = build_function_declaration_with_json_schema(CoordinatorResponse)
895+
schema = decl.parameters_json_schema
896+
897+
sources_schema = schema["properties"]["sources"]
898+
self.assertNotIn("anyOf", sources_schema)
899+
self.assertEqual(sources_schema["type"], "array")
900+
self.assertTrue(sources_schema["nullable"])
901+
902+
def test_true_union_left_untouched(self):
903+
"""A real multi-variant union (not Optional) is not flattened."""
904+
905+
def process(value: int | str) -> str:
906+
"""Process a value."""
907+
return str(value)
908+
909+
decl = build_function_declaration_with_json_schema(process)
910+
schema = decl.parameters_json_schema
911+
912+
value_schema = schema["properties"]["value"]
913+
self.assertIn("anyOf", value_schema)
914+
self.assertLen(value_schema["anyOf"], 2)
915+
916+
def test_optional_union_flattened(self):
917+
"""Optional[Union[int, str]] should flatten to Union[int, str] + nullable."""
918+
919+
def process(value: Optional[int | str] = None) -> str:
920+
"""Process an optional union value."""
921+
return str(value)
922+
923+
decl = build_function_declaration_with_json_schema(process)
924+
schema = decl.parameters_json_schema
925+
926+
value_schema = schema["properties"]["value"]
927+
self.assertIn("anyOf", value_schema)
928+
self.assertLen(value_schema["anyOf"], 2)
929+
types = {v.get("type") for v in value_schema["anyOf"]}
930+
self.assertEqual(types, {"integer", "string"})
931+
self.assertTrue(value_schema["nullable"])
932+
933+
840934
class TestPydanticModelAsFunction(parameterized.TestCase):
841935
"""Tests for using Pydantic BaseModel directly."""
842936

0 commit comments

Comments
 (0)