Skip to content

Commit 1b576dd

Browse files
authored
Improve support for Gemini 3 Pro Preview (#3464)
1 parent 9301c84 commit 1b576dd

File tree

11 files changed

+776
-42
lines changed

11 files changed

+776
-42
lines changed

docs/models/google.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,22 +214,22 @@ from pydantic_ai.models.google import GoogleModel, GoogleModelSettings
214214
settings = GoogleModelSettings(
215215
temperature=0.2,
216216
max_tokens=1024,
217-
google_thinking_config={'thinking_budget': 2048},
217+
google_thinking_config={'thinking_level': 'low'},
218218
google_safety_settings=[
219219
{
220220
'category': HarmCategory.HARM_CATEGORY_HATE_SPEECH,
221221
'threshold': HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
222222
}
223223
]
224224
)
225-
model = GoogleModel('gemini-2.5-flash')
225+
model = GoogleModel('gemini-2.5-pro')
226226
agent = Agent(model, model_settings=settings)
227227
...
228228
```
229229

230230
### Disable thinking
231231

232-
You can disable thinking by setting the `thinking_budget` to `0` on the `google_thinking_config`:
232+
On models older than Gemini 2.5 Pro, you can disable thinking by setting the `thinking_budget` to `0` on the `google_thinking_config`:
233233

234234
```python
235235
from pydantic_ai import Agent

pydantic_ai_slim/pydantic_ai/models/__init__.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,24 +145,28 @@
145145
'cohere:command-r7b-12-2024',
146146
'deepseek:deepseek-chat',
147147
'deepseek:deepseek-reasoner',
148+
'google-gla:gemini-flash-latest',
149+
'google-gla:gemini-flash-lite-latest',
148150
'google-gla:gemini-2.0-flash',
149151
'google-gla:gemini-2.0-flash-lite',
150152
'google-gla:gemini-2.5-flash',
153+
'google-gla:gemini-2.5-flash-preview-09-2025',
154+
'google-gla:gemini-2.5-flash-image',
151155
'google-gla:gemini-2.5-flash-lite',
152156
'google-gla:gemini-2.5-flash-lite-preview-09-2025',
153-
'google-gla:gemini-2.5-flash-preview-09-2025',
154157
'google-gla:gemini-2.5-pro',
155-
'google-gla:gemini-flash-latest',
156-
'google-gla:gemini-flash-lite-latest',
158+
'google-gla:gemini-3-pro-preview',
159+
'google-vertex:gemini-flash-latest',
160+
'google-vertex:gemini-flash-lite-latest',
157161
'google-vertex:gemini-2.0-flash',
158162
'google-vertex:gemini-2.0-flash-lite',
159163
'google-vertex:gemini-2.5-flash',
164+
'google-vertex:gemini-2.5-flash-preview-09-2025',
165+
'google-vertex:gemini-2.5-flash-image',
160166
'google-vertex:gemini-2.5-flash-lite',
161167
'google-vertex:gemini-2.5-flash-lite-preview-09-2025',
162-
'google-vertex:gemini-2.5-flash-preview-09-2025',
163168
'google-vertex:gemini-2.5-pro',
164-
'google-vertex:gemini-flash-latest',
165-
'google-vertex:gemini-flash-lite-latest',
169+
'google-vertex:gemini-3-pro-preview',
166170
'grok:grok-2-image-1212',
167171
'grok:grok-2-vision-1212',
168172
'grok:grok-3',

pydantic_ai_slim/pydantic_ai/models/google.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
VideoUrl,
3939
)
4040
from ..profiles import ModelProfileSpec
41+
from ..profiles.google import GoogleModelProfile
4142
from ..providers import Provider, infer_provider
4243
from ..settings import ModelSettings
4344
from ..tools import ToolDefinition
@@ -93,15 +94,17 @@
9394
) from _import_error
9495

9596
LatestGoogleModelNames = Literal[
97+
'gemini-flash-latest',
98+
'gemini-flash-lite-latest',
9699
'gemini-2.0-flash',
97100
'gemini-2.0-flash-lite',
98101
'gemini-2.5-flash',
99102
'gemini-2.5-flash-preview-09-2025',
100-
'gemini-flash-latest',
103+
'gemini-2.5-flash-image',
101104
'gemini-2.5-flash-lite',
102105
'gemini-2.5-flash-lite-preview-09-2025',
103-
'gemini-flash-lite-latest',
104106
'gemini-2.5-pro',
107+
'gemini-3-pro-preview',
105108
]
106109
"""Latest Gemini models."""
107110

