Skip to content

Commit ab6ca8a

Browse files
committed
Merge origin/main and resolve conflicts
2 parents 52fb5d1 + 64a6fe4 commit ab6ca8a

File tree

5 files changed

+264
-7
lines changed

5 files changed

+264
-7
lines changed

pydantic_ai_slim/pydantic_ai/_json_schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __init__(
4545
self.prefer_inlined_defs = prefer_inlined_defs
4646
self.simplify_nullable_unions = simplify_nullable_unions
4747

48-
self.defs: dict[str, JsonSchema] = self.schema.get('$defs', {})
48+
self.defs: dict[str, JsonSchema] = deepcopy(self.schema.get('$defs', {}))
4949
self.refs_stack: list[str] = []
5050
self.recursive_refs = set[str]()
5151

pydantic_ai_slim/pydantic_ai/profiles/google.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ def transform(self, schema: JsonSchema) -> JsonSchema:
4444
if (const := schema.pop('const', None)) is not None:
4545
# Gemini doesn't support const, but it does support enum with a single value
4646
schema['enum'] = [const]
47+
# If type is not present, infer it from the const value for Gemini API compatibility
48+
if 'type' not in schema:
49+
if isinstance(const, str):
50+
schema['type'] = 'string'
51+
elif isinstance(const, bool):
52+
# bool must be checked before int since bool is a subclass of int in Python
53+
schema['type'] = 'boolean'
54+
elif isinstance(const, int):
55+
schema['type'] = 'integer'
56+
elif isinstance(const, float):
57+
schema['type'] = 'number'
4758
schema.pop('discriminator', None)
4859
schema.pop('examples', None)
4960

tests/profiles/test_google.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Tests for Google JSON schema transformer.
2+
3+
The GoogleJsonSchemaTransformer transforms JSON schemas for compatibility with Gemini API:
4+
- Converts `const` to `enum` with inferred `type` field
5+
- Removes unsupported fields like $schema, title, discriminator, examples
6+
- Handles format fields by moving them to description
7+
"""
8+
9+
from __future__ import annotations as _annotations
10+
11+
from typing import Literal
12+
13+
from inline_snapshot import snapshot
14+
from pydantic import BaseModel
15+
16+
from pydantic_ai.profiles.google import GoogleJsonSchemaTransformer, google_model_profile
17+
18+
# =============================================================================
19+
# Transformer Tests - const to enum conversion with type inference
20+
# =============================================================================
21+
22+
23+
def test_const_string_infers_type():
24+
"""When converting const to enum, type should be inferred for string values."""
25+
schema = {'const': 'hello'}
26+
transformer = GoogleJsonSchemaTransformer(schema)
27+
transformed = transformer.walk()
28+
29+
assert transformed == snapshot({'enum': ['hello'], 'type': 'string'})
30+
31+
32+
def test_const_integer_infers_type():
33+
"""When converting const to enum, type should be inferred for integer values."""
34+
schema = {'const': 42}
35+
transformer = GoogleJsonSchemaTransformer(schema)
36+
transformed = transformer.walk()
37+
38+
assert transformed == snapshot({'enum': [42], 'type': 'integer'})
39+
40+
41+
def test_const_float_infers_type():
42+
"""When converting const to enum, type should be inferred for float values."""
43+
schema = {'const': 3.14}
44+
transformer = GoogleJsonSchemaTransformer(schema)
45+
transformed = transformer.walk()
46+
47+
assert transformed == snapshot({'enum': [3.14], 'type': 'number'})
48+
49+
50+
def test_const_boolean_infers_type():
51+
"""When converting const to enum, type should be inferred for boolean values."""
52+
schema = {'const': True}
53+
transformer = GoogleJsonSchemaTransformer(schema)
54+
transformed = transformer.walk()
55+
56+
assert transformed == snapshot({'enum': [True], 'type': 'boolean'})
57+
58+
59+
def test_const_false_boolean_infers_type():
60+
"""When converting const to enum, type should be inferred for False boolean."""
61+
schema = {'const': False}
62+
transformer = GoogleJsonSchemaTransformer(schema)
63+
transformed = transformer.walk()
64+
65+
assert transformed == snapshot({'enum': [False], 'type': 'boolean'})
66+
67+
68+
def test_const_preserves_existing_type():
69+
"""When const has an existing type field, it should be preserved."""
70+
schema = {'const': 'hello', 'type': 'string'}
71+
transformer = GoogleJsonSchemaTransformer(schema)
72+
transformed = transformer.walk()
73+
74+
assert transformed == snapshot({'enum': ['hello'], 'type': 'string'})
75+
76+
77+
def test_const_array_does_not_infer_type():
78+
"""When const is an array, type cannot be inferred and should not be added."""
79+
schema = {'const': [1, 2, 3]}
80+
transformer = GoogleJsonSchemaTransformer(schema)
81+
transformed = transformer.walk()
82+
83+
assert transformed == snapshot({'enum': [[1, 2, 3]]})
84+
85+
86+
def test_const_in_nested_object():
87+
"""const should be properly converted in nested object properties."""
88+
89+
class TaggedModel(BaseModel):
90+
tag: Literal['hello']
91+
value: str
92+
93+
schema = TaggedModel.model_json_schema()
94+
transformer = GoogleJsonSchemaTransformer(schema)
95+
transformed = transformer.walk()
96+
97+
# The tag property should have both enum and type
98+
assert transformed['properties']['tag'] == snapshot({'enum': ['hello'], 'type': 'string'})
99+
100+
101+
# =============================================================================
102+
# Transformer Tests - field removal
103+
# =============================================================================
104+
105+
106+
def test_removes_schema_field():
107+
"""$schema field should be removed."""
108+
schema = {'$schema': 'http://json-schema.org/draft-07/schema#', 'type': 'string'}
109+
transformer = GoogleJsonSchemaTransformer(schema)
110+
transformed = transformer.walk()
111+
112+
assert '$schema' not in transformed
113+
assert transformed == snapshot({'type': 'string'})
114+
115+
116+
def test_removes_title_field():
117+
"""title field should be removed."""
118+
schema = {'title': 'MyString', 'type': 'string'}
119+
transformer = GoogleJsonSchemaTransformer(schema)
120+
transformed = transformer.walk()
121+
122+
assert 'title' not in transformed
123+
assert transformed == snapshot({'type': 'string'})
124+
125+
126+
def test_removes_discriminator_field():
127+
"""discriminator field should be removed."""
128+
schema = {'discriminator': {'propertyName': 'type'}, 'type': 'object'}
129+
transformer = GoogleJsonSchemaTransformer(schema)
130+
transformed = transformer.walk()
131+
132+
assert 'discriminator' not in transformed
133+
assert transformed == snapshot({'type': 'object'})
134+
135+
136+
def test_removes_examples_field():
137+
"""examples field should be removed."""
138+
schema = {'examples': ['foo', 'bar'], 'type': 'string'}
139+
transformer = GoogleJsonSchemaTransformer(schema)
140+
transformed = transformer.walk()
141+
142+
assert 'examples' not in transformed
143+
assert transformed == snapshot({'type': 'string'})
144+
145+
146+
def test_removes_exclusive_min_max():
147+
"""exclusiveMinimum and exclusiveMaximum should be removed."""
148+
schema = {'type': 'integer', 'exclusiveMinimum': 0, 'exclusiveMaximum': 100}
149+
transformer = GoogleJsonSchemaTransformer(schema)
150+
transformed = transformer.walk()
151+
152+
assert 'exclusiveMinimum' not in transformed
153+
assert 'exclusiveMaximum' not in transformed
154+
assert transformed == snapshot({'type': 'integer'})
155+
156+
157+
# =============================================================================
158+
# Transformer Tests - format handling
159+
# =============================================================================
160+
161+
162+
def test_format_moved_to_description():
163+
"""format should be moved to description for string types."""
164+
schema = {'type': 'string', 'format': 'date-time'}
165+
transformer = GoogleJsonSchemaTransformer(schema)
166+
transformed = transformer.walk()
167+
168+
assert 'format' not in transformed
169+
assert transformed == snapshot({'type': 'string', 'description': 'Format: date-time'})
170+
171+
172+
def test_format_appended_to_existing_description():
173+
"""format should be appended to existing description."""
174+
schema = {'type': 'string', 'format': 'email', 'description': 'User email address'}
175+
transformer = GoogleJsonSchemaTransformer(schema)
176+
transformed = transformer.walk()
177+
178+
assert 'format' not in transformed
179+
assert transformed == snapshot({'type': 'string', 'description': 'User email address (format: email)'})
180+
181+
182+
# =============================================================================
183+
# Model Profile Tests
184+
# =============================================================================
185+
186+
187+
def test_model_profile_gemini_2():
188+
"""Gemini 2.x models should have proper profile settings."""
189+
profile = google_model_profile('gemini-2.0-flash')
190+
assert profile is not None
191+
assert profile.json_schema_transformer == GoogleJsonSchemaTransformer
192+
assert profile.supports_json_schema_output is True
193+
194+
195+
def test_model_profile_gemini_3():
196+
"""Gemini 3.x models should support native output with builtin tools."""
197+
profile = google_model_profile('gemini-3.0-pro')
198+
assert profile is not None
199+
assert profile.google_supports_native_output_with_builtin_tools is True # type: ignore
200+
201+
202+
def test_model_profile_image_model():
203+
"""Image models should have limited capabilities."""
204+
profile = google_model_profile('gemini-2.0-flash-image')
205+
assert profile is not None
206+
assert profile.supports_image_output is True
207+
assert profile.supports_json_schema_output is False
208+
assert profile.supports_tools is False

tests/test_json_schema.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations as _annotations
44

5+
from copy import deepcopy
56
from typing import Any
67

78
from pydantic_ai._json_schema import JsonSchemaTransformer
@@ -49,3 +50,40 @@ def transform(self, schema: dict[str, Any]) -> dict[str, Any]:
4950
# Should keep anyOf since it's not nullable
5051
assert 'anyOf' in result3
5152
assert len(result3['anyOf']) == 2
53+
54+
55+
def test_schema_defs_not_modified():
56+
"""Test that the original schema $defs are not modified during transformation."""
57+
58+
# Create a concrete subclass for testing
59+
class TestTransformer(JsonSchemaTransformer):
60+
def transform(self, schema: dict[str, Any]) -> dict[str, Any]:
61+
return schema
62+
63+
# Create a schema with $defs that should not be modified
64+
original_schema = {
65+
'type': 'object',
66+
'properties': {'value': {'$ref': '#/$defs/TestUnion'}},
67+
'$defs': {
68+
'TestUnion': {
69+
'anyOf': [
70+
{'type': 'string'},
71+
{'type': 'number'},
72+
],
73+
'title': 'TestUnion',
74+
}
75+
},
76+
}
77+
78+
# Keep a deepcopy to compare against later
79+
original_schema_copy = deepcopy(original_schema)
80+
81+
# Transform the schema
82+
transformer = TestTransformer(original_schema)
83+
result = transformer.walk()
84+
85+
# Verify the original schema was not modified
86+
assert original_schema == original_schema_copy
87+
88+
# Verify the result is correct
89+
assert result == original_schema_copy

uv.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)