Route Vertex-vs-Gemini API branches in GoogleModel by client transport instead of provider name (#7280)

Signed-off-by: James Gore <[email protected]>
Co-authored-by: David SF <[email protected]>
This commit is contained in:
James Gore
2026-08-29 23:03:28 -05:00
committed by GitHub
co-authored by David SF
parent 720bf0e105
commit 22b3d6a4dc
11 changed files with 467 additions and 75 deletions
+33 -12
View File
@@ -519,13 +519,34 @@ class GoogleModel(Model[Client]):
"""The model provider."""
return self._provider.name
@property
def _is_google_cloud(self) -> bool:
"""Whether requests go to Google Cloud (Vertex) rather than the Gemini Developer API.
Derived from the client's transport rather than the provider name, because either provider
accepts a pre-built `client=` and stores it as-is, so the two can disagree in both
directions: a Vertex-backed client in `GoogleProvider` keeps `name` `'google'`, and a
Gemini-API client in `GoogleCloudProvider` keeps `name` `'google-cloud'`. The name cannot be
reconciled against the client instead — it is persisted in `ModelMessage.provider_name` and
checked when replaying history, so changing it breaks replay of anything captured under the
old name. `AnthropicModel` reads its transport off the client for the same reason.
"""
return bool(self.client.vertexai)
@property
def _matching_provider_names(self) -> frozenset[str]:
if self.system in _GOOGLE_CLOUD_PROVIDER_NAMES:
return _GOOGLE_CLOUD_PROVIDER_NAMES
if self.system in _GEMINI_API_PROVIDER_NAMES:
return _GEMINI_API_PROVIDER_NAMES
return frozenset({self.system}) # pragma: no cover
# Keyed on the transport, because the pre-v2 names this set exists to accept were themselves
# transport-derived: `GoogleProvider.name` used to return `google-vertex`/`google-gla` off the
# client. The union with `self.system` is load-bearing, not cosmetic — a Vertex-backed
# `GoogleProvider` stamps `provider_name='google'`, which the Google Cloud set does not contain,
# so without it this model would reject its own history. Both consumers depend on this set:
# `_content_model_response` replays thinking signatures and native tool parts through it, and
# `_validate_uploaded_file` checks `UploadedFile.provider_name` against it.
if self.system in _GOOGLE_CLOUD_PROVIDER_NAMES or self.system in _GEMINI_API_PROVIDER_NAMES:
names = _GOOGLE_CLOUD_PROVIDER_NAMES if self._is_google_cloud else _GEMINI_API_PROVIDER_NAMES
return names | {self.system}
# A provider outside both families has no pre-v2 alias to accept, so it only matches itself.
return frozenset({self.system})
@cached_property
def profile(self) -> GoogleModelProfile:
@@ -597,7 +618,7 @@ class GoogleModel(Model[Client]):
config = CountTokensConfigDict(
http_options=generation_config.get('http_options'),
)
if self._provider.name not in _GEMINI_API_PROVIDER_NAMES:
if self._is_google_cloud:
# The fields are not supported by the Gemini API per https://github.com/googleapis/python-genai/blob/7e4ec284dc6e521949626f3ed54028163ef9121d/google/genai/models.py#L1195-L1214
# The Vertex `countTokens` endpoint accepts native/server-side tools (e.g. Google Search grounding), so we
# forward `tools` as-is to mirror the real request for an accurate count. This intentionally differs from
@@ -673,7 +694,7 @@ class GoogleModel(Model[Client]):
)
image_config['image_size'] = tool.size
if self.system in _GOOGLE_CLOUD_PROVIDER_NAMES:
if self._is_google_cloud:
if tool.output_format is not None:
if tool.output_format not in _GOOGLE_IMAGE_OUTPUT_FORMATS:
raise UserError(
@@ -809,7 +830,7 @@ class GoogleModel(Model[Client]):
if (
emits_tool_call_invocations
and self.profile.get('google_supports_server_side_tool_invocations', False)
and self.system not in _GOOGLE_CLOUD_PROVIDER_NAMES
and not self._is_google_cloud
):
tool_config['include_server_side_tool_invocations'] = True
@@ -941,7 +962,7 @@ class GoogleModel(Model[Client]):
headers.update(extra_headers)
gla_service_tier: _GlaServiceTier | None = None
if self.system in _GOOGLE_CLOUD_PROVIDER_NAMES:
if self._is_google_cloud:
headers.update(_google_cloud_service_tier_headers(_resolve_google_cloud_service_tier(model_settings)))
else:
gla_service_tier = _resolve_gla_service_tier(model_settings)
@@ -1231,7 +1252,7 @@ class GoogleModel(Model[Client]):
f'UploadedFile with `provider_name={file.provider_name!r}` cannot be used with GoogleModel. '
f'Expected `provider_name` to be one of {sorted(self._matching_provider_names)!r}.'
)
if self.system in _GOOGLE_CLOUD_PROVIDER_NAMES:
if self._is_google_cloud:
if not file.file_id.startswith('gs://'):
raise UserError(
f'UploadedFile for GoogleModel (Google Cloud) must use a GCS URI (gs://bucket/path), got: {file.file_id}'
@@ -1256,12 +1277,12 @@ class GoogleModel(Model[Client]):
file_uri, mime_type = self._validate_uploaded_file(file)
return ('file', file_uri, mime_type)
elif isinstance(file, VideoUrl) and (
file.is_youtube or (file.url.startswith('gs://') and self.system in _GOOGLE_CLOUD_PROVIDER_NAMES)
file.is_youtube or (file.url.startswith('gs://') and self._is_google_cloud)
):
return ('file', file.url, file.media_type)
elif isinstance(file, FileUrl):
if file.force_download or (
self.system in _GEMINI_API_PROVIDER_NAMES
not self._is_google_cloud
and not file.url.startswith(r'https://generativelanguage.googleapis.com/v1beta/files')
):
downloaded_item = await download_item(file, data_format='bytes')
@@ -131,7 +131,10 @@ class GoogleProvider(BaseGoogleProvider):
Args:
api_key: The [API key](https://ai.google.dev/gemini-api/docs/api-key) to
use for authentication. It can also be set via the `GOOGLE_API_KEY` environment variable.
client: A pre-initialized client to use.
client: A pre-initialized client to use. Stored as-is, so a client built for Google Cloud
(`Client(vertexai=True, ...)`, or its current spelling `Client(enterprise=True, ...)`)
is used on that transport even though `name` stays `'google'`. `GoogleModel` reads the
transport off the client rather than the provider name, so it routes correctly either way.
http_client: An existing `httpx2.AsyncClient` or legacy `httpx.AsyncClient` to use for making HTTP requests.
base_url: The base URL for the Gemini API.
retry_options: HTTP retry options for transient errors (429, 5xx, etc.).
@@ -60,7 +60,10 @@ class GoogleCloudProvider(BaseGoogleProvider):
`global` — see the
[Vertex AI locations docs](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#available-regions).
Can be obtained from the `GOOGLE_CLOUD_LOCATION` environment variable.
client: A pre-initialized client to use.
client: A pre-initialized client to use. Stored as-is without forcing `vertexai=True`, so a
client built for the Gemini Developer API is used on that transport even though `name`
stays `'google-cloud'`. `GoogleModel` reads the transport off the client rather than the
provider name, so it routes correctly either way.
http_client: An existing `httpx2.AsyncClient` or legacy `httpx.AsyncClient` to use for making HTTP requests.
base_url: The base URL for the Google Cloud API.
retry_options: HTTP retry options for transient errors (429, 5xx, etc.).
+37
View File
@@ -9,12 +9,22 @@ import httpx2
import pytest
from pydantic import TypeAdapter
from ..conftest import try_import
if TYPE_CHECKING:
from vcr.cassette import Cassette
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.providers.google_cloud import GoogleCloudProvider
from tests.cassette_utils import CassetteContext
with try_import() as google_imports:
from google.genai import Client
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.providers.google_cloud import GoogleCloudProvider
# `validate_json` parses through pydantic-core rather than the stdlib, and types the result without a cast.
_REQUEST_BODY_ADAPTER = TypeAdapter(dict[str, Any])
@@ -174,3 +184,30 @@ def cassette_ctx(request: pytest.FixtureRequest, vcr: Cassette) -> CassetteConte
test_module=test_module, # pyright: ignore[reportUnknownArgumentType]
test_dir=test_dir,
)
@pytest.fixture
def vertex_client_google_provider() -> GoogleProvider:
"""A Vertex-backed `genai.Client` wrapped in `GoogleProvider`, the construction from #6792.
`system` stays `'google'` while the transport is Google Cloud (Vertex), so transport
(not the provider name) must drive Vertex-vs-Gemini-API behavior.
"""
if not google_imports(): # pragma: lax no cover
pytest.skip('google is not installed')
return GoogleProvider(client=Client(vertexai=True, project='test-project', location='us-central1'))
@pytest.fixture
def gla_client_google_cloud_provider() -> GoogleCloudProvider:
"""A Gemini-Developer-API `genai.Client` wrapped in `GoogleCloudProvider`, the mirror of #6792.
`system` stays `'google-cloud'` while the transport is the Gemini Developer API. `__init__`
short-circuits on `client=` before it would force `vertexai=True`, so the two disagree in this
direction too and every transport branch has to follow the client rather than the name.
"""
if not google_imports(): # pragma: lax no cover
pytest.skip('google is not installed')
return GoogleCloudProvider(client=Client(vertexai=False, api_key='mock-api-key'))
+23 -22
View File
@@ -51,6 +51,13 @@ def mapping_model() -> GoogleModel:
return GoogleModel('gemini-1.5-flash', provider=GoogleProvider(api_key='test-key'))
@pytest.fixture
def vertex_mapping_model(vertex_client_google_provider: GoogleProvider) -> GoogleModel:
"""Like `mapping_model`, but Google Cloud (Vertex) — built the way #6792 reports,
so transport (not the provider name) drives the mapping."""
return GoogleModel('gemini-1.5-flash', provider=vertex_client_google_provider)
# =============================================================================
# Per-Part `media_resolution` forwarding via `vendor_metadata`
# =============================================================================
@@ -61,8 +68,8 @@ class MediaResolutionCase:
id: str
content: BinaryContent | ImageUrl | DocumentUrl | UploadedFile
expected: dict[str, object]
system: str | None = None
"""When set, patch `GoogleModel.system` (e.g. 'google-cloud' for gs:// URIs)."""
google_cloud: bool = False
"""When True, run against the Vertex-backed model (needed for gs:// URIs)."""
MEDIA_RESOLUTION_CASES = [
@@ -117,7 +124,7 @@ MEDIA_RESOLUTION_CASES = [
'file_data': {'file_uri': 'gs://bucket/image.png', 'mime_type': 'image/png'},
'media_resolution': {'level': 'MEDIA_RESOLUTION_ULTRA_HIGH'},
},
system='google-cloud',
google_cloud=True,
),
MediaResolutionCase(
id='document_url_media_resolution',
@@ -129,7 +136,7 @@ MEDIA_RESOLUTION_CASES = [
'file_data': {'file_uri': 'gs://bucket/report.pdf', 'mime_type': 'application/pdf'},
'media_resolution': {'level': 'MEDIA_RESOLUTION_HIGH'},
},
system='google-cloud',
google_cloud=True,
),
MediaResolutionCase(
id='uploaded_file_media_resolution_and_video_metadata',
@@ -157,18 +164,17 @@ MEDIA_RESOLUTION_CASES = [
@pytest.mark.parametrize('case', [pytest.param(c, id=c.id) for c in MEDIA_RESOLUTION_CASES])
async def test_media_resolution_forwarding(
case: MediaResolutionCase, mapping_model: GoogleModel, mocker: MockerFixture
case: MediaResolutionCase, mapping_model: GoogleModel, vertex_mapping_model: GoogleModel
):
"""`vendor_metadata['media_resolution']` is lifted to the per-Part `media_resolution`
field for every file type, remaining keys still route to `video_metadata`, and the
user's `vendor_metadata` dict is never mutated (the mapper works on a copy).
"""
if case.system is not None:
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value=case.system)
model = vertex_mapping_model if case.google_cloud else mapping_model
original_vendor_metadata = deepcopy(case.content.vendor_metadata)
content = await mapping_model._map_user_prompt(UserPromptPart(content=[case.content])) # pyright: ignore[reportPrivateUsage]
content = await model._map_user_prompt(UserPromptPart(content=[case.content])) # pyright: ignore[reportPrivateUsage]
assert content == [case.expected]
assert case.content.vendor_metadata == original_vendor_metadata
@@ -218,13 +224,12 @@ async def test_uploaded_file_invalid_file_id(allow_model_requests: None, mapping
await agent.run(['Analyze this file', UploadedFile(file_id='file-abc123', provider_name='google')])
async def test_uploaded_file_vertex_requires_gs_uri(mapping_model: GoogleModel, mocker: MockerFixture):
async def test_uploaded_file_vertex_requires_gs_uri(vertex_mapping_model: GoogleModel):
"""Vertex `UploadedFile` must use a gs:// URI (not Files API https URLs)."""
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
https_files_api = 'https://generativelanguage.googleapis.com/v1beta/files/abc123'
with pytest.raises(UserError, match='must use a GCS URI'):
await mapping_model._map_user_prompt( # pyright: ignore[reportPrivateUsage]
await vertex_mapping_model._map_user_prompt( # pyright: ignore[reportPrivateUsage]
UserPromptPart(
content=[UploadedFile(file_id=https_files_api, provider_name='google-cloud')],
)
@@ -273,19 +278,18 @@ async def test_youtube_video_url_without_vendor_metadata(mapping_model: GoogleMo
# =============================================================================
async def test_gcs_video_url_with_vendor_metadata_on_google_cloud(mapping_model: GoogleModel, mocker: MockerFixture):
async def test_gcs_video_url_with_vendor_metadata_on_google_cloud(vertex_mapping_model: GoogleModel):
"""GCS URIs use file_uri with video_metadata on google-cloud (Vertex).
This is the main fix - GCS URIs were previously falling through to FileUrl
handling which doesn't pass vendor_metadata as video_metadata.
"""
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
video = VideoUrl(
url='gs://bucket/video.mp4',
vendor_metadata={'start_offset': '300s', 'end_offset': '330s'},
)
content = await mapping_model._map_user_prompt(UserPromptPart(content=[video])) # pyright: ignore[reportPrivateUsage]
content = await vertex_mapping_model._map_user_prompt(UserPromptPart(content=[video])) # pyright: ignore[reportPrivateUsage]
assert len(content) == 1
assert content[0] == {
@@ -341,15 +345,14 @@ async def test_http_video_url_downloads_on_google(mapping_model: GoogleModel, mo
]
async def test_http_video_url_uses_file_uri_on_google_cloud(mapping_model: GoogleModel, mocker: MockerFixture):
async def test_http_video_url_uses_file_uri_on_google_cloud(vertex_mapping_model: GoogleModel):
"""HTTP VideoUrls use file_uri directly on google-cloud (Vertex) with video_metadata."""
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
video = VideoUrl(
url='https://example.com/video.mp4',
vendor_metadata={'start_offset': '10s', 'end_offset': '20s'},
)
content = await mapping_model._map_user_prompt(UserPromptPart(content=[video])) # pyright: ignore[reportPrivateUsage]
content = await vertex_mapping_model._map_user_prompt(UserPromptPart(content=[video])) # pyright: ignore[reportPrivateUsage]
assert len(content) == 1
assert content[0] == {
@@ -388,20 +391,18 @@ async def test_http_video_url_uses_file_uri_on_google_cloud(mapping_model: Googl
],
)
async def test_file_url_in_tool_return_on_vertex(
mocker: MockerFixture, file_url: VideoUrl | ImageUrl, expected: dict[str, object]
vertex_client_google_provider: GoogleProvider, file_url: VideoUrl | ImageUrl, expected: dict[str, object]
):
"""Test file URLs use file_data (not download) in tool returns on Vertex."""
model = GoogleModel('gemini-3-flash-preview', provider=GoogleProvider(api_key='test-key'))
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
model = GoogleModel('gemini-3-flash-preview', provider=vertex_client_google_provider)
result = await model._map_file_to_function_response_part(file_url) # pyright: ignore[reportPrivateUsage]
assert result == expected
async def test_map_user_prompt_with_text_content(mapping_model: GoogleModel, mocker: MockerFixture):
async def test_map_user_prompt_with_text_content(mapping_model: GoogleModel):
"""Test that _map_user_prompt correctly handles a mix of text content and str."""
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google')
user_prompt_part = UserPromptPart(
content=['Hi', TextContent(content='This is some context', metadata={'source': 'user'})]
+5 -4
View File
@@ -4,7 +4,7 @@ On supported models (Gemini 2.5+), `VALIDATED` is the default — it enforces th
schema rewrites, so it's a safe silent improvement — and a caller opts a tool out with `strict=False`.
Test organization:
1. Mode resolution (unit, against a `MagicMock` client)
1. Mode resolution (unit, against an offline `genai.Client`)
2. `strict` resolution via `GoogleJsonSchemaTransformer`
3. End-to-end wire contract (live recording)
"""
@@ -14,7 +14,6 @@ from __future__ import annotations as _annotations
import json
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
import httpx2
import pytest
@@ -28,6 +27,8 @@ from ..._inline_snapshot import snapshot
from ...conftest import try_import
with try_import() as imports_successful:
from google.genai import Client
from pydantic_ai.models.google import GoogleModel
from pydantic_ai.providers.google import GoogleProvider
@@ -130,7 +131,7 @@ def test_google_strict_tools_upgrade_auto_to_validated(case: dict[str, Any]):
Asserted on the request shape directly rather than via VCR: a cassette replay can't catch the mode we send,
since it replays a recorded response without re-validating the request against the API.
"""
m = GoogleModel(case['model'], provider=GoogleProvider(client=MagicMock()))
m = GoogleModel(case['model'], provider=GoogleProvider(client=Client(vertexai=False, api_key='mock-api-key')))
params = ModelRequestParameters(
function_tools=case['function_tools'],
output_tools=case.get('output_tools', []),
@@ -152,7 +153,7 @@ def test_google_strict_resolution_via_transformer():
"""`GoogleJsonSchemaTransformer` treats every schema as `VALIDATED`-compatible (the mode needs no schema
rewrites): `strict=None` resolves to `True` (VALIDATED-eligible), and an explicit `strict=False` is
preserved as the per-tool opt-out."""
m = GoogleModel('gemini-2.5-flash', provider=GoogleProvider(client=MagicMock()))
m = GoogleModel('gemini-2.5-flash', provider=GoogleProvider(client=Client(vertexai=False, api_key='mock-api-key')))
# `strict` left as `None` resolves to `True`: default-on, VALIDATED-eligible.
params = m.customize_request_parameters(
+255
View File
@@ -0,0 +1,255 @@
"""Vertex-vs-Gemini-API routing, driven by the client's transport rather than the provider name.
Either provider accepts a pre-built `client=` and stores it as-is, so `name` and transport can
disagree in both directions: a Google Cloud client in `GoogleProvider` keeps `name` `'google'`
(#6792), and a Gemini Developer API client in `GoogleCloudProvider` keeps `name` `'google-cloud'`.
`GoogleModel` reads the transport off the client, so both route by where the bytes actually go.
None of these are VCR tests. The routed fields and headers are decided before the request is built,
the cassette serializer does not persist provider request headers, and the default matchers do not
inspect the body — so a recording would replay green through a regression in either direction.
"""
from __future__ import annotations as _annotations
from dataclasses import dataclass
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
from pydantic_ai import UploadedFile
from pydantic_ai.exceptions import UserError
from pydantic_ai.messages import ModelRequest, UploadedFileProviderName, UserPromptPart
from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.native_tools import WebSearchTool
from pydantic_ai.tools import ToolDefinition
from ...conftest import try_import
with try_import() as imports_successful:
from google.genai import Client
from pydantic_ai.models.google import GoogleModel, GoogleModelSettings
from pydantic_ai.providers import Provider
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.providers.google_cloud import GoogleCloudProvider
pytestmark = [
pytest.mark.anyio,
pytest.mark.skipif(not imports_successful(), reason='google-genai not installed'),
]
async def test_google_cloud_client_in_google_provider_uses_cloud_service_tier_headers(
allow_model_requests: None, vertex_client_google_provider: GoogleProvider
) -> None:
"""A Google Cloud client wrapped in `GoogleProvider` gets Cloud service-tier handling (#6792)."""
m = GoogleModel('gemini-2.5-flash', provider=vertex_client_google_provider)
assert m.system == 'google'
_, config = await m._build_content_and_config( # pyright: ignore[reportPrivateUsage]
messages=[ModelRequest(parts=[UserPromptPart(content='Hello')])],
model_settings=GoogleModelSettings(google_cloud_service_tier='pt_only'),
model_request_parameters=ModelRequestParameters(),
)
http_options = config.get('http_options') # pyright: ignore[reportUnknownMemberType]
assert http_options is not None
headers = http_options.get('headers')
assert headers is not None
assert headers['X-Vertex-AI-LLM-Request-Type'] == 'dedicated'
async def test_google_cloud_service_tier_is_dropped_on_a_gemini_api_transport(
allow_model_requests: None, gla_client_google_cloud_provider: GoogleCloudProvider
) -> None:
"""`google_cloud_service_tier` has no Gemini API equivalent, so it is dropped on that transport.
Newly reachable: a Gemini API client in `GoogleCloudProvider` keeps `system == 'google-cloud'`,
which used to send Vertex routing headers. Pins that the setting is ignored silently rather than
raising, and that it does not leak into the Gemini API's own `service_tier` config field.
"""
m = GoogleModel('gemini-2.5-flash', provider=gla_client_google_cloud_provider)
assert m.system == 'google-cloud'
_, config = await m._build_content_and_config( # pyright: ignore[reportPrivateUsage]
messages=[ModelRequest(parts=[UserPromptPart(content='Hello')])],
model_settings=GoogleModelSettings(google_cloud_service_tier='pt_only'),
model_request_parameters=ModelRequestParameters(),
)
http_options = config.get('http_options') # pyright: ignore[reportUnknownMemberType]
assert http_options is not None
headers = http_options.get('headers')
assert headers is not None
assert not any(header.startswith('X-Vertex-AI') for header in headers)
assert 'service_tier' not in config
def test_gemini_api_sets_include_server_side_tool_invocations_on_a_google_cloud_provider(
gla_client_google_cloud_provider: GoogleCloudProvider,
) -> None:
"""The Gemini Developer API flag is set even when `system` is `'google-cloud'`.
Newly reachable: a Gemini API client in `GoogleCloudProvider` used to skip the flag because
`system == 'google-cloud'`. Pins that transport, not name, drives the SET. Twin of
`test_google_gemini_api_sets_include_server_side_tool_invocations` on the agreeing construction.
Not a VCR test: the field is decided before the request is sent, and cassette matchers do not
inspect the body.
"""
model = GoogleModel('gemini-3-pro-preview', provider=gla_client_google_cloud_provider)
assert model.system == 'google-cloud'
params = ModelRequestParameters(function_tools=[ToolDefinition(name='search')], native_tools=[WebSearchTool()])
_tools, tool_config, _image_config = model._get_tool_config(params, GoogleModelSettings()) # pyright: ignore[reportPrivateUsage]
assert tool_config is not None
assert tool_config.get('include_server_side_tool_invocations') is True
async def test_count_tokens_forwards_tools_on_a_google_cloud_transport(
allow_model_requests: None,
vertex_client_google_provider: GoogleProvider,
mocker: MockerFixture,
) -> None:
"""Vertex `countTokens` extras follow the client, not `system == 'google'`.
Twin of `test_google_vertexai_count_tokens_forwards_native_tools` on the agreeing construction.
Not a VCR test: tools-forwarding is decided before the request is sent.
"""
m = GoogleModel('gemini-2.5-flash', provider=vertex_client_google_provider)
assert m.system == 'google'
fake = mocker.AsyncMock(return_value=SimpleNamespace(total_tokens=1))
mocker.patch.object(m.client.aio.models, 'count_tokens', fake)
await m.count_tokens(
[ModelRequest(parts=[UserPromptPart(content='Hello')])],
None,
ModelRequestParameters(native_tools=[WebSearchTool()]),
)
config = fake.call_args.kwargs['config']
assert config.get('tools') == [{'google_search': {}}]
async def test_count_tokens_omits_tools_on_a_gemini_api_transport(
allow_model_requests: None,
gla_client_google_cloud_provider: GoogleCloudProvider,
mocker: MockerFixture,
) -> None:
"""Gemini API `countTokens` does not take Vertex extras, even when `system` is `'google-cloud'`."""
m = GoogleModel('gemini-2.5-flash', provider=gla_client_google_cloud_provider)
assert m.system == 'google-cloud'
fake = mocker.AsyncMock(return_value=SimpleNamespace(total_tokens=1))
mocker.patch.object(m.client.aio.models, 'count_tokens', fake)
await m.count_tokens(
[ModelRequest(parts=[UserPromptPart(content='Hello')])],
None,
ModelRequestParameters(native_tools=[WebSearchTool()]),
)
config = fake.call_args.kwargs['config']
assert config.get('tools') is None
GCS_URI = 'gs://bucket/doc.pdf'
FILES_API_URI = 'https://generativelanguage.googleapis.com/v1beta/files/abc'
@dataclass(frozen=True)
class UploadedFileCase:
"""One construction where the provider name and the client's transport disagree."""
id: str
provider_fixture: str
canonical_name: UploadedFileProviderName
"""The name this construction stamps on its own files — also the one used for the rejection."""
also_accepted: UploadedFileProviderName
valid_file_id: str
"""A file id the transport can actually serve."""
rejected_file_id: str
rejection_match: str
UPLOADED_FILE_CASES = [
UploadedFileCase(
id='google_cloud_client_in_google_provider',
provider_fixture='vertex_client_google_provider',
canonical_name='google-cloud',
also_accepted='google',
valid_file_id=GCS_URI,
rejected_file_id=FILES_API_URI,
rejection_match='must use a GCS URI',
),
UploadedFileCase(
id='gemini_api_client_in_google_cloud_provider',
provider_fixture='gla_client_google_cloud_provider',
canonical_name='google',
also_accepted='google-cloud',
valid_file_id=FILES_API_URI,
rejected_file_id=GCS_URI,
rejection_match='must use a file URI from the Google Files API',
),
]
@pytest.mark.parametrize('case', [pytest.param(c, id=c.id) for c in UPLOADED_FILE_CASES])
def test_uploaded_file_validation_follows_the_client_transport(
request: pytest.FixtureRequest, case: UploadedFileCase
) -> None:
"""`UploadedFile` validation follows the client's transport, not the provider name (#6792).
Both disagreeing constructions run through one test so the pair cannot drift: each accepts the
file id its transport can serve and rejects the other's, whichever way `name` points.
"""
m = GoogleModel('gemini-2.5-flash', provider=request.getfixturevalue(case.provider_fixture))
for provider_name in (case.canonical_name, case.also_accepted):
file = UploadedFile(file_id=case.valid_file_id, provider_name=provider_name, media_type='application/pdf')
assert m._validate_uploaded_file(file) == (case.valid_file_id, 'application/pdf') # pyright: ignore[reportPrivateUsage]
wrong_transport_file = UploadedFile(
file_id=case.rejected_file_id, provider_name=case.canonical_name, media_type='application/pdf'
)
with pytest.raises(UserError, match=case.rejection_match):
m._validate_uploaded_file(wrong_transport_file) # pyright: ignore[reportPrivateUsage]
@pytest.mark.parametrize('vertexai', [True, False])
def test_provider_outside_both_name_families_matches_only_itself(vertexai: bool) -> None:
"""A custom `Provider[Client]` keeps its own name as the only accepted one, on either transport.
`GoogleModel` accepts any `Provider[Client]`, so a third-party provider can carry a name in
neither Google family. It has no pre-v2 alias to accept, and inheriting a family's aliases would
replay another provider's thinking signatures and native tool parts. Its transport branches still
follow its client — that part is what the two directions above also rely on.
"""
class MyProxyProvider(Provider[Client]):
def __init__(self, client: Client) -> None:
self._client = client
@property
def name(self) -> str:
return 'my-google-proxy'
@property
def base_url(self) -> str:
return 'https://proxy.example.invalid'
@property
def client(self) -> Client:
return self._client
client = (
Client(vertexai=True, project='test-project', location='us-central1')
if vertexai
else Client(vertexai=False, api_key='mock-api-key')
)
m = GoogleModel('gemini-2.5-flash', provider=MyProxyProvider(client))
assert m.system == 'my-google-proxy'
assert m.base_url == 'https://proxy.example.invalid'
assert m._matching_provider_names == frozenset({'my-google-proxy'}) # pyright: ignore[reportPrivateUsage]
assert m._is_google_cloud is vertexai # pyright: ignore[reportPrivateUsage]
+17 -20
View File
@@ -3899,12 +3899,9 @@ async def test_google_image_generation_auto_size_raises_error(google_provider: G
model._get_native_tools(params) # pyright: ignore[reportPrivateUsage]
async def test_google_image_generation_tool_output_format(
mocker: MockerFixture, google_provider: GoogleProvider
) -> None:
async def test_google_image_generation_tool_output_format(vertex_client_google_provider: GoogleProvider) -> None:
"""Test that ImageGenerationTool.output_format is mapped to ImageConfigDict.output_mime_type on Vertex AI."""
model = GoogleModel('gemini-3-pro-image-preview', provider=google_provider)
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
model = GoogleModel('gemini-3-pro-image-preview', provider=vertex_client_google_provider)
params = ModelRequestParameters(native_tools=[ImageGenerationTool(output_format='png')])
tools, image_config = model._get_native_tools(params) # pyright: ignore[reportPrivateUsage]
@@ -3913,11 +3910,10 @@ async def test_google_image_generation_tool_output_format(
async def test_google_image_generation_tool_unsupported_format_raises_error(
mocker: MockerFixture, google_provider: GoogleProvider
vertex_client_google_provider: GoogleProvider,
) -> None:
"""Test that unsupported output_format values raise an error on Vertex AI."""
model = GoogleModel('gemini-3-pro-image-preview', provider=google_provider)
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
model = GoogleModel('gemini-3-pro-image-preview', provider=vertex_client_google_provider)
# 'gif' is not supported by Google
params = ModelRequestParameters(native_tools=[ImageGenerationTool(output_format='gif')]) # pyright: ignore[reportArgumentType]
@@ -3926,11 +3922,10 @@ async def test_google_image_generation_tool_unsupported_format_raises_error(
async def test_google_image_generation_tool_output_compression(
mocker: MockerFixture, google_provider: GoogleProvider
vertex_client_google_provider: GoogleProvider,
) -> None:
"""Test that ImageGenerationTool.output_compression is mapped to ImageConfigDict.output_compression_quality on Vertex AI."""
model = GoogleModel('gemini-3-pro-image-preview', provider=google_provider)
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
model = GoogleModel('gemini-3-pro-image-preview', provider=vertex_client_google_provider)
# Test explicit value
params = ModelRequestParameters(native_tools=[ImageGenerationTool(output_compression=85)])
@@ -3945,11 +3940,10 @@ async def test_google_image_generation_tool_output_compression(
async def test_google_image_generation_tool_compression_validation(
mocker: MockerFixture, google_provider: GoogleProvider
vertex_client_google_provider: GoogleProvider,
) -> None:
"""Test compression validation on Vertex AI: range and JPEG-only."""
model = GoogleModel('gemini-3-pro-image-preview', provider=google_provider)
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
model = GoogleModel('gemini-3-pro-image-preview', provider=vertex_client_google_provider)
# Invalid range: > 100
with pytest.raises(UserError, match='`output_compression` must be between 0 and 100'):
@@ -4011,10 +4005,9 @@ async def test_google_vertexai_image_generation_with_output_format(
assert result.output.media_type == 'image/jpeg'
async def test_google_image_generation_tool_all_fields(mocker: MockerFixture, google_provider: GoogleProvider) -> None:
async def test_google_image_generation_tool_all_fields(vertex_client_google_provider: GoogleProvider) -> None:
"""Test that all ImageGenerationTool fields are mapped correctly on Vertex AI."""
model = GoogleModel('gemini-3-pro-image-preview', provider=google_provider)
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
model = GoogleModel('gemini-3-pro-image-preview', provider=vertex_client_google_provider)
params = ModelRequestParameters(
native_tools=[ImageGenerationTool(aspect_ratio='16:9', size='2K', output_format='jpeg', output_compression=90)]
)
@@ -4030,15 +4023,19 @@ async def test_google_image_generation_tool_all_fields(mocker: MockerFixture, go
def test_google_vertex_skips_include_server_side_tool_invocations(
mocker: MockerFixture, google_provider: GoogleProvider
vertex_client_google_provider: GoogleProvider,
) -> None:
"""Vertex rejects `include_server_side_tool_invocations`, so it must not be set on Gemini 3+ via Vertex.
The model is built the way #6792 reports: a
Vertex-backed `genai.Client` wrapped in `GoogleProvider`, whose `system` stays `'google'` —
the transport, not the provider name, must drive the skip.
Not a VCR test: the field is dropped before the request is sent, and our cassette matchers don't
inspect the request body, so a recording would stay green if it were reintroduced.
"""
model = GoogleModel('gemini-3-pro-preview', provider=google_provider)
mocker.patch.object(GoogleModel, 'system', new_callable=mocker.PropertyMock, return_value='google-cloud')
model = GoogleModel('gemini-3-pro-preview', provider=vertex_client_google_provider)
assert model.system == 'google'
# A function tool is included so `tool_config` is non-empty on both paths; the only field that
# should differ is `include_server_side_tool_invocations`.
params = ModelRequestParameters(function_tools=[ToolDefinition(name='search')], native_tools=[WebSearchTool()])
+10 -11
View File
@@ -14,7 +14,6 @@ The SUPPORT_MATRIX determines expected behavior for each (provider, file_type) p
from __future__ import annotations
import os
import unittest.mock
from dataclasses import dataclass
from itertools import count
from pathlib import Path
@@ -954,6 +953,7 @@ UPLOADED_FILE_ERROR_CASES: list[UploadedFileErrorCase] = [
async def test_uploaded_file_validation_error_in_tool_return(
case: UploadedFileErrorCase,
bedrock_provider: Any,
vertex_client_google_provider: GoogleProvider,
) -> None:
"""Test that invalid UploadedFile in a tool return raises UserError before the API call."""
provider = case.provider
@@ -977,12 +977,9 @@ async def test_uploaded_file_validation_error_in_tool_return(
with pytest.raises(UserError, match=case.match):
await m_bedrock._map_messages(messages, params, None) # pyright: ignore[reportPrivateUsage]
elif provider == 'google_vertex':
m_google = GoogleModel('gemini-3-flash-preview', provider=GoogleProvider(api_key='test-key'))
m_google = GoogleModel('gemini-3-flash-preview', provider=vertex_client_google_provider)
with pytest.raises(UserError, match=case.match):
with unittest.mock.patch.object(
type(m_google), 'system', new_callable=lambda: property(lambda self: 'google-vertex')
):
await m_google._map_messages(messages, params) # pyright: ignore[reportPrivateUsage]
await m_google._map_messages(messages, params) # pyright: ignore[reportPrivateUsage]
elif provider == 'openai_responses':
m_openai = OpenAIResponsesModel('gpt-5-mini', provider=OpenAIProvider(api_key='test-key'))
with pytest.raises(UserError, match=case.match):
@@ -992,13 +989,15 @@ async def test_uploaded_file_validation_error_in_tool_return(
@pytest.mark.skipif(not google_available(), reason='google dependencies not installed')
async def test_uploaded_file_vertex_valid_gcs_uri() -> None:
"""Test that a valid Vertex UploadedFile with gs:// URI maps correctly."""
model = GoogleModel('gemini-3-flash-preview', provider=GoogleProvider(api_key='test-key'))
async def test_uploaded_file_vertex_valid_gcs_uri(vertex_client_google_provider: GoogleProvider) -> None:
"""Test that a valid Vertex UploadedFile with gs:// URI maps correctly.
The model is Vertex-backed via the client transport (not the provider name), matching #6792.
"""
model = GoogleModel('gemini-3-flash-preview', provider=vertex_client_google_provider)
file = UploadedFile(file_id='gs://bucket/path/file.pdf', provider_name='google-cloud', media_type='application/pdf')
messages: list[ModelMessage] = [
ModelRequest(parts=[ToolReturnPart(tool_name='get_file', content=file, tool_call_id='1')]),
]
with unittest.mock.patch.object(type(model), 'system', new_callable=lambda: property(lambda self: 'google-vertex')):
_, contents = await model._map_messages(messages, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage]
_, contents = await model._map_messages(messages, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage]
assert len(contents) == 1
+5 -4
View File
@@ -47,6 +47,8 @@ with try_import() as openai_available:
from pydantic_ai.providers.openai import OpenAIProvider
with try_import() as google_available:
from google.genai import Client
from pydantic_ai.models.google import GoogleModel
from pydantic_ai.providers.google import GoogleProvider
@@ -770,8 +772,7 @@ def test_bedrock_prepare_request_thinking_auto_output_mode(supports_json_schema:
@skip_if_no_google
def test_google_auto_tuple_filters_tool_defs():
"""When resolve_tool_choice returns ('auto', [...]), Google filters tool_defs to only include allowed tools."""
mock_client = MagicMock()
provider = GoogleProvider(client=mock_client)
provider = GoogleProvider(client=Client(vertexai=False, api_key='mock-api-key'))
m = GoogleModel('gemini-2.0-flash', provider=provider)
params = ModelRequestParameters(
function_tools=[make_tool('func')],
@@ -790,7 +791,7 @@ def test_google_auto_tuple_filters_tool_defs():
@skip_if_no_google
def test_google_allowed_function_names_ignore_unavailable_tools():
m = GoogleModel('gemini-2.0-flash', provider=GoogleProvider(client=MagicMock()))
m = GoogleModel('gemini-2.0-flash', provider=GoogleProvider(client=Client(vertexai=False, api_key='mock-api-key')))
params = ModelRequestParameters(function_tools=[make_tool('get_time')], allow_text_output=False)
with pytest.warns(UserWarning, match='not currently available'):
@@ -845,7 +846,7 @@ def test_google_native_tool_only_omits_function_calling_config(case: dict[str, A
Asserted on the request shape directly rather than via VCR: a cassette replay can't catch a
malformed request, since it replays a recorded response without re-validating against the API.
"""
m = GoogleModel(case['model'], provider=GoogleProvider(client=MagicMock()))
m = GoogleModel(case['model'], provider=GoogleProvider(client=Client(vertexai=False, api_key='mock-api-key')))
_, tool_config, _ = m._get_tool_config(case['request_parameters'], {}) # pyright: ignore[reportPrivateUsage]
+74
View File
@@ -6,6 +6,11 @@ model class accepts both via the `_GEMINI_API_PROVIDER_NAMES` / `_GOOGLE_CLOUD_P
replay still routes thinking signatures and built-in tool parts correctly. These tests pin
that contract.
The pre-v2 names were transport-derived, so the set follows the client's transport rather than the
current `name`, and the two can disagree when a pre-built client is passed to either provider. Each
model therefore replays its transport's alias and drops the other transport's, whose signatures it
could not use anyway.
TODO: generalize this into a cross-module history-replay test suite (V2-RULES rule 21).
"""
@@ -21,6 +26,8 @@ from pydantic_ai.native_tools import CodeExecutionTool
from ..conftest import try_import
with try_import() as imports_successful:
from google.genai import Client
from pydantic_ai.models.google import GoogleModel, _content_model_response # pyright: ignore[reportPrivateUsage]
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.providers.google_cloud import GoogleCloudProvider
@@ -42,11 +49,28 @@ def _google_cloud_model() -> GoogleModel:
return GoogleModel('gemini-2.5-flash', provider=GoogleCloudProvider(project='p', location='us-central1'))
def _vertex_client_in_google_provider_model() -> GoogleModel:
"""A Google Cloud client handed to `GoogleProvider`, so `name` stays `'google'` (#6792)."""
return GoogleModel(
'gemini-2.5-flash',
provider=GoogleProvider(client=Client(vertexai=True, project='p', location='us-central1')),
)
def _gla_client_in_google_cloud_provider_model() -> GoogleModel:
"""A Gemini Developer API client handed to `GoogleCloudProvider`, so `name` stays `'google-cloud'`."""
return GoogleModel('gemini-2.5-flash', provider=GoogleCloudProvider(client=Client(vertexai=False, api_key='k')))
@pytest.mark.parametrize(
('model_factory', 'historical_provider_name', 'current_provider_name'),
[
(_gemini_api_model, 'google-gla', 'google'),
(_google_cloud_model, 'google-vertex', 'google-cloud'),
# Name and transport disagree: the accepted set follows the transport, so the pre-v2 alias
# that replays is the transport's one, not the one the current `name` would suggest.
(_vertex_client_in_google_provider_model, 'google-vertex', 'google'),
(_gla_client_in_google_cloud_provider_model, 'google-gla', 'google-cloud'),
],
)
def test_history_with_legacy_provider_name_still_routes_thinking_signature(
@@ -81,6 +105,10 @@ def test_history_with_legacy_provider_name_still_routes_thinking_signature(
[
(_gemini_api_model, 'google-gla', 'google'),
(_google_cloud_model, 'google-vertex', 'google-cloud'),
# Name and transport disagree: the accepted set follows the transport, so the pre-v2 alias
# that replays is the transport's one, not the one the current `name` would suggest.
(_vertex_client_in_google_provider_model, 'google-vertex', 'google'),
(_gla_client_in_google_cloud_provider_model, 'google-gla', 'google-cloud'),
],
)
def test_history_with_legacy_provider_name_still_replays_builtin_tool_parts(
@@ -112,3 +140,49 @@ def test_history_with_legacy_provider_name_still_replays_builtin_tool_parts(
parts = content.get('parts') or []
assert any('executable_code' in p for p in parts)
assert any('code_execution_result' in p for p in parts)
@pytest.mark.parametrize(
('model_factory', 'foreign_provider_name', 'current_provider_name'),
[
(_gemini_api_model, 'google-vertex', 'google'),
(_google_cloud_model, 'google-gla', 'google-cloud'),
(_vertex_client_in_google_provider_model, 'google-gla', 'google'),
(_gla_client_in_google_cloud_provider_model, 'google-vertex', 'google-cloud'),
],
)
def test_history_from_the_other_transport_does_not_replay_its_signature(
model_factory: type, foreign_provider_name: str, current_provider_name: str
) -> None:
"""A signature captured on the *other* transport is dropped rather than replayed.
`ThinkingPart.provider_name` documents that signatures are only sent back to the same provider,
and a signature minted by the Gemini Developer API is not valid on Google Cloud or vice versa.
The positive cases above pin which alias replays; this pins that the other transport's alias
does not, so re-widening the set back to both families can't go unnoticed.
What this cannot pin: the v2 names carry no transport, so a `'google'`-stamped history minted on
the Gemini Developer API still replays on a Vertex-backed `GoogleProvider` (and the mirror for
`'google-cloud'`). That ambiguity is inherent to the v2 names, not introduced by keying on the
transport — the pre-v2 aliases are the only part of the stamp that identifies one.
"""
model = model_factory()
assert model.system == current_provider_name
signature = base64.b64encode(b'sig').decode('ascii')
response = ModelResponse(
parts=[
ThinkingPart(content='reasoning', provider_name=foreign_provider_name, signature=signature),
TextPart(content='final'),
],
provider_name=foreign_provider_name,
)
accepted = model._matching_provider_names
assert foreign_provider_name not in accepted
content = _content_model_response(response, accepted)
assert content is not None
parts = content.get('parts') or []
text_part = next(p for p in parts if p.get('text') == 'final')
assert 'thought_signature' not in text_part