diff --git a/docs/models/google.md b/docs/models/google.md index 23259f2e5a..00e4dfd507 100644 --- a/docs/models/google.md +++ b/docs/models/google.md @@ -411,6 +411,49 @@ avg_logprobs = result.response.provider_details.get('avg_logprobs') See the [Google Dev Blog](https://developers.googleblog.com/unlock-gemini-reasoning-with-logprobs-on-vertex-ai/) for more information. +### Context caching (`google_cached_content`) + +When you've created a Gemini [cached content resource](https://ai.google.dev/gemini-api/docs/caching), pass its resource name through [`google_cached_content`][pydantic_ai.models.google.GoogleModelSettings.google_cached_content] to reuse it across requests: + +```python +from pydantic_ai import Agent +from pydantic_ai.models.google import GoogleModel, GoogleModelSettings + +model_settings = GoogleModelSettings( + google_cached_content='projects/p/locations/global/cachedContents/your-cache-id', +) + +agent = Agent(GoogleModel('gemini-2.5-pro'), model_settings=model_settings) +... +``` + +!!! warning "Cached fields are owned by the cache resource" + The cache resource owns `system_instruction`, `tools`, and `tool_config` — Pydantic AI strips them from outgoing requests when `google_cached_content` is set, so agent instructions and registered tools are ignored on cached requests. A `UserWarning` is emitted whenever stripping drops a field, so the mismatch is discoverable. + +??? example "Create a cached content resource" + Pydantic AI doesn't wrap the cache-management API — create the resource with the underlying [google-genai](https://googleapis.github.io/python-genai/) SDK, then pass its name through `google_cached_content`: + + ```python {test="skip"} + from google.genai.types import Content, CreateCachedContentConfig, Part + + from pydantic_ai.providers.google import GoogleProvider + + provider = GoogleProvider(api_key='your-api-key') + + cache = provider.client.caches.create( + model='gemini-2.5-flash', + config=CreateCachedContentConfig( + system_instruction='You are a geography expert. Be concise.', + contents=[Content(role='user', parts=[Part(text='...long context to cache...')])], + ttl='3600s', + ), + ) + print(cache.name) + #> cachedContents/abc123... + ``` + + Caches have a minimum size (≈1024 tokens for `gemini-2.5-flash`, ≈4096 for `gemini-2.5-pro`) and a TTL — see the [Gemini caching docs](https://ai.google.dev/gemini-api/docs/caching) for the current thresholds, pricing, and `list` / `update` / `delete` operations. + ## Streaming cancellation !!! warning "Cancellation limitations" diff --git a/pydantic_ai_slim/pydantic_ai/models/google.py b/pydantic_ai_slim/pydantic_ai/models/google.py index 6893f5ca39..449d802d20 100644 --- a/pydantic_ai_slim/pydantic_ai/models/google.py +++ b/pydantic_ai_slim/pydantic_ai/models/google.py @@ -270,6 +270,16 @@ class GoogleModelSettings(ModelSettings, total=False): google_cached_content: str """The name of the cached content to use for the model. + When set, `system_instruction`, `tools`, and `tool_config` are omitted from + the outgoing request — the cached content resource owns those fields, and + both the Gemini API and Vertex AI reject requests that supply them + alongside `cached_content` (`400 INVALID_ARGUMENT`: "Tool config, tools and + system instruction should not be set in the request when using cached + content."). Any tools registered on the agent and any system prompt are + therefore ignored on requests that go through the cache; a `UserWarning` + is emitted whenever stripping actually drops a field so the mismatch is + discoverable. + See for more information. """ @@ -325,6 +335,31 @@ def _get_deprecated_google_service_tier(model_settings: GoogleModelSettings) -> return None +def _warn_on_cached_content_strips( + cached_content: str | None, + system_instruction: ContentDict | None, + tools: list[ToolDict] | None, +) -> None: + """Emit a `UserWarning` when `google_cached_content` would strip a field that the caller populated.""" + if not cached_content: + return + dropped: list[str] = [] + if system_instruction is not None: + dropped.append('system_instruction') + if tools is not None: + dropped.extend(('tools', 'tool_config')) + if dropped: + names = ', '.join(f'`{n}`' for n in dropped) + warnings.warn( + f'`google_cached_content` is set; the cached content resource owns ' + f'{names}, so these fields are stripped from the outgoing request ' + f'and any agent instructions or registered tools are ignored. ' + f'See https://ai.google.dev/gemini-api/docs/caching.', + UserWarning, + stacklevel=3, + ) + + def _get_deprecated_google_vertex_service_tier(model_settings: GoogleModelSettings) -> GoogleCloudServiceTier | None: """Return `google_vertex_service_tier`, emitting a `PydanticAIDeprecationWarning` when it is set. @@ -844,6 +879,12 @@ async def _build_content_and_config( if model_request_parameters.function_tools and not self.profile.supports_tools: raise UserError('Tools are not supported by this model.') + # `google_cached_content` will strip `tools` (and `tool_config` / `system_instruction`) + # below — resolve it up front so `prompted` output-mode sees the post-strip tool set + # and still enables JSON mode when the cache effectively leaves the request tool-less. + cached_content = model_settings.get('google_cached_content') + effective_tools = None if cached_content else tools + response_mime_type = None response_schema = None if model_request_parameters.output_mode == 'native': @@ -855,7 +896,7 @@ async def _build_content_and_config( output_object = model_request_parameters.output_object assert output_object is not None response_schema = self._map_response_schema(output_object) - elif model_request_parameters.output_mode == 'prompted' and not tools: + elif model_request_parameters.output_mode == 'prompted' and not effective_tools: if not self.profile.supports_json_object_output: raise UserError('JSON output is not supported by this model.') response_mime_type = 'application/json' @@ -884,9 +925,12 @@ async def _build_content_and_config( else: raise UserError('Google does not support setting ModelSettings.timeout to a httpx.Timeout') + # See `GoogleModelSettings.google_cached_content` for why these three fields are stripped. + _warn_on_cached_content_strips(cached_content, system_instruction, tools) + config = GenerateContentConfigDict( http_options=http_options, - system_instruction=system_instruction, + system_instruction=None if cached_content else system_instruction, temperature=model_settings.get('temperature'), top_p=model_settings.get('top_p'), top_k=model_settings.get('top_k'), @@ -899,9 +943,9 @@ async def _build_content_and_config( thinking_config=self._translate_thinking(model_settings, model_request_parameters), labels=model_settings.get('google_labels'), media_resolution=model_settings.get('google_video_resolution'), - cached_content=model_settings.get('google_cached_content'), - tools=cast(ToolListUnionDict, tools), - tool_config=tool_config, + cached_content=cached_content, + tools=cast(ToolListUnionDict, effective_tools) if effective_tools is not None else None, + tool_config=None if cached_content else tool_config, response_mime_type=response_mime_type, response_json_schema=response_schema, response_modalities=modalities, diff --git a/tests/models/google/cassettes/test_cache/test_google_model_cached_content.yaml b/tests/models/google/cassettes/test_cache/test_google_model_cached_content.yaml new file mode 100644 index 0000000000..5169b9c161 --- /dev/null +++ b/tests/models/google/cassettes/test_cache/test_google_model_cached_content.yaml @@ -0,0 +1,364 @@ +interactions: +- request: + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, br, zstd + connection: + - keep-alive + content-length: + - '15711' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + method: POST + parsed_body: + contents: + - parts: + - text: 'Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel + Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. + The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital + of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is + the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the + capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. + Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower + is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of France. The + Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. Paris is the capital of + France. The Eiffel Tower is in Paris. Paris is the capital of France. The Eiffel Tower is in Paris. ' + role: user + model: models/gemini-2.5-flash + systemInstruction: + parts: + - text: You are a geography expert. Be concise. + role: user + ttl: 120s + uri: https://generativelanguage.googleapis.com/v1beta/cachedContents + response: + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-length: + - '304' + content-type: + - application/json; charset=UTF-8 + server-timing: + - gfet4t7; dur=682 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + parsed_body: + createTime: '2026-05-28T14:45:17.041985Z' + displayName: '' + expireTime: '2026-05-28T14:47:16.701652823Z' + model: models/gemini-2.5-flash + name: cachedContents/7lf5him5ev4iemi1yjv2gwli5bhpz3yidzejwllo + updateTime: '2026-05-28T14:45:17.041985Z' + usageMetadata: + totalTokenCount: 3512 + status: + code: 200 + message: OK +- request: + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, br, zstd + connection: + - keep-alive + content-length: + - '217' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + method: POST + parsed_body: + cachedContent: cachedContents/7lf5him5ev4iemi1yjv2gwli5bhpz3yidzejwllo + contents: + - parts: + - text: What is the capital of France? + role: user + generationConfig: + responseModalities: + - TEXT + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-length: + - '508' + content-type: + - application/json; charset=UTF-8 + server-timing: + - gfet4t7; dur=1588 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + parsed_body: + candidates: + - content: + parts: + - text: Paris. + role: model + finishReason: STOP + index: 0 + modelVersion: gemini-2.5-flash + responseId: _VQYaqvRGbW6qtsPg4TDoAg + usageMetadata: + cacheTokensDetails: + - modality: TEXT + tokenCount: 3512 + cachedContentTokenCount: 3512 + candidatesTokenCount: 2 + promptTokenCount: 3520 + promptTokensDetails: + - modality: TEXT + tokenCount: 3520 + serviceTier: standard + thoughtsTokenCount: 42 + totalTokenCount: 3564 + status: + code: 200 + message: OK +- request: + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, br, zstd + connection: + - keep-alive + content-length: + - '217' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + method: POST + parsed_body: + cachedContent: cachedContents/7lf5him5ev4iemi1yjv2gwli5bhpz3yidzejwllo + contents: + - parts: + - text: Say the capital one more time. + role: user + generationConfig: + responseModalities: + - TEXT + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-length: + - '508' + content-type: + - application/json; charset=UTF-8 + server-timing: + - gfet4t7; dur=1760 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + parsed_body: + candidates: + - content: + parts: + - text: Paris. + role: model + finishReason: STOP + index: 0 + modelVersion: gemini-2.5-flash + responseId: _1QYavqiB-6wqtsPxaKcuQw + usageMetadata: + cacheTokensDetails: + - modality: TEXT + tokenCount: 3512 + cachedContentTokenCount: 3512 + candidatesTokenCount: 2 + promptTokenCount: 3520 + promptTokensDetails: + - modality: TEXT + tokenCount: 3520 + serviceTier: standard + thoughtsTokenCount: 51 + totalTokenCount: 3573 + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, br, zstd + connection: + - keep-alive + content-type: + - application/json + host: + - generativelanguage.googleapis.com + method: DELETE + uri: https://generativelanguage.googleapis.com/v1beta/cachedContents/7lf5him5ev4iemi1yjv2gwli5bhpz3yidzejwllo + response: + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-length: + - '2' + content-type: + - application/json; charset=UTF-8 + server-timing: + - gfet4t7; dur=336 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + parsed_body: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/google/test_cache.py b/tests/models/google/test_cache.py new file mode 100644 index 0000000000..c44e2e6307 --- /dev/null +++ b/tests/models/google/test_cache.py @@ -0,0 +1,147 @@ +"""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'