9201ef759e
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled
148 lines
5.2 KiB
Python
148 lines
5.2 KiB
Python
"""Tests for `google_cached_content` context caching."""
|
|
|
|
from __future__ import annotations as _annotations
|
|
|
|
from collections.abc import Callable
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
from pytest_mock import MockerFixture
|
|
|
|
from pydantic_ai import Agent
|
|
from pydantic_ai.output import PromptedOutput
|
|
|
|
from ...conftest import try_import
|
|
|
|
with try_import() as imports_successful:
|
|
from google.genai.types import (
|
|
Candidate,
|
|
Content,
|
|
CreateCachedContentConfig,
|
|
FinishReason as GoogleFinishReason,
|
|
GenerateContentResponse,
|
|
Part,
|
|
)
|
|
|
|
from pydantic_ai.models.google import GoogleModel, GoogleModelSettings
|
|
|
|
if TYPE_CHECKING:
|
|
GoogleModelFactory = Callable[..., GoogleModel]
|
|
|
|
pytestmark = [
|
|
pytest.mark.skipif(not imports_successful(), reason='google-genai not installed'),
|
|
pytest.mark.anyio,
|
|
pytest.mark.vcr,
|
|
]
|
|
|
|
|
|
async def test_google_model_cached_content(
|
|
allow_model_requests: None,
|
|
google_model: GoogleModelFactory,
|
|
):
|
|
"""End-to-end contract for `google_cached_content`: the cache resource
|
|
owns `system_instruction`, `tools`, and `tool_config`, and both Gemini
|
|
and Vertex return `400 INVALID_ARGUMENT` if those fields are sent
|
|
alongside `cached_content`. Pydantic AI therefore strips them from the
|
|
outgoing request, emitting a `UserWarning` whenever stripping actually
|
|
drops a populated field.
|
|
|
|
One cassette covers both branches: first run carries instructions + a
|
|
registered tool (warning fires, request still succeeds, response shows a
|
|
cache hit); second run is minimal (nothing to strip, no warning — the
|
|
suite's `filterwarnings = ['error']` setting turns any stray warning
|
|
into a failure). The shared `_build_content_and_config` helper means a
|
|
non-streaming test covers the streaming path too.
|
|
|
|
See issue #5671.
|
|
"""
|
|
model_name = 'gemini-2.5-flash'
|
|
long_text = 'Paris is the capital of France. The Eiffel Tower is in Paris. ' * 250
|
|
|
|
model = google_model(model_name)
|
|
cache = await model.client.aio.caches.create(
|
|
model=model_name,
|
|
config=CreateCachedContentConfig(
|
|
system_instruction='You are a geography expert. Be concise.',
|
|
contents=[Content(role='user', parts=[Part(text=long_text)])],
|
|
ttl='120s',
|
|
),
|
|
)
|
|
cache_name = cache.name
|
|
assert cache_name is not None
|
|
try:
|
|
settings = GoogleModelSettings(google_cached_content=cache_name)
|
|
|
|
agent_with_extras = Agent(
|
|
model=model,
|
|
instructions='These instructions get stripped — the cache owns the system_instruction.',
|
|
model_settings=settings,
|
|
)
|
|
|
|
@agent_with_extras.tool_plain
|
|
def unused_tool(x: str) -> str:
|
|
return x # pragma: no cover
|
|
|
|
with pytest.warns(UserWarning, match='`google_cached_content` is set'):
|
|
result = await agent_with_extras.run('What is the capital of France?')
|
|
|
|
assert 'Paris' in result.output
|
|
assert (result.usage.details or {}).get('cached_content_tokens', 0) > 0
|
|
|
|
agent_minimal = Agent(model=model, model_settings=settings)
|
|
result_minimal = await agent_minimal.run('Say the capital one more time.')
|
|
assert 'Paris' in result_minimal.output
|
|
finally:
|
|
await model.client.aio.caches.delete(name=cache_name)
|
|
|
|
|
|
async def test_google_model_cached_content_prompted_output_enables_json_mode(
|
|
allow_model_requests: None,
|
|
google_model: GoogleModelFactory,
|
|
mocker: MockerFixture,
|
|
):
|
|
"""`prompted` output mode normally only switches the request to JSON mode when no
|
|
tools are registered (the model has to dedicate its output to JSON instead of
|
|
tool calls). When `google_cached_content` strips the tools, the post-strip request
|
|
*is* tool-less, so JSON mode should kick in — otherwise the agent gets free-form
|
|
text back and the prompted-JSON parser fails downstream with no clear link to the
|
|
cache. Regression test for the interaction flagged on #5681.
|
|
"""
|
|
cache_name = 'projects/p/locations/global/cachedContents/test-cache'
|
|
model = google_model('gemini-2.5-pro')
|
|
|
|
class CityLocation(BaseModel):
|
|
city: str
|
|
country: str
|
|
|
|
chunk = GenerateContentResponse(
|
|
candidates=[
|
|
Candidate(
|
|
content=Content(parts=[Part(text='{"city": "Paris", "country": "France"}')], role='model'),
|
|
finish_reason=GoogleFinishReason.STOP,
|
|
)
|
|
],
|
|
response_id='cached',
|
|
model_version='gemini-2.5-pro',
|
|
)
|
|
mock = mocker.patch.object(model.client.aio.models, 'generate_content', return_value=chunk)
|
|
|
|
agent = Agent(
|
|
model=model,
|
|
output_type=PromptedOutput(CityLocation),
|
|
model_settings=GoogleModelSettings(google_cached_content=cache_name),
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def unused_tool(x: str) -> str:
|
|
return x # pragma: no cover
|
|
|
|
with pytest.warns(UserWarning, match='`google_cached_content` is set'):
|
|
await agent.run('Where is the Eiffel Tower?')
|
|
|
|
assert mock.call_count == 1
|
|
_, kwargs = mock.call_args
|
|
config = kwargs['config']
|
|
assert not config.get('tools')
|
|
assert config['response_mime_type'] == 'application/json'
|