9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
128 lines
5.0 KiB
Python
128 lines
5.0 KiB
Python
"""Wire-contract tests: unified `thinking` and a native web-search tool both land on the request body.
|
|
|
|
Parametrized across the providers that support reasoning and native web search at once. Each case asserts
|
|
on the actual request wire body (`vcr.requests[0].body`) — the cassette matcher isn't sensitive to the
|
|
body, so asserting it directly is what proves both the thinking config and the tool survive together.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
from vcr.cassette import Cassette
|
|
|
|
from pydantic_ai import Agent
|
|
from pydantic_ai.capabilities import NativeTool
|
|
from pydantic_ai.native_tools import WebSearchTool
|
|
from pydantic_ai.settings import ModelSettings
|
|
|
|
from .cassette_utils import single_request_body
|
|
from .conftest import try_import
|
|
|
|
with try_import() as anthropic_imports:
|
|
from pydantic_ai.models.anthropic import AnthropicModel
|
|
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
|
|
with try_import() as openai_imports:
|
|
from pydantic_ai.models.openai import OpenAIResponsesModel
|
|
from pydantic_ai.providers.openai import OpenAIProvider
|
|
|
|
with try_import() as google_imports:
|
|
from pydantic_ai.models.google import GoogleModel
|
|
from pydantic_ai.providers.google import GoogleProvider
|
|
|
|
if TYPE_CHECKING:
|
|
from pydantic_ai.models import Model
|
|
|
|
pytestmark = [pytest.mark.anyio, pytest.mark.vcr]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WireCase:
|
|
id: str
|
|
provider: str
|
|
model_name: str
|
|
present: tuple[tuple[tuple[str, ...], object], ...]
|
|
"""(path, value) pairs: each nested key path in the request body must resolve to exactly this value."""
|
|
tool_marker: str
|
|
"""Substring that must appear in the compact-JSON-serialized `tools` array (the native web-search tool)."""
|
|
max_tokens: int | None = None
|
|
"""Set when the model needs `max_tokens` above the thinking budget (Anthropic rejects budget >= max_tokens)."""
|
|
marks: tuple[pytest.MarkDecorator, ...] = ()
|
|
|
|
|
|
CASES = [
|
|
WireCase(
|
|
id='anthropic',
|
|
provider='anthropic',
|
|
model_name='claude-sonnet-4-5',
|
|
present=((('thinking', 'type'), 'enabled'), (('thinking', 'budget_tokens'), 16384)),
|
|
tool_marker='"type":"web_search_',
|
|
max_tokens=20000,
|
|
marks=(pytest.mark.skipif(not anthropic_imports(), reason='anthropic not installed'),),
|
|
),
|
|
WireCase(
|
|
id='openai-responses',
|
|
provider='openai-responses',
|
|
model_name='gpt-5',
|
|
present=((('reasoning', 'effort'), 'high'),),
|
|
tool_marker='"type":"web_search"',
|
|
marks=(pytest.mark.skipif(not openai_imports(), reason='openai not installed'),),
|
|
),
|
|
WireCase(
|
|
id='google',
|
|
provider='google',
|
|
# Gemini 2.5 rejects a native-tool-only request (`functionCallingConfig` without
|
|
# `functionDeclarations`); Gemini 3 accepts it, so this uses a Gemini 3 model.
|
|
model_name='gemini-3-flash-preview',
|
|
present=(
|
|
(('generationConfig', 'thinkingConfig', 'include_thoughts'), True),
|
|
(('generationConfig', 'thinkingConfig', 'thinking_level'), 'HIGH'),
|
|
),
|
|
tool_marker='"googleSearch":',
|
|
marks=(pytest.mark.skipif(not google_imports(), reason='google-genai not installed'),),
|
|
),
|
|
]
|
|
|
|
|
|
def _build_model(case: WireCase, *, anthropic_api_key: str, openai_api_key: str, gemini_api_key: str) -> Model:
|
|
if case.provider == 'anthropic':
|
|
return AnthropicModel(case.model_name, provider=AnthropicProvider(api_key=anthropic_api_key))
|
|
if case.provider == 'openai-responses':
|
|
return OpenAIResponsesModel(case.model_name, provider=OpenAIProvider(api_key=openai_api_key))
|
|
if case.provider == 'google':
|
|
return GoogleModel(case.model_name, provider=GoogleProvider(api_key=gemini_api_key))
|
|
raise ValueError(f'unknown provider {case.provider!r}') # pragma: no cover
|
|
|
|
|
|
@pytest.mark.parametrize('case', [pytest.param(c, id=c.id, marks=c.marks) for c in CASES])
|
|
async def test_thinking_with_native_tool_wire_contract(
|
|
case: WireCase,
|
|
allow_model_requests: None,
|
|
anthropic_api_key: str,
|
|
openai_api_key: str,
|
|
gemini_api_key: str,
|
|
vcr: Cassette,
|
|
):
|
|
"""`thinking='high'` and a native web-search tool both land on the request wire body."""
|
|
model = _build_model(
|
|
case, anthropic_api_key=anthropic_api_key, openai_api_key=openai_api_key, gemini_api_key=gemini_api_key
|
|
)
|
|
settings = ModelSettings(thinking='high')
|
|
if case.max_tokens is not None:
|
|
settings['max_tokens'] = case.max_tokens
|
|
agent = Agent(model, model_settings=settings, capabilities=[NativeTool(WebSearchTool())])
|
|
await agent.run('What is the top news story today? Use web search.')
|
|
|
|
body = single_request_body(vcr)
|
|
for path, value in case.present:
|
|
node = body
|
|
for key in path:
|
|
node = node[key]
|
|
assert node == value, f'{path!r}={node!r}, expected {value!r}'
|
|
tools = json.dumps(body['tools'], separators=(',', ':'))
|
|
assert case.tool_marker in tools, f'{case.tool_marker!r} not in tools {tools!r}'
|