@@ -228,12 +231,17 @@ def system(self) -> str:
228231
def prepare_request(
229232
self, model_settings: ModelSettings | None, model_request_parameters: ModelRequestParameters
230233
) -> tuple[ModelSettings | None, ModelRequestParameters]:
234+
supports_native_output_with_builtin_tools = GoogleModelProfile.from_profile(
235+
self.profile
236+
).google_supports_native_output_with_builtin_tools
231237
if model_request_parameters.builtin_tools and model_request_parameters.output_tools:
232238
if model_request_parameters.output_mode == 'auto':
233-
model_request_parameters = replace(model_request_parameters, output_mode='prompted')
239+
output_mode = 'native' if supports_native_output_with_builtin_tools else 'prompted'
240+
model_request_parameters = replace(model_request_parameters, output_mode=output_mode)
234241
else:
242+
output_mode = 'NativeOutput' if supports_native_output_with_builtin_tools else 'PromptedOutput'
235243
raise UserError(
236-
'Google does not support output tools and built-in tools at the same time. Use `output_type=PromptedOutput(...)` instead.'
244+
f'Google does not support output tools and built-in tools at the same time. Use `output_type={output_mode}(...)` instead.'
237245
)
238246
return super().prepare_request(model_settings, model_request_parameters)
239247

