Skip to main content
Glama
pydantic

mcp-run-python

Official
by pydantic
test_output.py24.7 kB
"""Tests for Anthropic native JSON schema output and strict tool support. This module tests the implementation of Anthropic's structured outputs feature, including native JSON schema output for final responses and strict tool calling. Test organization: 1. Strict Tools - Model Support 2. Strict Tools - Schema Compatibility 3. Native Output - Model Support """ from __future__ import annotations as _annotations from collections.abc import Callable from typing import Annotated import httpx import pytest from inline_snapshot import snapshot from pydantic import BaseModel, Field from pydantic_ai import Agent from pydantic_ai.exceptions import UserError from pydantic_ai.output import NativeOutput from ...conftest import try_import from ..test_anthropic import MockAnthropic, get_mock_chat_completion_kwargs with try_import() as imports_successful: from anthropic import AsyncAnthropic, omit as OMIT from anthropic.types.beta import BetaMessage, BetaTextBlock, BetaUsage from pydantic_ai.models.anthropic import AnthropicModel from pydantic_ai.providers.anthropic import AnthropicProvider from ..test_anthropic import completion_message pytestmark = [ pytest.mark.skipif(not imports_successful(), reason='anthropic not installed'), pytest.mark.anyio, pytest.mark.vcr, ] # ============================================================================= # STRICT TOOLS - Model Support # ============================================================================= def test_strict_tools_supported_model_auto_enabled( allow_model_requests: None, weather_tool_responses: list[BetaMessage] ): """sonnet-4-5: strict=None + compatible schema → no strict field, no beta header.""" mock_client = MockAnthropic.create_mock(weather_tool_responses) model = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) agent = Agent(model) @agent.tool_plain def get_weather(location: str) -> str: return f'Weather in {location}' agent.run_sync('What is the weather in Paris?') completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] tools = completion_kwargs['tools'] betas = completion_kwargs['betas'] tool = tools[0] assert 'strict' not in tool # strict was not explicitly set assert tools == snapshot( [ { 'name': 'get_weather', 'description': '', 'input_schema': { 'type': 'object', 'properties': {'location': {'type': 'string'}}, 'additionalProperties': False, 'required': ['location'], }, } ] ) assert betas == OMIT def test_strict_tools_supported_model_explicit_false( allow_model_requests: None, weather_tool_responses: list[BetaMessage] ): """sonnet-4-5: strict=False → no strict field, no beta header.""" mock_client = MockAnthropic.create_mock(weather_tool_responses) model = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) agent = Agent(model) @agent.tool_plain(strict=False) def get_weather(location: str) -> str: return f'Weather in {location}' agent.run_sync('What is the weather in Paris?') completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] tools = completion_kwargs['tools'] betas = completion_kwargs.get('betas') assert 'strict' not in tools[0] assert tools[0]['input_schema']['additionalProperties'] is False assert betas is OMIT def test_strict_tools_unsupported_model_no_strict_sent( allow_model_requests: None, weather_tool_responses: list[BetaMessage] ): """sonnet-4-0: strict=None → no strict field, no beta header (model doesn't support strict).""" mock_client = MockAnthropic.create_mock(weather_tool_responses) model = AnthropicModel('claude-sonnet-4-0', provider=AnthropicProvider(anthropic_client=mock_client)) agent = Agent(model) @agent.tool_plain def get_weather(location: str) -> str: return f'Weather in {location}' agent.run_sync('What is the weather in Paris?') completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] tools = completion_kwargs['tools'] betas = completion_kwargs.get('betas') # sonnet-4-0 doesn't support strict tools, so no strict field or beta header assert 'strict' not in tools[0] assert betas is OMIT # ============================================================================= # STRICT TOOLS - Schema Compatibility # ============================================================================= def test_strict_tools_incompatible_schema_not_auto_enabled(allow_model_requests: None): """sonnet-4-5: strict=None → no strict field, no beta header.""" mock_client = MockAnthropic.create_mock( completion_message([BetaTextBlock(text='Sure', type='text')], BetaUsage(input_tokens=5, output_tokens=2)) ) model = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) agent = Agent(model) @agent.tool_plain def constrained_tool(username: Annotated[str, Field(min_length=3)]) -> str: # pragma: no cover return username agent.run_sync('Test') completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] tools = completion_kwargs['tools'] betas = completion_kwargs.get('betas') # strict is not auto-enabled, so no strict field assert 'strict' not in tools[0] # because the schema wasn't transformed, it keeps the pydantic constraint assert tools[0]['input_schema']['properties']['username']['minLength'] == 3 assert betas is OMIT # ============================================================================= # NATIVE OUTPUT - Model Support # ============================================================================= def test_native_output_supported_model( allow_model_requests: None, mock_sonnet_4_5: tuple[AnthropicModel, AsyncAnthropic], city_location_schema: type[BaseModel], ): """sonnet-4-5: NativeOutput → strict=True + beta header + output_format.""" model, mock_client = mock_sonnet_4_5 agent = Agent(model, output_type=NativeOutput(city_location_schema)) agent.run_sync('What is the capital of France?') completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[-1] output_format = completion_kwargs['output_format'] betas = completion_kwargs['betas'] assert output_format['type'] == 'json_schema' assert output_format['schema']['type'] == 'object' assert betas == snapshot(['structured-outputs-2025-11-13']) # ============================================================================= # COMPREHENSIVE INTEGRATION TESTS - All Combinations # ============================================================================= class CityInfo(BaseModel): """Information about a city.""" city: str country: str population: int def create_header_verification_hook(expect_beta: bool, test_name: str): """Create an httpx event hook to verify request headers. NOTE: the vcr config doesn't record anthropic-beta headers. This hook allows us to verify them in live API tests. TODO: remove when structured outputs is generally available and no longer a beta feature. """ errors: list[str] = [] async def verify_headers(request: httpx.Request): # Only verify for messages endpoint if '/messages' in str(request.url): # pragma: no branch beta_header = request.headers.get('anthropic-beta', '') # excluded from coverage cause the if's shouldn't trigger when the tests are passing if expect_beta: if 'structured-outputs-2025-11-13' not in beta_header: # pragma: no cover errors.append( f'Test "{test_name}": Expected beta header ' f'"structured-outputs-2025-11-13" but got: {beta_header!r}' ) else: if 'structured-outputs-2025-11-13' in beta_header: # pragma: no cover errors.append( f'Test "{test_name}": Did not expect beta header ' f'"structured-outputs-2025-11-13" but got: {beta_header!r}' ) verify_headers.errors = errors # type: ignore[attr-defined] return verify_headers ANTHROPIC_MODEL_FIXTURE = Callable[..., AnthropicModel] # ============================================================================= # Supported Model Tests (claude-sonnet-4-5) # ============================================================================= @pytest.mark.vcr def test_no_tools_no_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Agent with no tools and no output_type → no beta header.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=False, test_name='test_no_tools_no_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model) agent.run_sync('Tell me a brief fact about Paris') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_no_tools_basemodel_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Agent with no tools and BaseModel output_type → no beta header.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=False, test_name='test_no_tools_basemodel_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=CityInfo) agent.run_sync('Give me information about Tokyo') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_no_tools_native_output_strict_true( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Agent with NativeOutput(strict=True) → beta header + output_format.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_no_tools_native_output_strict_true') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=NativeOutput(CityInfo, strict=True)) result = agent.run_sync('Tell me about London') assert isinstance(result.output, CityInfo) if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_no_tools_native_output_strict_none( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Agent with NativeOutput(strict=None) → forces strict=True, beta header + output_format.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_no_tools_native_output_strict_none') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=NativeOutput(CityInfo)) result = agent.run_sync('Give me facts about Berlin') assert isinstance(result.output, CityInfo) if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) def test_no_tools_native_output_strict_false( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Agent with NativeOutput(strict=False) → raises UserError.""" model = anthropic_model('claude-sonnet-4-5') agent = Agent(model, output_type=NativeOutput(CityInfo, strict=False)) with pytest.raises( UserError, match='Setting `strict=False` on `output_type=NativeOutput\\(\\.\\.\\.\\)` is not allowed for Anthropic models.', ): agent.run_sync('Tell me about Rome') @pytest.mark.vcr def test_strict_true_tool_no_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=True, no output_type → beta header, tool has strict field.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_strict_true_tool_no_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model) @agent.tool_plain(strict=True) def get_weather(city: str) -> str: return f'Weather in {city}: Sunny, 22°C' agent.run_sync("What's the weather in San Francisco?") if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_strict_true_tool_basemodel_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=True, BaseModel output_type → beta header, tool has strict field.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_strict_true_tool_basemodel_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=CityInfo) @agent.tool_plain(strict=True) def get_population(city: str) -> int: return 8_000_000 if city == 'New York' else 1_000_000 agent.run_sync('Get me info about New York including its population') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_strict_true_tool_native_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=True, NativeOutput → beta header, tool has strict field + output_format.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_strict_true_tool_native_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=NativeOutput(CityInfo)) @agent.tool_plain(strict=True) def lookup_country(city: str) -> str: return 'France' if city == 'Paris' else 'Unknown' result = agent.run_sync('Give me details about Paris') assert isinstance(result.output, CityInfo) if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_strict_none_tool_no_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=None, no output_type → no beta header, tool has no strict field.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=False, test_name='test_strict_none_tool_no_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model) @agent.tool_plain def search_database(query: str) -> str: return f'Found 42 results for "{query}"' agent.run_sync('Find cities in Europe') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_strict_none_tool_basemodel_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=None, BaseModel output_type → no beta header, tool has no strict field.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=False, test_name='test_strict_none_tool_basemodel_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=CityInfo) @agent.tool_plain def get_timezone(city: str) -> str: # pragma: no cover return 'UTC+10:00' if city == 'Sydney' else 'UTC+1:00' agent.run_sync('Give me info about Sydney including its timezone') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_strict_none_tool_native_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=None, NativeOutput → beta from native only, tool has no strict field + output_format.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_strict_none_tool_native_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=NativeOutput(CityInfo)) @agent.tool_plain def get_coordinates(city: str) -> str: return '41.3874° N, 2.1686° E' if city == 'Barcelona' else 'Unknown' result = agent.run_sync('Give me details about Barcelona') assert isinstance(result.output, CityInfo) if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_strict_false_tool_no_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=False, no output_type → no beta header.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=False, test_name='test_strict_false_tool_no_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model) @agent.tool_plain(strict=False) def calculate_distance(city_a: str, city_b: str) -> str: return f'Distance from {city_a} to {city_b}: 504 km' agent.run_sync('How far is Madrid from Lisbon?') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_strict_false_tool_native_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Tool with strict=False, NativeOutput → beta from native only + output_format.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_strict_false_tool_native_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=NativeOutput(CityInfo)) @agent.tool_plain(strict=False) def get_currency(country: str) -> str: return 'Mexican Peso (MXN)' if country == 'Mexico' else 'Unknown' result = agent.run_sync('Give me details about Mexico City') assert isinstance(result.output, CityInfo) if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_mixed_tools_no_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Mixed tools (one strict=True, one strict=None), no output_type → beta, only strict=True has strict field.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_mixed_tools_no_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model) @agent.tool_plain(strict=True) def get_weather(city: str) -> str: return f'Weather in {city}: Sunny, 22°C' @agent.tool_plain def get_elevation(city: str) -> str: return f'Elevation of {city}: 650m above sea level' agent.run_sync("What's the weather and elevation in Denver?") if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_mixed_tools_basemodel_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Mixed tools (one strict=True, one strict=None), BaseModel output_type → beta, only strict=True has strict field.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_mixed_tools_basemodel_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=CityInfo) @agent.tool_plain(strict=True) def get_population(city: str) -> int: return 8_900_000 if city == 'London' else 1_000_000 @agent.tool_plain def get_area(city: str) -> str: return f'Area of {city}: 1,572 km²' agent.run_sync('Tell me about London including population and area') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_mixed_tools_native_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Mixed tools (one strict=True, one strict=None), NativeOutput → beta, only strict=True has strict field + output_format.""" model = anthropic_model('claude-sonnet-4-5') hook = create_header_verification_hook(expect_beta=True, test_name='test_mixed_tools_native_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=NativeOutput(CityInfo)) @agent.tool_plain(strict=True) def lookup_country(city: str) -> str: return 'Japan' if city == 'Tokyo' else 'Unknown' @agent.tool_plain def get_founded_year(city: str) -> str: return '1457' if city == 'Tokyo' else 'Unknown' result = agent.run_sync('Give me complete details about Tokyo') assert isinstance(result.output, CityInfo) if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) # ============================================================================= # Unsupported Model Tests (claude-sonnet-4-0) # ============================================================================= @pytest.mark.vcr def test_unsupported_strict_true_tool_no_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Unsupported model: tool with strict=True, no output_type → no beta, no strict field.""" model = anthropic_model('claude-sonnet-4-0') hook = create_header_verification_hook(expect_beta=False, test_name='test_unsupported_strict_true_tool_no_output') model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model) @agent.tool_plain(strict=True) def get_weather(city: str) -> str: return f'Weather in {city}: Sunny, 18°C' agent.run_sync("What's the weather in Amsterdam?") if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) @pytest.mark.vcr def test_unsupported_strict_true_tool_basemodel_output( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Unsupported model: tool with strict=True, BaseModel output_type → no beta, no strict field.""" model = anthropic_model('claude-sonnet-4-0') hook = create_header_verification_hook( expect_beta=False, test_name='test_unsupported_strict_true_tool_basemodel_output' ) model.client._client.event_hooks['request'].append(hook) # pyright: ignore[reportPrivateUsage] agent = Agent(model, output_type=CityInfo) @agent.tool_plain(strict=True) def get_population(city: str) -> int: return 850_000 if city == 'Amsterdam' else 1_000_000 agent.run_sync('Get me details about Amsterdam including its population') if errors := hook.errors: # type: ignore[attr-defined] assert False, '\n'.join(sorted(errors)) def test_unsupported_native_output_raises( allow_model_requests: None, anthropic_model: ANTHROPIC_MODEL_FIXTURE, ) -> None: """Unsupported model: NativeOutput → raises UserError.""" model = anthropic_model('claude-sonnet-4-0') agent = Agent(model, output_type=NativeOutput(CityInfo)) with pytest.raises(UserError, match='Native structured output is not supported by this model.'): agent.run_sync('Tell me about Berlin')

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pydantic/pydantic-ai'

If you have feedback or need assistance with the MCP directory API, please join our Discord server