@@ -418,9 +426,9 @@ async def _build_content_and_config(
418426
response_mime_type = None
419427
response_schema = None
420428
if model_request_parameters.output_mode == 'native':
421-
if tools:
429+
if model_request_parameters.function_tools:
422430
raise UserError(
423-
'Google does not support `NativeOutput` and tools at the same time. Use `output_type=ToolOutput(...)` instead.'
431+
'Google does not support `NativeOutput` and function tools at the same time. Use `output_type=ToolOutput(...)` instead.'
424432
)
425433
response_mime_type = 'application/json'
426434
output_object = model_request_parameters.output_object
@@ -685,23 +693,25 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
685693

686694
for part in parts:
687695
if part.thought_signature:
696+
# Per https://ai.google.dev/gemini-api/docs/function-calling?example=meeting#thought-signatures:
697+
# - Always send the thought_signature back to the model inside its original Part.
698+
# - Don't merge a Part containing a signature with one that does not. This breaks the positional context of the thought.
699+
# - Don't combine two Parts that both contain signatures, as the signature strings cannot be merged.
700+
688701
signature = base64.b64encode(part.thought_signature).decode('utf-8')
702+
# Attach signature to most recent thinking part, if there was one
689703
yield self._parts_manager.handle_thinking_delta(
690-
vendor_part_id='thinking',
704+
vendor_part_id=None,
691705
signature=signature,
692706
provider_name=self.provider_name,
693707
)
694708

695709
if part.text is not None:
696710
if len(part.text) > 0:
697711
if part.thought:
698-
yield self._parts_manager.handle_thinking_delta(
699-
vendor_part_id='thinking', content=part.text
700-
)
712+
yield self._parts_manager.handle_thinking_delta(vendor_part_id=None, content=part.text)
701713
else:
702-
maybe_event = self._parts_manager.handle_text_delta(
703-
vendor_part_id='content', content=part.text
704-
)
714+
maybe_event = self._parts_manager.handle_text_delta(vendor_part_id=None, content=part.text)
705715
if maybe_event is not None: # pragma: no branch
706716
yield maybe_event
707717
elif part.function_call:
@@ -760,6 +770,7 @@ def timestamp(self) -> datetime:
760770
def _content_model_response(m: ModelResponse, provider_name: str) -> ContentDict: # noqa: C901
761771
parts: list[PartDict] = []
762772
thought_signature: bytes | None = None
773+
function_call_requires_signature: bool = True
763774
for item in m.parts:
764775
part: PartDict = {}
765776
if thought_signature:
@@ -769,6 +780,15 @@ def _content_model_response(m: ModelResponse, provider_name: str) -> ContentDict
769780
if isinstance(item, ToolCallPart):
770781
function_call = FunctionCallDict(name=item.tool_name, args=item.args_as_dict(), id=item.tool_call_id)
771782
part['function_call'] = function_call
783+
if function_call_requires_signature and not part.get('thought_signature'):
784+
# Per https://ai.google.dev/gemini-api/docs/gemini-3?thinking=high#migrating_from_other_models:
785+
# > If you are transferring a conversation trace from another model (e.g., Gemini 2.5) or injecting
786+
# > a custom function call that was not generated by Gemini 3, you will not have a valid signature.
787+
# > To bypass strict validation in these specific scenarios, populate the field with this specific
788+
# > dummy string: "thoughtSignature": "context_engineering_is_the_way_to_go"
789+
part['thought_signature'] = b'context_engineering_is_the_way_to_go'
790+
# Only the first function call requires a signature
791+
function_call_requires_signature = False
772792
elif isinstance(item, TextPart):
773793
part['text'] = item.content
774794
elif isinstance(item, ThinkingPart):
@@ -881,7 +901,7 @@ def _function_declaration_from_tool(tool: ToolDefinition) -> FunctionDeclaration
881901
f = FunctionDeclarationDict(
882902
name=tool.name,
883903
description=tool.description or '',
884-
parameters=json_schema, # type: ignore
904+
parameters_json_schema=json_schema,
885905
)
886906
return f
887907

pydantic_ai_slim/pydantic_ai/profiles/google.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
from __future__ import annotations as _annotations
22

3+
from dataclasses import dataclass
4+
35
from .._json_schema import JsonSchema, JsonSchemaTransformer
46
from . import ModelProfile
57

68

9+
@dataclass(kw_only=True)
10+
class GoogleModelProfile(ModelProfile):
11+
"""Profile for models used with `GoogleModel`.
12+
13+
ALL FIELDS MUST BE `google_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS.
14+
"""
15+
16+
google_supports_native_output_with_builtin_tools: bool = False
17+
"""Whether the model supports native output with builtin tools.
18+
See https://ai.google.dev/gemini-api/docs/structured-output?example=recipe#structured_outputs_with_tools"""
19+
20+
721
def google_model_profile(model_name: str) -> ModelProfile | None:
822
"""Get the model profile for a Google model."""
923
is_image_model = 'image' in model_name
10-
return ModelProfile(
24+
is_3_or_newer = 'gemini-3' in model_name
25+
return GoogleModelProfile(
1126
json_schema_transformer=GoogleJsonSchemaTransformer,
1227
supports_image_output=is_image_model,
1328
supports_json_schema_output=not is_image_model,
1429
supports_json_object_output=not is_image_model,
1530
supports_tools=not is_image_model,
31+
google_supports_native_output_with_builtin_tools=is_3_or_newer,
1632
)
1733

1834

pydantic_ai_slim/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ logfire = ["logfire[httpx]>=3.14.1"]
7070
openai = ["openai>=1.107.2"]
7171
cohere = ["cohere>=5.18.0; platform_system != 'Emscripten'"]
7272
vertexai = ["google-auth>=2.36.0", "requests>=2.32.2"]
73-
google = ["google-genai>=1.50.1"]
73+
google = ["google-genai>=1.51.0"]
7474
anthropic = ["anthropic>=0.70.0"]
7575
groq = ["groq>=0.25.0"]
7676
mistral = ["mistralai>=1.9.10"]

tests/models/cassettes/test_google/test_google_builtin_tools_with_other_tools.yaml

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ interactions:
88
connection:
99
- keep-alive
1010
content-length:
11-
- '526'
11+
- '560'
1212
content-type:
1313
- application/json
1414
host:
@@ -19,10 +19,13 @@ interactions:
1919
- parts:
2020
- text: What is the largest city in Mexico?
2121
role: user
22-
generationConfig: {}
22+
generationConfig:
23+
responseModalities:
24+
- TEXT
2325
systemInstruction:
2426
parts:
25-
- text: |-
27+
- text: |2
28+
2629
Always respond with a JSON object that's compatible with this schema:
2730
2831
{"properties": {"city": {"type": "string"}, "country": {"type": "string"}}, "required": ["city", "country"], "title": "CityLocation", "type": "object"}
@@ -41,7 +44,7 @@ interactions:
4144
content-type:
4245
- application/json; charset=UTF-8
4346
server-timing:
44-
- gfet4t7; dur=780
47+
- gfet4t7; dur=873
4548
transfer-encoding:
4649
- chunked
4750
vary:
@@ -58,15 +61,15 @@ interactions:
5861
groundingMetadata: {}
5962
index: 0
6063
modelVersion: gemini-2.5-flash
61-
responseId: 6Xq3aPnXNtqKqtsP8ZuDyAc
64+
responseId: 0f4caf7iM-mgz7IPtLeryAc
6265
usageMetadata:
6366
candidatesTokenCount: 13
64-
promptTokenCount: 83
67+
promptTokenCount: 85
6568
promptTokensDetails:
6669
- modality: TEXT
67-
tokenCount: 83
68-
thoughtsTokenCount: 33
69-
totalTokenCount: 129
70+
tokenCount: 85
71+
thoughtsTokenCount: 32
72+
totalTokenCount: 130
7073
status:
7174
code: 200
7275
message: OK

0 commit comments

Comments
 (0)