From b26f93b63e375dafb615b813bb5ba9cda37ef19d Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:09:01 -0500 Subject: [PATCH 01/14] adds native json output and strict tool call support for the anthropic model - addresses #3428 --- docs/models/anthropic.md | 28 +++ docs/output.md | 2 +- .../pydantic_ai/models/anthropic.py | 78 ++++++-- .../pydantic_ai/profiles/anthropic.py | 45 ++++- tests/models/test_anthropic.py | 167 +++++++++++++++++- 5 files changed, 298 insertions(+), 22 deletions(-) diff --git a/docs/models/anthropic.md b/docs/models/anthropic.md index 586084ace0..7da4e53456 100644 --- a/docs/models/anthropic.md +++ b/docs/models/anthropic.md @@ -144,3 +144,31 @@ async def main(): print(f'Cache write tokens: {usage.cache_write_tokens}') print(f'Cache read tokens: {usage.cache_read_tokens}') ``` + +## Structured outputs & strict tool calls + +Anthropic offers [Structured Outputs](https://docs.claude.com/en/docs/build-with-claude/structured-outputs), which force the model to emit JSON matching a supplied schema and to validate tool inputs before they reach your code. Pydantic AI enables this automatically: + +- When you use [`NativeOutput`][pydantic_ai.output.NativeOutput] (or set `output_mode='native'`), the agent sends Anthropic the JSON schema as an `output_format` payload and adds the required `structured-outputs-2025-11-13` beta header. The model's response will be validated just like on other providers, and `result.output` will contain your typed object. +- Tool definitions that are strict-compatible are sent with `strict: true`, which enables Anthropic's parameter validation. You can opt out for a specific tool by passing `strict=False` to `Tool(...)` or an agent decorator. + +```python {test="skip"} +from pydantic import BaseModel +from pydantic_ai import Agent, NativeOutput +from pydantic_ai.models.anthropic import AnthropicModel + + +class Contact(BaseModel): + name: str + email: str + + +model = AnthropicModel('claude-sonnet-4-5') +agent = Agent(model, output_type=NativeOutput(Contact)) +result = agent.run_sync('Extract the contact info for Ada Lovelace.') +print(result.output) +#> Contact(name='Ada Lovelace', email='ada@example.com') +``` + +!!! note + Pydantic AI automatically sets the beta headers, so you do not need to modify `model_settings.extra_headers`. If you also need to send custom `extra_body` fields, avoid supplying your own `output_format` as it will be generated for you. diff --git a/docs/output.md b/docs/output.md index 182a753944..bb6292c11b 100644 --- a/docs/output.md +++ b/docs/output.md @@ -308,7 +308,7 @@ _(This example is complete, it can be run "as is")_ #### Native Output -Native Output mode uses a model's native "Structured Outputs" feature (aka "JSON Schema response format"), where the model is forced to only output text matching the provided JSON schema. Note that this is not supported by all models, and sometimes comes with restrictions. For example, Anthropic does not support this at all, and Gemini cannot use tools at the same time as structured output, and attempting to do so will result in an error. +Native Output mode uses a model's native "Structured Outputs" feature (aka "JSON Schema response format"), where the model is forced to only output text matching the provided JSON schema. Note that this is not supported by all models, and sometimes comes with restrictions. For example, Anthropic requires enabling their Structured Outputs beta (Pydantic AI handles the required headers automatically), while Gemini cannot use tools at the same time as structured output, and attempting to do so will result in an error. To use this mode, you can wrap the output type(s) in the [`NativeOutput`][pydantic_ai.output.NativeOutput] marker class that also lets you specify a `name` and `description` if the name and docstring of the type or function are not sufficient. diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index c636ba9cfc..afffdcefa9 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -53,6 +53,9 @@ 'refusal': 'content_filter', } +# TODO: remove once anthropic moves it out of beta +_STRUCTURED_OUTPUTS_BETA = 'structured-outputs-2025-11-13' + try: from anthropic import NOT_GIVEN, APIStatusError, AsyncStream, omit as OMIT @@ -307,8 +310,10 @@ async def _messages_create( model_request_parameters: ModelRequestParameters, ) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]: # standalone function to make it easier to override - tools = self._get_tools(model_request_parameters, model_settings) + tools, strict_tools_requested = self._get_tools(model_request_parameters, model_settings) tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters) + output_format = self._build_output_format(model_request_parameters) + structured_output_beta_required = strict_tools_requested or bool(output_format) tool_choice: BetaToolChoiceParam | None @@ -328,10 +333,18 @@ async def _messages_create( try: extra_headers = model_settings.get('extra_headers', {}) extra_headers.setdefault('User-Agent', get_user_agent()) - if beta_features: - if 'anthropic-beta' in extra_headers: - beta_features.insert(0, extra_headers['anthropic-beta']) - extra_headers['anthropic-beta'] = ','.join(beta_features) + if beta_features or structured_output_beta_required: + new_features = list(beta_features) + if structured_output_beta_required: + new_features.append(_STRUCTURED_OUTPUTS_BETA) + extra_headers['anthropic-beta'] = self._format_beta_header( + extra_headers.get('anthropic-beta'), + new_features, + ) + + extra_body = cast(dict[str, Any] | None, model_settings.get('extra_body')) + if output_format is not None: + extra_body = self._merge_output_format_extra_body(extra_body, output_format) return await self.client.beta.messages.create( max_tokens=model_settings.get('max_tokens', 4096), @@ -349,7 +362,7 @@ async def _messages_create( timeout=model_settings.get('timeout', NOT_GIVEN), metadata=model_settings.get('anthropic_metadata', OMIT), extra_headers=extra_headers, - extra_body=model_settings.get('extra_body'), + extra_body=extra_body, ) except APIStatusError as e: if (status_code := e.status_code) >= 400: @@ -431,17 +444,20 @@ async def _process_streamed_response( def _get_tools( self, model_request_parameters: ModelRequestParameters, model_settings: AnthropicModelSettings - ) -> list[BetaToolUnionParam]: - tools: list[BetaToolUnionParam] = [ - self._map_tool_definition(r) for r in model_request_parameters.tool_defs.values() - ] + ) -> tuple[list[BetaToolUnionParam], bool]: + tools: list[BetaToolUnionParam] = [] + strict_tools_requested = False + for tool_def in model_request_parameters.tool_defs.values(): + tools.append(self._map_tool_definition(tool_def)) + if tool_def.strict: + strict_tools_requested = True # Add cache_control to the last tool if enabled if tools and model_settings.get('anthropic_cache_tool_definitions'): last_tool = tools[-1] last_tool['cache_control'] = BetaCacheControlEphemeralParam(type='ephemeral') - return tools + return tools, strict_tools_requested def _add_builtin_tools( self, tools: list[BetaToolUnionParam], model_request_parameters: ModelRequestParameters @@ -759,11 +775,49 @@ async def _map_user_prompt( @staticmethod def _map_tool_definition(f: ToolDefinition) -> BetaToolParam: - return { + tool_param: BetaToolParam = { 'name': f.name, 'description': f.description or '', 'input_schema': f.parameters_json_schema, } + if f.strict is not None: + tool_param['strict'] = f.strict # type: ignore[assignment] + return tool_param + + @staticmethod + def _build_output_format(model_request_parameters: ModelRequestParameters) -> dict[str, Any] | None: + if model_request_parameters.output_mode != 'native': + return None + output_object = model_request_parameters.output_object + if output_object is None: + return None + return {'type': 'json_schema', 'schema': output_object.json_schema} + + @staticmethod + def _merge_output_format_extra_body( + existing: dict[str, Any] | None, output_format: dict[str, Any] + ) -> dict[str, Any]: + merged = dict(existing or {}) + if 'output_format' in merged: + raise UserError( + '`model_settings.extra_body` cannot define `output_format` when using native structured output.' + ) + merged['output_format'] = output_format + return merged + + @staticmethod + def _format_beta_header(existing: str | None, new_features: list[str]) -> str: + values: list[str] = [] + if existing: + values.extend(value.strip() for value in existing.split(',') if value.strip()) + values.extend(new_features) + ordered: list[str] = [] + seen: set[str] = set() + for value in values: + if value not in seen: + ordered.append(value) + seen.add(value) + return ','.join(ordered) def _map_usage( diff --git a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py index f6a2755819..45ead844c8 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py @@ -1,8 +1,51 @@ from __future__ import annotations as _annotations +import importlib +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast + +from .._json_schema import JsonSchema, JsonSchemaTransformer from . import ModelProfile +TransformSchemaFunc = Callable[[Any], JsonSchema] + +try: # pragma: no cover + _anthropic_module = importlib.import_module('anthropic') +except Exception: + _anthropic_transform_schema: TransformSchemaFunc | None = None +else: + _anthropic_transform_schema = cast(TransformSchemaFunc | None, getattr(_anthropic_module, 'transform_schema', None)) + + +@dataclass(init=False) +class AnthropicJsonSchemaTransformer(JsonSchemaTransformer): + """Transforms schemas to the subset supported by Anthropic structured outputs.""" + + def walk(self) -> JsonSchema: + schema = super().walk() + helper = _anthropic_transform_schema + if helper is None: + return schema + try: # pragma: no branch + # helper may raise if schema already transformed + transformed = helper(schema) + except Exception: + return schema + if isinstance(transformed, dict): + return transformed + return schema + + def transform(self, schema: JsonSchema) -> JsonSchema: + schema.pop('title', None) + schema.pop('$schema', None) + return schema + def anthropic_model_profile(model_name: str) -> ModelProfile | None: """Get the model profile for an Anthropic model.""" - return ModelProfile(thinking_tags=('', '')) + return ModelProfile( + thinking_tags=('', ''), + supports_json_schema_output=True, + json_schema_transformer=AnthropicJsonSchemaTransformer, + ) diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 1170483879..ea00e120c7 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -5318,17 +5318,168 @@ class CountryLanguage(BaseModel): ) -async def test_anthropic_native_output(allow_model_requests: None, anthropic_api_key: str): - m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(api_key=anthropic_api_key)) +async def test_anthropic_native_output_uses_output_format(allow_model_requests: None): + response = completion_message( + [BetaTextBlock(text='{"name": "John Doe", "email": "john@example.com"}', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(response) + m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + + class ContactInfo(BaseModel): + name: str + email: str + + agent = Agent(m, output_type=NativeOutput(ContactInfo)) + + result = await agent.run('Extract the contact info for John Doe.') + assert result.output == snapshot(ContactInfo(name='John Doe', email='john@example.com')) + + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + assert completion_kwargs['extra_body'] == snapshot( + { + 'output_format': { + 'schema': { + 'properties': { + 'email': {'type': 'string'}, + 'name': {'type': 'string'}, + }, + 'required': ['name', 'email'], + 'type': 'object', + }, + 'type': 'json_schema', + } + } + ) + assert completion_kwargs['extra_headers']['anthropic-beta'] == 'structured-outputs-2025-11-13' + + +async def test_anthropic_native_output_merges_extra_body(allow_model_requests: None): + response = completion_message( + [BetaTextBlock(text='{"name": "Ada Lovelace", "email": "ada@example.com"}', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(response) + m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + + class ContactInfo(BaseModel): + name: str + email: str + + agent = Agent(m, output_type=NativeOutput(ContactInfo)) + + await agent.run( + 'Extract the contact info for Ada.', + model_settings=ModelSettings(extra_body={'metadata': {'request_id': 'abc-123'}}), + ) + + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + assert completion_kwargs['extra_body'] == snapshot( + { + 'metadata': {'request_id': 'abc-123'}, + 'output_format': { + 'schema': { + 'properties': { + 'email': {'type': 'string'}, + 'name': {'type': 'string'}, + }, + 'required': ['name', 'email'], + 'type': 'object', + }, + 'type': 'json_schema', + }, + } + ) + + +async def test_anthropic_native_output_conflicting_extra_body(allow_model_requests: None): + response = completion_message( + [BetaTextBlock(text='{"name": "Grace", "email": "grace@example.com"}', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(response) + m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + + class ContactInfo(BaseModel): + name: str + email: str + + agent = Agent(m, output_type=NativeOutput(ContactInfo)) + + with pytest.raises( + UserError, + match='`model_settings.extra_body` cannot define `output_format` when using native structured output.', + ): + await agent.run( + 'Extract the contact info for Grace.', + model_settings=ModelSettings(extra_body={'output_format': {'type': 'json_schema'}}), + ) + + +async def test_anthropic_tool_strict_sets_beta_header(allow_model_requests: None): + response = completion_message( + [BetaTextBlock(text='No tool call needed.', type='text')], BetaUsage(input_tokens=5, output_tokens=5) + ) + mock_client = MockAnthropic.create_mock(response) + m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + agent = Agent(m) + + @agent.tool_plain + async def get_weather(city: str) -> str: + """Return mock weather data.""" + return f'{city}: sunny' + + await agent.run('What is the weather in Paris?') + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + assert completion_kwargs['tools'][0]['strict'] is True + assert completion_kwargs['extra_headers']['anthropic-beta'] == 'structured-outputs-2025-11-13' - class CityLocation(BaseModel): - city: str - country: str - agent = Agent(m, output_type=NativeOutput(CityLocation)) +async def test_anthropic_beta_header_merges_existing_value(allow_model_requests: None): + response = completion_message( + [BetaTextBlock(text='{"name": "Ben", "email": "ben@example.com"}', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(response) + m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + + class ContactInfo(BaseModel): + name: str + email: str + + agent = Agent(m, output_type=NativeOutput(ContactInfo)) + await agent.run( + 'Extract contact info.', + model_settings=ModelSettings(extra_headers={'anthropic-beta': 'prompt-caching-2024-10-22'}), + ) + + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + assert ( + completion_kwargs['extra_headers']['anthropic-beta'] + == 'prompt-caching-2024-10-22,structured-outputs-2025-11-13' + ) - with pytest.raises(UserError, match='Native structured output is not supported by this model.'): - await agent.run('What is the largest city in the user country?') + +async def test_anthropic_beta_header_deduplicates(allow_model_requests: None): + response = completion_message( + [BetaTextBlock(text='{"name": "Ivy", "email": "ivy@example.com"}', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(response) + m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + + class ContactInfo(BaseModel): + name: str + email: str + + agent = Agent(m, output_type=NativeOutput(ContactInfo)) + await agent.run( + 'Extract contact info.', + model_settings=ModelSettings(extra_headers={'anthropic-beta': 'structured-outputs-2025-11-13'}), + ) + + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + assert completion_kwargs['extra_headers']['anthropic-beta'] == 'structured-outputs-2025-11-13' async def test_anthropic_output_tool_with_thinking(allow_model_requests: None, anthropic_api_key: str): From 04a9b3b1900b1661965997e2a0248c38445024de Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:37:18 -0500 Subject: [PATCH 02/14] update tests to use new transformer --- docs/models/anthropic.md | 1 + tests/models/test_anthropic.py | 3 +++ tests/providers/test_openrouter.py | 4 ++-- tests/providers/test_vercel.py | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/models/anthropic.md b/docs/models/anthropic.md index 7da4e53456..795857f6f9 100644 --- a/docs/models/anthropic.md +++ b/docs/models/anthropic.md @@ -154,6 +154,7 @@ Anthropic offers [Structured Outputs](https://docs.claude.com/en/docs/build-with ```python {test="skip"} from pydantic import BaseModel + from pydantic_ai import Agent, NativeOutput from pydantic_ai.models.anthropic import AnthropicModel diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index ea00e120c7..282b5d9217 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -461,11 +461,13 @@ def tool_two() -> str: # pragma: no cover 'name': 'tool_one', 'description': '', 'input_schema': {'additionalProperties': False, 'properties': {}, 'type': 'object'}, + 'strict': True, }, { 'name': 'tool_two', 'description': '', 'input_schema': {'additionalProperties': False, 'properties': {}, 'type': 'object'}, + 'strict': True, 'cache_control': {'type': 'ephemeral'}, }, ] @@ -540,6 +542,7 @@ def my_tool(value: str) -> str: # pragma: no cover 'required': ['value'], 'type': 'object', }, + 'strict': True, 'cache_control': {'type': 'ephemeral'}, } ] diff --git a/tests/providers/test_openrouter.py b/tests/providers/test_openrouter.py index acdf166c50..68e760ef92 100644 --- a/tests/providers/test_openrouter.py +++ b/tests/providers/test_openrouter.py @@ -9,7 +9,7 @@ from pydantic_ai.agent import Agent from pydantic_ai.exceptions import UserError from pydantic_ai.profiles.amazon import amazon_model_profile -from pydantic_ai.profiles.anthropic import anthropic_model_profile +from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer, anthropic_model_profile from pydantic_ai.profiles.cohere import cohere_model_profile from pydantic_ai.profiles.deepseek import deepseek_model_profile from pydantic_ai.profiles.google import GoogleJsonSchemaTransformer, google_model_profile @@ -114,7 +114,7 @@ def test_openrouter_provider_model_profile(mocker: MockerFixture): anthropic_profile = provider.model_profile('anthropic/claude-3.5-sonnet') anthropic_model_profile_mock.assert_called_with('claude-3.5-sonnet') assert anthropic_profile is not None - assert anthropic_profile.json_schema_transformer == OpenAIJsonSchemaTransformer + assert anthropic_profile.json_schema_transformer == AnthropicJsonSchemaTransformer mistral_profile = provider.model_profile('mistralai/mistral-large-2407') mistral_model_profile_mock.assert_called_with('mistral-large-2407') diff --git a/tests/providers/test_vercel.py b/tests/providers/test_vercel.py index 91c26f9c8c..3e60456cdb 100644 --- a/tests/providers/test_vercel.py +++ b/tests/providers/test_vercel.py @@ -7,7 +7,7 @@ from pydantic_ai._json_schema import InlineDefsJsonSchemaTransformer from pydantic_ai.exceptions import UserError from pydantic_ai.profiles.amazon import amazon_model_profile -from pydantic_ai.profiles.anthropic import anthropic_model_profile +from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer, anthropic_model_profile from pydantic_ai.profiles.cohere import cohere_model_profile from pydantic_ai.profiles.deepseek import deepseek_model_profile from pydantic_ai.profiles.google import GoogleJsonSchemaTransformer, google_model_profile @@ -82,7 +82,7 @@ def test_vercel_provider_model_profile(mocker: MockerFixture): profile = provider.model_profile('anthropic/claude-sonnet-4-5') anthropic_mock.assert_called_with('claude-sonnet-4-5') assert profile is not None - assert profile.json_schema_transformer == OpenAIJsonSchemaTransformer + assert profile.json_schema_transformer == AnthropicJsonSchemaTransformer # Test bedrock provider profile = provider.model_profile('bedrock/anthropic.claude-sonnet-4-5') From b5243d0366d308682b566f6736a4c347dee4c6af Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:39:51 -0500 Subject: [PATCH 03/14] restore docs and bump anthropic sdk and simplify model - add new test with cassette --- docs/models/anthropic.md | 29 -- docs/output.md | 2 +- .../pydantic_ai/models/__init__.py | 3 - .../pydantic_ai/models/anthropic.py | 61 +--- .../pydantic_ai/profiles/anthropic.py | 26 +- pydantic_ai_slim/pyproject.toml | 2 +- .../test_anthropic_mixed_strict_tool_run.yaml | 296 ++++++++++++++++++ tests/models/test_anthropic.py | 198 ++---------- uv.lock | 16 +- 9 files changed, 361 insertions(+), 272 deletions(-) create mode 100644 tests/models/cassettes/test_anthropic/test_anthropic_mixed_strict_tool_run.yaml diff --git a/docs/models/anthropic.md b/docs/models/anthropic.md index 795857f6f9..586084ace0 100644 --- a/docs/models/anthropic.md +++ b/docs/models/anthropic.md @@ -144,32 +144,3 @@ async def main(): print(f'Cache write tokens: {usage.cache_write_tokens}') print(f'Cache read tokens: {usage.cache_read_tokens}') ``` - -## Structured outputs & strict tool calls - -Anthropic offers [Structured Outputs](https://docs.claude.com/en/docs/build-with-claude/structured-outputs), which force the model to emit JSON matching a supplied schema and to validate tool inputs before they reach your code. Pydantic AI enables this automatically: - -- When you use [`NativeOutput`][pydantic_ai.output.NativeOutput] (or set `output_mode='native'`), the agent sends Anthropic the JSON schema as an `output_format` payload and adds the required `structured-outputs-2025-11-13` beta header. The model's response will be validated just like on other providers, and `result.output` will contain your typed object. -- Tool definitions that are strict-compatible are sent with `strict: true`, which enables Anthropic's parameter validation. You can opt out for a specific tool by passing `strict=False` to `Tool(...)` or an agent decorator. - -```python {test="skip"} -from pydantic import BaseModel - -from pydantic_ai import Agent, NativeOutput -from pydantic_ai.models.anthropic import AnthropicModel - - -class Contact(BaseModel): - name: str - email: str - - -model = AnthropicModel('claude-sonnet-4-5') -agent = Agent(model, output_type=NativeOutput(Contact)) -result = agent.run_sync('Extract the contact info for Ada Lovelace.') -print(result.output) -#> Contact(name='Ada Lovelace', email='ada@example.com') -``` - -!!! note - Pydantic AI automatically sets the beta headers, so you do not need to modify `model_settings.extra_headers`. If you also need to send custom `extra_body` fields, avoid supplying your own `output_format` as it will be generated for you. diff --git a/docs/output.md b/docs/output.md index bb6292c11b..99a2a59d45 100644 --- a/docs/output.md +++ b/docs/output.md @@ -308,7 +308,7 @@ _(This example is complete, it can be run "as is")_ #### Native Output -Native Output mode uses a model's native "Structured Outputs" feature (aka "JSON Schema response format"), where the model is forced to only output text matching the provided JSON schema. Note that this is not supported by all models, and sometimes comes with restrictions. For example, Anthropic requires enabling their Structured Outputs beta (Pydantic AI handles the required headers automatically), while Gemini cannot use tools at the same time as structured output, and attempting to do so will result in an error. +Native Output mode uses a model's native "Structured Outputs" feature (aka "JSON Schema response format"), where the model is forced to only output text matching the provided JSON schema. Note that this is not supported by all models, and sometimes comes with restrictions. For example, Gemini cannot use tools at the same time as structured output, and attempting to do so will result in an error. To use this mode, you can wrap the output type(s) in the [`NativeOutput`][pydantic_ai.output.NativeOutput] marker class that also lets you specify a `name` and `description` if the name and docstring of the type or function are not sufficient. diff --git a/pydantic_ai_slim/pydantic_ai/models/__init__.py b/pydantic_ai_slim/pydantic_ai/models/__init__.py index 98214910bd..427beaab6d 100644 --- a/pydantic_ai_slim/pydantic_ai/models/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/models/__init__.py @@ -57,9 +57,6 @@ Literal[ 'anthropic:claude-3-5-haiku-20241022', 'anthropic:claude-3-5-haiku-latest', - 'anthropic:claude-3-5-sonnet-20240620', - 'anthropic:claude-3-5-sonnet-20241022', - 'anthropic:claude-3-5-sonnet-latest', 'anthropic:claude-3-7-sonnet-20250219', 'anthropic:claude-3-7-sonnet-latest', 'anthropic:claude-3-haiku-20240307', diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index afffdcefa9..0890fc919d 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -53,9 +53,6 @@ 'refusal': 'content_filter', } -# TODO: remove once anthropic moves it out of beta -_STRUCTURED_OUTPUTS_BETA = 'structured-outputs-2025-11-13' - try: from anthropic import NOT_GIVEN, APIStatusError, AsyncStream, omit as OMIT @@ -73,6 +70,7 @@ BetaContentBlockParam, BetaImageBlockParam, BetaInputJSONDelta, + BetaJSONOutputFormatParam, BetaMCPToolResultBlock, BetaMCPToolUseBlock, BetaMCPToolUseBlockParam, @@ -313,7 +311,6 @@ async def _messages_create( tools, strict_tools_requested = self._get_tools(model_request_parameters, model_settings) tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters) output_format = self._build_output_format(model_request_parameters) - structured_output_beta_required = strict_tools_requested or bool(output_format) tool_choice: BetaToolChoiceParam | None @@ -330,21 +327,14 @@ async def _messages_create( system_prompt, anthropic_messages = await self._map_message(messages, model_request_parameters, model_settings) + # Build betas list for SDK + betas: list[str] = list(beta_features) + if strict_tools_requested or output_format: + betas.append('structured-outputs-2025-11-13') + try: extra_headers = model_settings.get('extra_headers', {}) extra_headers.setdefault('User-Agent', get_user_agent()) - if beta_features or structured_output_beta_required: - new_features = list(beta_features) - if structured_output_beta_required: - new_features.append(_STRUCTURED_OUTPUTS_BETA) - extra_headers['anthropic-beta'] = self._format_beta_header( - extra_headers.get('anthropic-beta'), - new_features, - ) - - extra_body = cast(dict[str, Any] | None, model_settings.get('extra_body')) - if output_format is not None: - extra_body = self._merge_output_format_extra_body(extra_body, output_format) return await self.client.beta.messages.create( max_tokens=model_settings.get('max_tokens', 4096), @@ -354,6 +344,8 @@ async def _messages_create( tools=tools or OMIT, tool_choice=tool_choice or OMIT, mcp_servers=mcp_servers or OMIT, + output_format=output_format or OMIT, + betas=betas or OMIT, stream=stream, thinking=model_settings.get('anthropic_thinking', OMIT), stop_sequences=model_settings.get('stop_sequences', OMIT), @@ -362,7 +354,7 @@ async def _messages_create( timeout=model_settings.get('timeout', NOT_GIVEN), metadata=model_settings.get('anthropic_metadata', OMIT), extra_headers=extra_headers, - extra_body=extra_body, + extra_body=model_settings.get('extra_body'), ) except APIStatusError as e: if (status_code := e.status_code) >= 400: @@ -780,45 +772,18 @@ def _map_tool_definition(f: ToolDefinition) -> BetaToolParam: 'description': f.description or '', 'input_schema': f.parameters_json_schema, } - if f.strict is not None: - tool_param['strict'] = f.strict # type: ignore[assignment] + if f.strict: + tool_param['strict'] = f.strict return tool_param @staticmethod - def _build_output_format(model_request_parameters: ModelRequestParameters) -> dict[str, Any] | None: + def _build_output_format(model_request_parameters: ModelRequestParameters) -> BetaJSONOutputFormatParam | None: if model_request_parameters.output_mode != 'native': return None output_object = model_request_parameters.output_object - if output_object is None: - return None + assert output_object is not None return {'type': 'json_schema', 'schema': output_object.json_schema} - @staticmethod - def _merge_output_format_extra_body( - existing: dict[str, Any] | None, output_format: dict[str, Any] - ) -> dict[str, Any]: - merged = dict(existing or {}) - if 'output_format' in merged: - raise UserError( - '`model_settings.extra_body` cannot define `output_format` when using native structured output.' - ) - merged['output_format'] = output_format - return merged - - @staticmethod - def _format_beta_header(existing: str | None, new_features: list[str]) -> str: - values: list[str] = [] - if existing: - values.extend(value.strip() for value in existing.split(',') if value.strip()) - values.extend(new_features) - ordered: list[str] = [] - seen: set[str] = set() - for value in values: - if value not in seen: - ordered.append(value) - seen.add(value) - return ','.join(ordered) - def _map_usage( message: BetaMessage | BetaRawMessageStartEvent | BetaRawMessageDeltaEvent, diff --git a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py index 45ead844c8..6544f75f16 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py @@ -1,42 +1,28 @@ from __future__ import annotations as _annotations -import importlib from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any from .._json_schema import JsonSchema, JsonSchemaTransformer from . import ModelProfile TransformSchemaFunc = Callable[[Any], JsonSchema] -try: # pragma: no cover - _anthropic_module = importlib.import_module('anthropic') -except Exception: - _anthropic_transform_schema: TransformSchemaFunc | None = None -else: - _anthropic_transform_schema = cast(TransformSchemaFunc | None, getattr(_anthropic_module, 'transform_schema', None)) - @dataclass(init=False) class AnthropicJsonSchemaTransformer(JsonSchemaTransformer): """Transforms schemas to the subset supported by Anthropic structured outputs.""" def walk(self) -> JsonSchema: + from anthropic import transform_schema + schema = super().walk() - helper = _anthropic_transform_schema - if helper is None: - return schema - try: # pragma: no branch - # helper may raise if schema already transformed - transformed = helper(schema) - except Exception: - return schema - if isinstance(transformed, dict): - return transformed - return schema + transformed = transform_schema(schema) + return transformed def transform(self, schema: JsonSchema) -> JsonSchema: + # for consistency with other transformers (openai,google) schema.pop('title', None) schema.pop('$schema', None) return schema diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 1b5909140d..e9d0d2c15c 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -71,7 +71,7 @@ openai = ["openai>=1.107.2"] cohere = ["cohere>=5.18.0; platform_system != 'Emscripten'"] vertexai = ["google-auth>=2.36.0", "requests>=2.32.2"] google = ["google-genai>=1.50.1"] -anthropic = ["anthropic>=0.70.0"] +anthropic = ["anthropic>=0.74.0"] groq = ["groq>=0.25.0"] mistral = ["mistralai>=1.9.10"] bedrock = ["boto3>=1.40.14"] diff --git a/tests/models/cassettes/test_anthropic/test_anthropic_mixed_strict_tool_run.yaml b/tests/models/cassettes/test_anthropic/test_anthropic_mixed_strict_tool_run.yaml new file mode 100644 index 0000000000..676818f8fc --- /dev/null +++ b/tests/models/cassettes/test_anthropic/test_anthropic_mixed_strict_tool_run.yaml @@ -0,0 +1,296 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '649' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: 'Use the registered tools and respond exactly as `Capital: `.' + type: text + role: user + model: claude-sonnet-4-5 + stream: false + system: Always call `country_source` first, then call `capital_lookup` with that result before replying. + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: country_source + strict: true + - description: '' + input_schema: + additionalProperties: false + properties: + country: + type: string + required: + - country + type: object + name: capital_lookup + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '562' + content-type: + - application/json + retry-after: + - '4' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - text: I'll help you find the capital city using the available tools. + type: text + - id: toolu_01Ttepb9joVoQFHP568v7UAL + input: {} + name: country_source + type: tool_use + id: msg_01CTV3rhAAYCrzRGTEoJbJt7 + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: tool_use + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 628 + output_tokens: 50 + service_tier: standard + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '996' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: 'Use the registered tools and respond exactly as `Capital: `.' + type: text + role: user + - content: + - text: I'll help you find the capital city using the available tools. + type: text + - id: toolu_01Ttepb9joVoQFHP568v7UAL + input: {} + name: country_source + type: tool_use + role: assistant + - content: + - content: Japan + is_error: false + tool_use_id: toolu_01Ttepb9joVoQFHP568v7UAL + type: tool_result + role: user + model: claude-sonnet-4-5 + stream: false + system: Always call `country_source` first, then call `capital_lookup` with that result before replying. + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: country_source + strict: true + - description: '' + input_schema: + additionalProperties: false + properties: + country: + type: string + required: + - country + type: object + name: capital_lookup + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '491' + content-type: + - application/json + retry-after: + - '52' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - id: toolu_011j5uC2Tg3TZJo3nmLtJ8Mm + input: + country: Japan + name: capital_lookup + type: tool_use + id: msg_01KgnnRwGgZEK3kvEGM5nbW8 + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: tool_use + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 691 + output_tokens: 53 + service_tier: standard + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '1272' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: 'Use the registered tools and respond exactly as `Capital: `.' + type: text + role: user + - content: + - text: I'll help you find the capital city using the available tools. + type: text + - id: toolu_01Ttepb9joVoQFHP568v7UAL + input: {} + name: country_source + type: tool_use + role: assistant + - content: + - content: Japan + is_error: false + tool_use_id: toolu_01Ttepb9joVoQFHP568v7UAL + type: tool_result + role: user + - content: + - id: toolu_011j5uC2Tg3TZJo3nmLtJ8Mm + input: + country: Japan + name: capital_lookup + type: tool_use + role: assistant + - content: + - content: Tokyo + is_error: false + tool_use_id: toolu_011j5uC2Tg3TZJo3nmLtJ8Mm + type: tool_result + role: user + model: claude-sonnet-4-5 + stream: false + system: Always call `country_source` first, then call `capital_lookup` with that result before replying. + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: country_source + strict: true + - description: '' + input_schema: + additionalProperties: false + properties: + country: + type: string + required: + - country + type: object + name: capital_lookup + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '420' + content-type: + - application/json + retry-after: + - '36' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - text: 'Capital: Tokyo' + type: text + id: msg_0111CmwjQHh6LerTTnrW2GPi + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 757 + output_tokens: 6 + service_tier: standard + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 282b5d9217..60d3fe0183 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -50,7 +50,7 @@ BuiltinToolResultEvent, # pyright: ignore[reportDeprecated] ) from pydantic_ai.models import ModelRequestParameters -from pydantic_ai.output import NativeOutput, PromptedOutput, TextOutput, ToolOutput +from pydantic_ai.output import PromptedOutput, TextOutput, ToolOutput from pydantic_ai.result import RunUsage from pydantic_ai.settings import ModelSettings from pydantic_ai.usage import RequestUsage @@ -552,6 +552,38 @@ def my_tool(value: str) -> str: # pragma: no cover ) +async def test_anthropic_mixed_strict_tool_run(allow_model_requests: None, anthropic_api_key: str): + """Exercise both strict=True and strict=False tool definitions against the live API.""" + m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(api_key=anthropic_api_key)) + agent = Agent( + m, + system_prompt='Always call `country_source` first, then call `capital_lookup` with that result before replying.', + ) + + @agent.tool_plain(strict=True) + async def country_source() -> str: + return 'Japan' + + capital_called = {'value': False} + + @agent.tool_plain(strict=False) + async def capital_lookup(country: str) -> str: + capital_called['value'] = True + if country == 'Japan': + return 'Tokyo' + return f'Unknown capital for {country}' + + result = await agent.run('Use the registered tools and respond exactly as `Capital: `.') + assert capital_called['value'] is True + assert result.output.startswith('Capital:') + assert any( + isinstance(part, ToolCallPart) and part.tool_name == 'capital_lookup' + for message in result.all_messages() + if isinstance(message, ModelResponse) + for part in message.parts + ) + + async def test_async_request_text_response(allow_model_requests: None): c = completion_message( [BetaTextBlock(text='world', type='text')], @@ -5321,170 +5353,6 @@ class CountryLanguage(BaseModel): ) -async def test_anthropic_native_output_uses_output_format(allow_model_requests: None): - response = completion_message( - [BetaTextBlock(text='{"name": "John Doe", "email": "john@example.com"}', type='text')], - BetaUsage(input_tokens=5, output_tokens=10), - ) - mock_client = MockAnthropic.create_mock(response) - m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) - - class ContactInfo(BaseModel): - name: str - email: str - - agent = Agent(m, output_type=NativeOutput(ContactInfo)) - - result = await agent.run('Extract the contact info for John Doe.') - assert result.output == snapshot(ContactInfo(name='John Doe', email='john@example.com')) - - completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] - assert completion_kwargs['extra_body'] == snapshot( - { - 'output_format': { - 'schema': { - 'properties': { - 'email': {'type': 'string'}, - 'name': {'type': 'string'}, - }, - 'required': ['name', 'email'], - 'type': 'object', - }, - 'type': 'json_schema', - } - } - ) - assert completion_kwargs['extra_headers']['anthropic-beta'] == 'structured-outputs-2025-11-13' - - -async def test_anthropic_native_output_merges_extra_body(allow_model_requests: None): - response = completion_message( - [BetaTextBlock(text='{"name": "Ada Lovelace", "email": "ada@example.com"}', type='text')], - BetaUsage(input_tokens=5, output_tokens=10), - ) - mock_client = MockAnthropic.create_mock(response) - m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) - - class ContactInfo(BaseModel): - name: str - email: str - - agent = Agent(m, output_type=NativeOutput(ContactInfo)) - - await agent.run( - 'Extract the contact info for Ada.', - model_settings=ModelSettings(extra_body={'metadata': {'request_id': 'abc-123'}}), - ) - - completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] - assert completion_kwargs['extra_body'] == snapshot( - { - 'metadata': {'request_id': 'abc-123'}, - 'output_format': { - 'schema': { - 'properties': { - 'email': {'type': 'string'}, - 'name': {'type': 'string'}, - }, - 'required': ['name', 'email'], - 'type': 'object', - }, - 'type': 'json_schema', - }, - } - ) - - -async def test_anthropic_native_output_conflicting_extra_body(allow_model_requests: None): - response = completion_message( - [BetaTextBlock(text='{"name": "Grace", "email": "grace@example.com"}', type='text')], - BetaUsage(input_tokens=5, output_tokens=10), - ) - mock_client = MockAnthropic.create_mock(response) - m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) - - class ContactInfo(BaseModel): - name: str - email: str - - agent = Agent(m, output_type=NativeOutput(ContactInfo)) - - with pytest.raises( - UserError, - match='`model_settings.extra_body` cannot define `output_format` when using native structured output.', - ): - await agent.run( - 'Extract the contact info for Grace.', - model_settings=ModelSettings(extra_body={'output_format': {'type': 'json_schema'}}), - ) - - -async def test_anthropic_tool_strict_sets_beta_header(allow_model_requests: None): - response = completion_message( - [BetaTextBlock(text='No tool call needed.', type='text')], BetaUsage(input_tokens=5, output_tokens=5) - ) - mock_client = MockAnthropic.create_mock(response) - m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) - agent = Agent(m) - - @agent.tool_plain - async def get_weather(city: str) -> str: - """Return mock weather data.""" - return f'{city}: sunny' - - await agent.run('What is the weather in Paris?') - completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] - assert completion_kwargs['tools'][0]['strict'] is True - assert completion_kwargs['extra_headers']['anthropic-beta'] == 'structured-outputs-2025-11-13' - - -async def test_anthropic_beta_header_merges_existing_value(allow_model_requests: None): - response = completion_message( - [BetaTextBlock(text='{"name": "Ben", "email": "ben@example.com"}', type='text')], - BetaUsage(input_tokens=5, output_tokens=10), - ) - mock_client = MockAnthropic.create_mock(response) - m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) - - class ContactInfo(BaseModel): - name: str - email: str - - agent = Agent(m, output_type=NativeOutput(ContactInfo)) - await agent.run( - 'Extract contact info.', - model_settings=ModelSettings(extra_headers={'anthropic-beta': 'prompt-caching-2024-10-22'}), - ) - - completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] - assert ( - completion_kwargs['extra_headers']['anthropic-beta'] - == 'prompt-caching-2024-10-22,structured-outputs-2025-11-13' - ) - - -async def test_anthropic_beta_header_deduplicates(allow_model_requests: None): - response = completion_message( - [BetaTextBlock(text='{"name": "Ivy", "email": "ivy@example.com"}', type='text')], - BetaUsage(input_tokens=5, output_tokens=10), - ) - mock_client = MockAnthropic.create_mock(response) - m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) - - class ContactInfo(BaseModel): - name: str - email: str - - agent = Agent(m, output_type=NativeOutput(ContactInfo)) - await agent.run( - 'Extract contact info.', - model_settings=ModelSettings(extra_headers={'anthropic-beta': 'structured-outputs-2025-11-13'}), - ) - - completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] - assert completion_kwargs['extra_headers']['anthropic-beta'] == 'structured-outputs-2025-11-13' - - async def test_anthropic_output_tool_with_thinking(allow_model_requests: None, anthropic_api_key: str): m = AnthropicModel( 'claude-sonnet-4-0', diff --git a/uv.lock b/uv.lock index 62d3f22778..0630f2c998 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -221,7 +221,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.70.0" +version = "0.74.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -233,9 +233,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/be/a80a8678d39d77b2325b1a32a55d62ca9dc376984a3d66d351229d37da9c/anthropic-0.70.0.tar.gz", hash = "sha256:24078275246636d9fd38c94bb8cf64799ce7fc6bbad379422b36fa86b3e4deee", size = 480930, upload-time = "2025-10-15T16:54:33.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f9/baa1b885c8664b446e6a13003938046901e54ffd70b532bbebd01256e34b/anthropic-0.74.0.tar.gz", hash = "sha256:114ec10cb394b6764e199da06335da4747b019c5629e53add33572f66964ad99", size = 428958, upload-time = "2025-11-18T15:29:47.579Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/81/da287ba25b9f8a16d27e822b3f2dad6ddf005fba3e3696f5dce818383850/anthropic-0.70.0-py3-none-any.whl", hash = "sha256:fa7d0dee6f2b871faa7cd0b77f6047e8006d5863618804204cf34b1b95819971", size = 337327, upload-time = "2025-10-15T16:54:32.087Z" }, + { url = "https://files.pythonhosted.org/packages/61/27/8c404b290ec650e634eacc674df943913722ec21097b0476d68458250c2f/anthropic-0.74.0-py3-none-any.whl", hash = "sha256:df29b8dfcdbd2751fa31177f643d8d8f66c5315fe06bdc42f9139e9f00d181d5", size = 371474, upload-time = "2025-11-18T15:29:45.748Z" }, ] [[package]] @@ -2763,6 +2763,7 @@ version = "0.7.30" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/38/d1ef3ae08d8d857e5e0690c5b1e07bf7eb4a1cae5881d87215826dc6cadb/llguidance-0.7.30.tar.gz", hash = "sha256:e93bf75f2b6e48afb86a5cee23038746975e1654672bf5ba0ae75f7d4d4a2248", size = 1055528, upload-time = "2025-06-23T00:23:49.247Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/e1/694c89986fcae7777184fc8b22baa0976eba15a6847221763f6ad211fc1f/llguidance-0.7.30-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c80af02c118d2b0526bcecaab389af2ed094537a069b0fc724cd2a2f2ba3990f", size = 3327974, upload-time = "2025-06-23T00:23:47.556Z" }, { url = "https://files.pythonhosted.org/packages/fd/77/ab7a548ae189dc23900fdd37803c115c2339b1223af9e8eb1f4329b5935a/llguidance-0.7.30-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:00a256d532911d2cf5ba4ef63e182944e767dd2402f38d63002016bc37755958", size = 3210709, upload-time = "2025-06-23T00:23:45.872Z" }, { url = "https://files.pythonhosted.org/packages/9c/5b/6a166564b14f9f805f0ea01ec233a84f55789cb7eeffe1d6224ccd0e6cdd/llguidance-0.7.30-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af8741c867e4bc7e42f7cdc68350c076b4edd0ca10ecefbde75f15a9f6bc25d0", size = 14867038, upload-time = "2025-06-23T00:23:39.571Z" }, { url = "https://files.pythonhosted.org/packages/af/80/5a40b9689f17612434b820854cba9b8cabd5142072c491b5280fe5f7a35e/llguidance-0.7.30-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9edc409b9decd6cffba5f5bf3b4fbd7541f95daa8cbc9510cbf96c6ab1ffc153", size = 15004926, upload-time = "2025-06-23T00:23:43.965Z" }, @@ -5659,7 +5660,7 @@ vertexai = [ [package.metadata] requires-dist = [ { name = "ag-ui-protocol", marker = "extra == 'ag-ui'", specifier = ">=0.1.8" }, - { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.70.0" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.74.0" }, { name = "argcomplete", marker = "extra == 'cli'", specifier = ">=3.5.0" }, { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.40.14" }, { name = "cohere", marker = "sys_platform != 'emscripten' and extra == 'cohere'", specifier = ">=5.18.0" }, @@ -6742,6 +6743,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/fa/3234f913fe9a6525a7b97c6dad1f51e72b917e6872e051a5e2ffd8b16fbb/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:70eda7703b8126f5e52fcf276e6c0f40b0d314674f896fc58c47b0aef2b9ae83", size = 137970, upload-time = "2025-09-22T19:51:09.472Z" }, { url = "https://files.pythonhosted.org/packages/ef/ec/4edbf17ac2c87fa0845dd366ef8d5852b96eb58fcd65fc1ecf5fe27b4641/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a0cb71ccc6ef9ce36eecb6272c81afdc2f565950cdcec33ae8e6cd8f7fc86f27", size = 739639, upload-time = "2025-09-22T19:51:10.566Z" }, { url = "https://files.pythonhosted.org/packages/15/18/b0e1fafe59051de9e79cdd431863b03593ecfa8341c110affad7c8121efc/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7cb9ad1d525d40f7d87b6df7c0ff916a66bc52cb61b66ac1b2a16d0c1b07640", size = 764456, upload-time = "2025-09-22T19:51:11.736Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cd/150fdb96b8fab27fe08d8a59fe67554568727981806e6bc2677a16081ec7/ruamel_yaml_clib-0.2.14-cp314-cp314-win32.whl", hash = "sha256:9b4104bf43ca0cd4e6f738cb86326a3b2f6eef00f417bd1e7efb7bdffe74c539", size = 102394, upload-time = "2025-11-14T21:57:36.703Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/a3fa40084558c7e1dc9546385f22a93949c890a8b2e445b2ba43935f51da/ruamel_yaml_clib-0.2.14-cp314-cp314-win_amd64.whl", hash = "sha256:13997d7d354a9890ea1ec5937a219817464e5cc344805b37671562a401ca3008", size = 122673, upload-time = "2025-11-14T21:57:38.177Z" }, ] [[package]] @@ -8589,14 +8592,17 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/f2/a9/dc3c63cf7f082d183711e46ef34d10d8a135c2319dc581905d79449f52ea/xgrammar-0.1.25.tar.gz", hash = "sha256:70ce16b27e8082f20808ed759b0733304316facc421656f0f30cfce514b5b77a", size = 2297187, upload-time = "2025-09-21T05:58:58.942Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/b4/8f78b56ebf64f161258f339cc5898bf761b4fb6c6805d0bca1bcaaaef4a1/xgrammar-0.1.25-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:d12d1078ee2b5c1531610489b433b77694a7786210ceb2c0c1c1eb058e9053c7", size = 679074, upload-time = "2025-09-21T05:58:20.344Z" }, { url = "https://files.pythonhosted.org/packages/52/38/b57120b73adcd342ef974bff14b2b584e7c47edf28d91419cb9325fd5ef2/xgrammar-0.1.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c2e940541b7cddf3ef55a70f20d4c872af7f0d900bc0ed36f434bf7212e2e729", size = 622668, upload-time = "2025-09-21T05:58:22.269Z" }, { url = "https://files.pythonhosted.org/packages/19/8d/64430d01c21ca2b1d8c5a1ed47c90f8ac43717beafc9440d01d81acd5cfc/xgrammar-0.1.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2063e1c72f0c00f47ac8ce7ce0fcbff6fa77f79012e063369683844e2570c266", size = 8517569, upload-time = "2025-09-21T05:58:23.77Z" }, { url = "https://files.pythonhosted.org/packages/b1/c4/137d0e9cd038ff4141752c509dbeea0ec5093eb80815620c01b1f1c26d0a/xgrammar-0.1.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9785eafa251c996ebaa441f3b8a6c037538930104e265a64a013da0e6fd2ad86", size = 8709188, upload-time = "2025-09-21T05:58:26.246Z" }, { url = "https://files.pythonhosted.org/packages/6c/3d/c228c470d50865c9db3fb1e75a95449d0183a8248519b89e86dc481d6078/xgrammar-0.1.25-cp310-cp310-win_amd64.whl", hash = "sha256:42ecefd020038b3919a473fe5b9bb9d8d809717b8689a736b81617dec4acc59b", size = 698919, upload-time = "2025-09-21T05:58:28.368Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b7/ca0ff7c91f24b2302e94b0e6c2a234cc5752b10da51eb937e7f2aa257fde/xgrammar-0.1.25-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:27d7ac4be05cf9aa258c109a8647092ae47cb1e28df7d27caced6ab44b72b799", size = 678801, upload-time = "2025-09-21T05:58:29.936Z" }, { url = "https://files.pythonhosted.org/packages/43/cd/fdf4fb1b5f9c301d381656a600ad95255a76fa68132978af6f06e50a46e1/xgrammar-0.1.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:151c1636188bc8c5cdf318cefc5ba23221c9c8cc07cb392317fb3f7635428150", size = 622565, upload-time = "2025-09-21T05:58:31.185Z" }, { url = "https://files.pythonhosted.org/packages/55/04/55a87e814bcab771d3e4159281fa382b3d5f14a36114f2f9e572728da831/xgrammar-0.1.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35fc135650aa204bf84db7fe9c0c0f480b6b11419fe47d89f4bd21602ac33be9", size = 8517238, upload-time = "2025-09-21T05:58:32.835Z" }, { url = "https://files.pythonhosted.org/packages/31/f6/3c5210bc41b61fb32b66bf5c9fd8ec5edacfeddf9860e95baa9caa9a2c82/xgrammar-0.1.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc19d6d7e8e51b6c9a266e949ac7fb3d2992447efeec7df32cca109149afac18", size = 8709514, upload-time = "2025-09-21T05:58:34.727Z" }, { url = "https://files.pythonhosted.org/packages/21/de/85714f307536b328cc16cc6755151865e8875378c8557c15447ca07dff98/xgrammar-0.1.25-cp311-cp311-win_amd64.whl", hash = "sha256:8fcb24f5a7acd5876165c50bd51ce4bf8e6ff897344a5086be92d1fe6695f7fe", size = 698722, upload-time = "2025-09-21T05:58:36.411Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d7/a7bdb158afa88af7e6e0d312e9677ba5fb5e423932008c9aa2c45af75d5d/xgrammar-0.1.25-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:96500d7578c46e8551253b9211b02e02f54e147bc290479a64717d80dcf4f7e3", size = 678250, upload-time = "2025-09-21T05:58:37.936Z" }, { url = "https://files.pythonhosted.org/packages/10/9d/b20588a3209d544a3432ebfcf2e3b1a455833ee658149b08c18eef0c6f59/xgrammar-0.1.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ba9031e359447af53ce89dfb0775e7b9f4b358d513bcc28a6b4deace661dd5", size = 621550, upload-time = "2025-09-21T05:58:39.464Z" }, { url = "https://files.pythonhosted.org/packages/99/9c/39bb38680be3b6d6aa11b8a46a69fb43e2537d6728710b299fa9fc231ff0/xgrammar-0.1.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c519518ebc65f75053123baaf23776a21bda58f64101a64c2fc4aa467c9cd480", size = 8519097, upload-time = "2025-09-21T05:58:40.831Z" }, { url = "https://files.pythonhosted.org/packages/c6/c2/695797afa9922c30c45aa94e087ad33a9d87843f269461b622a65a39022a/xgrammar-0.1.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47fdbfc6007df47de2142613220292023e88e4a570546b39591f053e4d9ec33f", size = 8712184, upload-time = "2025-09-21T05:58:43.142Z" }, From 4a51289119b4ffc921d2292d9783f219ab3f12f1 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:09:35 -0500 Subject: [PATCH 04/14] validate strict compatibility --- .../pydantic_ai/models/__init__.py | 4 + .../pydantic_ai/profiles/anthropic.py | 74 +++++++++++++++++++ pydantic_ai_slim/pydantic_ai/tools.py | 2 +- tests/models/test_anthropic.py | 24 +++++- 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/__init__.py b/pydantic_ai_slim/pydantic_ai/models/__init__.py index 427beaab6d..e4fd00a078 100644 --- a/pydantic_ai_slim/pydantic_ai/models/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/models/__init__.py @@ -978,6 +978,10 @@ def get_user_agent() -> str: def _customize_tool_def(transformer: type[JsonSchemaTransformer], t: ToolDefinition): + """Customize the tool definition using the given transformer. + + If the tool definition has `strict` set to None, the strictness will be inferred from the transformer. + """ schema_transformer = transformer(t.parameters_json_schema, strict=t.strict) parameters_json_schema = schema_transformer.walk() return replace( diff --git a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py index 6544f75f16..e985459d66 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py @@ -4,12 +4,82 @@ from dataclasses import dataclass from typing import Any +from anthropic.lib._parse._transform import SupportedStringFormats + from .._json_schema import JsonSchema, JsonSchemaTransformer from . import ModelProfile TransformSchemaFunc = Callable[[Any], JsonSchema] +def _schema_is_lossless(schema: JsonSchema) -> bool: # noqa: C901 + """Return True when `anthropic.transform_schema` won't need to drop constraints.""" + + def _walk(node: JsonSchema) -> bool: # noqa: C901 + if not isinstance(node, dict): + return False + + node = dict(node) + + if '$ref' in node: + node.pop('$ref') + return not node + + defs = node.pop('$defs', None) + if defs: + for value in defs.values(): + if not _walk(value): + return False + + type_ = node.pop('type', None) + any_of = node.pop('anyOf', None) + one_of = node.pop('oneOf', None) + all_of = node.pop('allOf', None) + + node.pop('description', None) + node.pop('title', None) + + if isinstance(any_of, list): + return all(_walk(item) for item in any_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] + if isinstance(one_of, list): + return all(_walk(item) for item in one_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] + if isinstance(all_of, list): + return all(_walk(item) for item in all_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] + + if type_ is None: + return False + + if type_ == 'object': + properties = node.pop('properties', None) + if properties: + for value in properties.values(): + if not _walk(value): + return False + additional = node.pop('additionalProperties', None) + if additional not in (None, False): + return False + node.pop('required', None) + elif type_ == 'array': + items = node.pop('items', None) + if items and not _walk(items): + return False + min_items = node.pop('minItems', None) + if min_items not in (None, 0, 1): + return False + elif type_ == 'string': + format_ = node.pop('format', None) + if format_ is not None and format_ not in SupportedStringFormats: + return False + elif type_ in {'integer', 'number', 'boolean', 'null'}: + pass + else: + return False + + return not node + + return _walk(schema) + + @dataclass(init=False) class AnthropicJsonSchemaTransformer(JsonSchemaTransformer): """Transforms schemas to the subset supported by Anthropic structured outputs.""" @@ -18,6 +88,10 @@ def walk(self) -> JsonSchema: from anthropic import transform_schema schema = super().walk() + if self.is_strict_compatible and not _schema_is_lossless(schema): + # check compatibility before calling anthropic's transformer + # so we don't auto-enable strict when the SDK would drop constraints + self.is_strict_compatible = False transformed = transform_schema(schema) return transformed diff --git a/pydantic_ai_slim/pydantic_ai/tools.py b/pydantic_ai_slim/pydantic_ai/tools.py index ca72cafbb5..e54b829bfb 100644 --- a/pydantic_ai_slim/pydantic_ai/tools.py +++ b/pydantic_ai_slim/pydantic_ai/tools.py @@ -480,7 +480,7 @@ class ToolDefinition: When `False`, the model may be free to generate other properties or types (depending on the vendor). When `None` (the default), the value will be inferred based on the compatibility of the parameters_json_schema. - Note: this is currently only supported by OpenAI models. + Note: this is currently supported by OpenAI and Anthropic models. """ sequential: bool = False diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 00842bd217..a3d2413b56 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -8,12 +8,12 @@ from datetime import timezone from decimal import Decimal from functools import cached_property -from typing import Any, TypeVar, cast +from typing import Annotated, Any, TypeVar, cast import httpx import pytest from inline_snapshot import snapshot -from pydantic import BaseModel +from pydantic import BaseModel, Field from pydantic_ai import ( Agent, @@ -591,6 +591,26 @@ def my_tool(value: str) -> str: # pragma: no cover assert system[0]['cache_control'] == snapshot({'type': 'ephemeral', 'ttl': '5m'}) +async def test_anthropic_incompatible_schema_disables_auto_strict(allow_model_requests: None): + """Ensure strict mode is disabled when Anthropic cannot enforce the tool schema.""" + c = completion_message( + [BetaTextBlock(text='Done', type='text')], + usage=BetaUsage(input_tokens=8, output_tokens=3), + ) + mock_client = MockAnthropic.create_mock(c) + m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + agent = Agent(m) + + @agent.tool_plain + def constrained_tool(value: Annotated[str, Field(min_length=2)]) -> str: # pragma: no cover - not executed + return value + + await agent.run('hello') + + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + assert 'strict' not in completion_kwargs['tools'][0] + + async def test_anthropic_mixed_strict_tool_run(allow_model_requests: None, anthropic_api_key: str): """Exercise both strict=True and strict=False tool definitions against the live API.""" m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(api_key=anthropic_api_key)) From 157899331e4f2b5a0e69a29e22e710ac065a0c0b Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:34:38 -0500 Subject: [PATCH 05/14] add model-based support and add tests --- .../pydantic_ai/models/__init__.py | 5 +- .../pydantic_ai/models/anthropic.py | 21 +- .../pydantic_ai/profiles/__init__.py | 14 +- .../pydantic_ai/profiles/anthropic.py | 20 +- tests/CLAUDE.md | 45 +++ tests/models/anthropic/__init__.py | 1 + .../test_anthropic_auto_mode_sonnet_4_0.yaml | 83 +++++ .../test_anthropic_auto_mode_sonnet_4_5.yaml | 84 +++++ ...test_anthropic_native_output_multiple.yaml | 260 ++++++++++++++ ...st_anthropic_native_output_sonnet_4_5.yaml | 76 ++++ ...st_anthropic_native_output_with_tools.yaml | 184 ++++++++++ tests/models/anthropic/conftest.py | 127 +++++++ tests/models/anthropic/test_output.py | 334 ++++++++++++++++++ ...t_anthropic_output_tool_with_thinking.yaml | 49 ++- tests/models/test_anthropic.py | 6 +- tests/profiles/__init__.py | 0 tests/profiles/test_anthropic.py | 105 ++++++ 17 files changed, 1371 insertions(+), 43 deletions(-) create mode 100644 tests/CLAUDE.md create mode 100644 tests/models/anthropic/__init__.py create mode 100644 tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_0.yaml create mode 100644 tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_5.yaml create mode 100644 tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple.yaml create mode 100644 tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_sonnet_4_5.yaml create mode 100644 tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_with_tools.yaml create mode 100644 tests/models/anthropic/conftest.py create mode 100644 tests/models/anthropic/test_output.py create mode 100644 tests/profiles/__init__.py create mode 100644 tests/profiles/test_anthropic.py diff --git a/pydantic_ai_slim/pydantic_ai/models/__init__.py b/pydantic_ai_slim/pydantic_ai/models/__init__.py index a8eb56ab7b..c5a2771cef 100644 --- a/pydantic_ai_slim/pydantic_ai/models/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/models/__init__.py @@ -377,7 +377,10 @@ async def request( model_settings: ModelSettings | None, model_request_parameters: ModelRequestParameters, ) -> ModelResponse: - """Make a request to the model.""" + """Make a request to the model. + + This is ultimately called by `pydantic_ai._agent_graph.ModelRequestNode._make_request(...)`. + """ raise NotImplementedError() async def count_tokens( diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 5287fe0fb3..8608e02c94 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -199,8 +199,9 @@ def __init__( model_name: The name of the Anthropic model to use. List of model names available [here](https://docs.anthropic.com/en/docs/about-claude/models). provider: The provider to use for the Anthropic API. Can be either the string 'anthropic' or an - instance of `Provider[AsyncAnthropicClient]`. If not provided, the other parameters will be used. + instance of `Provider[AsyncAnthropicClient]`. Defaults to 'anthropic'. profile: The model profile to use. Defaults to a profile picked by the provider based on the model name. + The default 'anthropic' provider will use the default `..profiles.anthropic_model_profile`. settings: Default model settings for this model instance. """ self._model_name = model_name @@ -290,13 +291,14 @@ def prepare_request( and thinking.get('type') == 'enabled' ): if model_request_parameters.output_mode == 'auto': - model_request_parameters = replace(model_request_parameters, output_mode='prompted') + output_mode = 'native' if self.profile.supports_json_schema_output else 'prompted' + model_request_parameters = replace(model_request_parameters, output_mode=output_mode) elif ( model_request_parameters.output_mode == 'tool' and not model_request_parameters.allow_text_output ): # pragma: no branch # This would result in `tool_choice=required`, which Anthropic does not support with thinking. raise UserError( - 'Anthropic does not support thinking and output tools at the same time. Use `output_type=PromptedOutput(...)` instead.' + 'Anthropic does not support thinking and output tools at the same time. Use `output_type=NativeOutput(...)` instead.' ) return super().prepare_request(model_settings, model_request_parameters) @@ -330,7 +332,7 @@ async def _messages_create( # standalone function to make it easier to override tools, strict_tools_requested = self._get_tools(model_request_parameters, model_settings) tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters) - output_format = self._build_output_format(model_request_parameters) + native_format = self._native_output_format(model_request_parameters) tool_choice = self._infer_tool_choice(tools, model_settings, model_request_parameters) @@ -338,7 +340,7 @@ async def _messages_create( # Build betas list for SDK betas: list[str] = list(beta_features) - if strict_tools_requested or output_format: + if strict_tools_requested or native_format: betas.append('structured-outputs-2025-11-13') try: @@ -354,7 +356,7 @@ async def _messages_create( tools=tools or OMIT, tool_choice=tool_choice or OMIT, mcp_servers=mcp_servers or OMIT, - output_format=output_format or OMIT, + output_format=native_format or OMIT, betas=betas or OMIT, stream=stream, thinking=model_settings.get('anthropic_thinking', OMIT), @@ -849,19 +851,18 @@ async def _map_user_prompt( else: raise RuntimeError(f'Unsupported content type: {type(item)}') # pragma: no cover - @staticmethod - def _map_tool_definition(f: ToolDefinition) -> BetaToolParam: + def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam: tool_param: BetaToolParam = { 'name': f.name, 'description': f.description or '', 'input_schema': f.parameters_json_schema, } - if f.strict: + if f.strict and self.profile.supports_json_schema_output: # pragma: no branch tool_param['strict'] = f.strict return tool_param @staticmethod - def _build_output_format(model_request_parameters: ModelRequestParameters) -> BetaJSONOutputFormatParam | None: + def _native_output_format(model_request_parameters: ModelRequestParameters) -> BetaJSONOutputFormatParam | None: if model_request_parameters.output_mode != 'native': return None output_object = model_request_parameters.output_object diff --git a/pydantic_ai_slim/pydantic_ai/profiles/__init__.py b/pydantic_ai_slim/pydantic_ai/profiles/__init__.py index dace9f2b32..34224d47a7 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/__init__.py @@ -25,9 +25,19 @@ class ModelProfile: supports_tools: bool = True """Whether the model supports tools.""" supports_json_schema_output: bool = False - """Whether the model supports JSON schema output.""" + """Whether the model supports JSON schema output. + + This is also referred to as 'native' support for structured output. + This is the preferred way to get structured output from the model when available. + Relates to the `NativeOutput` output type. + """ supports_json_object_output: bool = False - """Whether the model supports JSON object output.""" + """Whether the model supports JSON object output. + + This is different from `supports_json_schema_output` in that it indicates whether the model can return arbitrary JSON objects, + rather than only JSON objects that conform to a provided JSON schema. + Relates to the `PromptedOutput` output type. + """ supports_image_output: bool = False """Whether the model supports image output.""" default_structured_output_mode: StructuredOutputMode = 'tool' diff --git a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py index e985459d66..6d58893074 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py @@ -1,19 +1,17 @@ from __future__ import annotations as _annotations -from collections.abc import Callable from dataclasses import dataclass -from typing import Any - -from anthropic.lib._parse._transform import SupportedStringFormats from .._json_schema import JsonSchema, JsonSchemaTransformer from . import ModelProfile -TransformSchemaFunc = Callable[[Any], JsonSchema] - def _schema_is_lossless(schema: JsonSchema) -> bool: # noqa: C901 - """Return True when `anthropic.transform_schema` won't need to drop constraints.""" + """Return True when `anthropic.transform_schema` won't need to drop constraints. + + Checks are performed based on https://docs.claude.com/en/docs/build-with-claude/structured-outputs#how-sdk-transformation-works + """ + from anthropic.lib._parse._transform import SupportedStringFormats def _walk(node: JsonSchema) -> bool: # noqa: C901 if not isinstance(node, dict): @@ -39,6 +37,8 @@ def _walk(node: JsonSchema) -> bool: # noqa: C901 node.pop('description', None) node.pop('title', None) + # every sub-schema in the list must itself be lossless -> `all(_walk(item) for item in any_of)` + # the wrapper object must not have any other unsupported fields -> `and not node` if isinstance(any_of, list): return all(_walk(item) for item in any_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] if isinstance(one_of, list): @@ -104,8 +104,12 @@ def transform(self, schema: JsonSchema) -> JsonSchema: def anthropic_model_profile(model_name: str) -> ModelProfile | None: """Get the model profile for an Anthropic model.""" + models_that_support_json_schema_output = ('claude-sonnet-4-5', 'claude-opus-4-1') + # anthropic introduced support for both structured outputs and strict tool use + # https://docs.claude.com/en/docs/build-with-claude/structured-outputs#example-usage + supports_json_schema_output = model_name.startswith(models_that_support_json_schema_output) return ModelProfile( thinking_tags=('', ''), - supports_json_schema_output=True, + supports_json_schema_output=supports_json_schema_output, json_schema_transformer=AnthropicJsonSchemaTransformer, ) diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md new file mode 100644 index 0000000000..c2baca5e73 --- /dev/null +++ b/tests/CLAUDE.md @@ -0,0 +1,45 @@ +# Testing conventions + +## general rules + +- prefer using `snapshot()` instead of line-by-line assertions +- unless the snapshot is too big and you only need to check specific values + +### about static typing + +- other codebases don't use types in their test files +- but this codebase is fully typed with static types +- proper types are required and the pre-commit hook sstrictly checks for types and won't allow commits with type errors +- so you're required to use proper types in test files as well +- refer to `tests/models/anthropic/conftest.py` for examples of typing in test files + +## for testing filepaths + +- define your function with a parameter `tmp_path: Path` + +## examples + +### inline vs snapshot +```python + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + assert 'tools' in completion_kwargs + tools = completion_kwargs['tools'] + # By default, tools should be strict-compatible + assert any(tool.get('strict') is True for tool in tools) + # Should include structured-outputs beta + assert 'structured-outputs-2025-11-13' in completion_kwargs.get('betas', []) +``` + +can be simplified to + +```python + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] + tools = completion_kwargs['tools'] + betas = completion_kwargs['betas'] + assert tools = snapshot() + assert betas = snapshot() +``` + +- it's preferable to use the snapshot, run the test and check what comes out +- if the snapshot is too large in comparison with the equivalent inline assertions, it's ok to keep the inline assertions +- confirm with the user what they prefer in cases that don't have a clear preference diff --git a/tests/models/anthropic/__init__.py b/tests/models/anthropic/__init__.py new file mode 100644 index 0000000000..1cad9fd8b4 --- /dev/null +++ b/tests/models/anthropic/__init__.py @@ -0,0 +1 @@ +"""Tests for Anthropic models.""" diff --git a/tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_0.yaml b/tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_0.yaml new file mode 100644 index 0000000000..fee4d326b2 --- /dev/null +++ b/tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_0.yaml @@ -0,0 +1,83 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '422' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What is the capital of France? + type: text + role: user + model: claude-sonnet-4-0 + stream: false + tool_choice: + type: any + tools: + - description: A city and its country. + input_schema: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + name: final_result + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '503' + content-type: + - application/json + retry-after: + - '59' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - id: toolu_011CgnivckJbK9QLLe9LN1Rt + input: + city: Paris + country: France + name: final_result + type: tool_use + id: msg_01QgYbVnhrJe61wKzuPNxcsQ + model: claude-sonnet-4-20250514 + role: assistant + stop_reason: tool_use + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 402 + output_tokens: 55 + service_tier: standard + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_5.yaml b/tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_5.yaml new file mode 100644 index 0000000000..f447c2d3f0 --- /dev/null +++ b/tests/models/anthropic/cassettes/test_output/test_anthropic_auto_mode_sonnet_4_5.yaml @@ -0,0 +1,84 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '436' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What is the capital of France? + type: text + role: user + model: claude-sonnet-4-5 + stream: false + tool_choice: + type: any + tools: + - description: A city and its country. + input_schema: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + name: final_result + strict: true + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '505' + content-type: + - application/json + retry-after: + - '1' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - id: toolu_01M1wKLEV2vS9t2oy4RihDKo + input: + city: Paris + country: France + name: final_result + type: tool_use + id: msg_01AW9scKTcw7CLGFCFrzfGNw + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: tool_use + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 675 + output_tokens: 55 + service_tier: standard + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple.yaml b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple.yaml new file mode 100644 index 0000000000..f650bfbf12 --- /dev/null +++ b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple.yaml @@ -0,0 +1,260 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '1158' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What is the capital of the user country? + type: text + role: user + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + result: + anyOf: + - additionalProperties: false + description: A city and its country. + properties: + data: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + kind: + description: '{const: CityLocation}' + type: string + required: + - kind + - data + type: object + - additionalProperties: false + properties: + data: + additionalProperties: false + properties: + country: + type: string + language: + type: string + required: + - country + - language + type: object + kind: + description: '{const: CountryLanguage}' + type: string + required: + - kind + - data + type: object + required: + - result + type: object + type: json_schema + stream: false + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: get_user_country + strict: true + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '477' + content-type: + - application/json + retry-after: + - '17' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - id: toolu_01CtCGUm8Mecn3hJ2R3mmKFK + input: {} + name: get_user_country + type: tool_use + id: msg_017BZRwY98Dcn7wvHbEq3XKx + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: tool_use + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 1047 + output_tokens: 38 + service_tier: standard + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '1420' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What is the capital of the user country? + type: text + role: user + - content: + - id: toolu_01CtCGUm8Mecn3hJ2R3mmKFK + input: {} + name: get_user_country + type: tool_use + role: assistant + - content: + - content: France + is_error: false + tool_use_id: toolu_01CtCGUm8Mecn3hJ2R3mmKFK + type: tool_result + role: user + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + result: + anyOf: + - additionalProperties: false + description: A city and its country. + properties: + data: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + kind: + description: '{const: CityLocation}' + type: string + required: + - kind + - data + type: object + - additionalProperties: false + properties: + data: + additionalProperties: false + properties: + country: + type: string + language: + type: string + required: + - country + - language + type: object + kind: + description: '{const: CountryLanguage}' + type: string + required: + - kind + - data + type: object + required: + - result + type: object + type: json_schema + stream: false + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: get_user_country + strict: true + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '508' + content-type: + - application/json + retry-after: + - '12' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - text: '{"result": {"kind": "CityLocation", "data": {"city": "Paris", "country": "France"}}}' + type: text + id: msg_016YnWzi9KrgJNbvMhWYqSJf + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 1099 + output_tokens: 30 + service_tier: standard + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_sonnet_4_5.yaml b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_sonnet_4_5.yaml new file mode 100644 index 0000000000..24664c0136 --- /dev/null +++ b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_sonnet_4_5.yaml @@ -0,0 +1,76 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '352' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What is the capital of France? + type: text + role: user + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + type: json_schema + stream: false + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '450' + content-type: + - application/json + retry-after: + - '26' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - text: '{"city":"Paris","country":"France"}' + type: text + id: msg_0194Cpmq3rHjBwgZFXzvyFsy + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 177 + output_tokens: 12 + service_tier: standard + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_with_tools.yaml b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_with_tools.yaml new file mode 100644 index 0000000000..4c599d682f --- /dev/null +++ b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_with_tools.yaml @@ -0,0 +1,184 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '544' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What is the largest city in the user country? + type: text + role: user + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + type: json_schema + stream: false + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: get_user_country + strict: true + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '476' + content-type: + - application/json + retry-after: + - '24' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - id: toolu_01UJWAFTeUEmNMsntRNTWDnc + input: {} + name: get_user_country + type: tool_use + id: msg_01GUDptt8Db9fXwet5eLnQuE + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: tool_use + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 715 + output_tokens: 38 + service_tier: standard + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '806' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What is the largest city in the user country? + type: text + role: user + - content: + - id: toolu_01UJWAFTeUEmNMsntRNTWDnc + input: {} + name: get_user_country + type: tool_use + role: assistant + - content: + - content: Mexico + is_error: false + tool_use_id: toolu_01UJWAFTeUEmNMsntRNTWDnc + type: tool_result + role: user + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + type: json_schema + stream: false + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: get_user_country + strict: true + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '459' + content-type: + - application/json + retry-after: + - '21' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - text: '{"city": "Mexico City", "country": "Mexico"}' + type: text + id: msg_016zjd8HTyAU2RFcVxeRue9h + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 767 + output_tokens: 16 + service_tier: standard + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/anthropic/conftest.py b/tests/models/anthropic/conftest.py new file mode 100644 index 0000000000..968f6374f2 --- /dev/null +++ b/tests/models/anthropic/conftest.py @@ -0,0 +1,127 @@ +"""Shared fixtures for Anthropic model tests.""" + +from __future__ import annotations as _annotations + +from typing import TYPE_CHECKING + +import pytest + +from ...conftest import try_import +from ..test_anthropic import MockAnthropic, completion_message + +with try_import() as imports_successful: + from anthropic import AsyncAnthropic + from anthropic.types.beta import BetaMessage, BetaTextBlock, BetaToolUseBlock, BetaUsage + from pydantic import BaseModel + + from pydantic_ai.models.anthropic import AnthropicModel + from pydantic_ai.providers.anthropic import AnthropicProvider + +if TYPE_CHECKING: + from pydantic_ai import Agent + + +# Model fixtures for live API tests +@pytest.fixture +def anthropic_sonnet_4_5(anthropic_api_key: str) -> AnthropicModel: + """Anthropic claude-sonnet-4-5 model with real API key.""" + return AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(api_key=anthropic_api_key)) + + +@pytest.fixture +def anthropic_sonnet_4_0(anthropic_api_key: str) -> AnthropicModel: + """Anthropic claude-sonnet-4-0 model with real API key.""" + return AnthropicModel('claude-sonnet-4-0', provider=AnthropicProvider(api_key=anthropic_api_key)) + + +@pytest.fixture +def anthropic_haiku_4_5(anthropic_api_key: str) -> AnthropicModel: + """Anthropic claude-haiku-4-5 model with real API key.""" + return AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(api_key=anthropic_api_key)) + + +# Mock model fixtures for unit tests +@pytest.fixture +def mock_sonnet_4_5(allow_model_requests: None) -> tuple[AnthropicModel, AsyncAnthropic]: + """Mock claude-sonnet-4-5 model for unit tests.""" + c = completion_message( + [BetaTextBlock(text='{"city": "Mexico City", "country": "Mexico"}', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(c) + model = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + return model, mock_client + + +@pytest.fixture +def mock_sonnet_4_0(allow_model_requests: None) -> tuple[AnthropicModel, AsyncAnthropic]: + """Mock claude-sonnet-4-0 model for unit tests.""" + c = completion_message( + [BetaTextBlock(text='response', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(c) + model = AnthropicModel('claude-sonnet-4-0', provider=AnthropicProvider(anthropic_client=mock_client)) + return model, mock_client + + +# Schema fixtures +@pytest.fixture +def city_location_schema() -> type[BaseModel]: + """Standard CityLocation schema for testing.""" + + class CityLocation(BaseModel): + """A city and its country.""" + + city: str + country: str + + return CityLocation + + +@pytest.fixture +def country_language_schema() -> type[BaseModel]: + """Standard CountryLanguage schema for testing.""" + + class CountryLanguage(BaseModel): + country: str + language: str + + return CountryLanguage + + +# Agent factory helper +@pytest.fixture +def make_agent(): + """Factory to create agents.""" + from typing import Any + + from pydantic_ai import Agent + + def _make_agent(model: AnthropicModel, **agent_kwargs: Any) -> Agent: + return Agent(model, **agent_kwargs) + + return _make_agent + + +# Mock response fixtures +@pytest.fixture +def weather_tool_responses() -> list[BetaMessage]: + """Standard mock responses for weather tool tests.""" + return [ + completion_message( + [ + BetaToolUseBlock( + id='tool_123', + name='get_weather', + input={'location': 'Paris'}, + type='tool_use', + ) + ], + BetaUsage(input_tokens=5, output_tokens=10), + ), + completion_message( + [BetaTextBlock(text='The weather in Paris is sunny.', type='text')], + BetaUsage(input_tokens=3, output_tokens=5), + ), + ] diff --git a/tests/models/anthropic/test_output.py b/tests/models/anthropic/test_output.py new file mode 100644 index 0000000000..5b32641842 --- /dev/null +++ b/tests/models/anthropic/test_output.py @@ -0,0 +1,334 @@ +""" """ + +from __future__ import annotations as _annotations + +from collections.abc import Callable +from datetime import timezone + +import pytest +from inline_snapshot import snapshot +from pydantic import BaseModel, ConfigDict + +from pydantic_ai import Agent, ModelRequest, ModelResponse, TextPart, UserPromptPart +from pydantic_ai.exceptions import UserError +from pydantic_ai.output import NativeOutput +from pydantic_ai.usage import RequestUsage + +from ...conftest import IsNow, IsStr, try_import + +# Import reusable test utilities from parent test module +from ..test_anthropic import MockAnthropic, get_mock_chat_completion_kwargs + +with try_import() as imports_successful: + from anthropic import AsyncAnthropic + from anthropic.types.beta import BetaMessage + + from pydantic_ai.models.anthropic import AnthropicModel + from pydantic_ai.providers.anthropic import AnthropicProvider + +# Type alias for the make_agent fixture +MakeAgentType = Callable[..., Agent] + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='anthropic not installed'), + pytest.mark.anyio, + pytest.mark.vcr, +] + + +async def test_anthropic_native_output_sonnet_4_5( + allow_model_requests: None, + anthropic_sonnet_4_5: AnthropicModel, + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test native JSON schema output with claude-sonnet-4-5 (supporting model).""" + agent = make_agent(anthropic_sonnet_4_5, output_type=NativeOutput(city_location_schema)) + + result = await agent.run('What is the capital of France?') + assert result.output == snapshot(city_location_schema(city='Paris', country='France')) + + assert result.all_messages() == snapshot( + [ + ModelRequest( + parts=[ + UserPromptPart( + content='What is the capital of France?', + timestamp=IsNow(tz=timezone.utc), + ) + ], + run_id=IsStr(), + ), + ModelResponse( + parts=[TextPart(content='{"city":"Paris","country":"France"}')], + usage=RequestUsage( + input_tokens=177, + output_tokens=12, + details={ + 'cache_creation_input_tokens': 0, + 'cache_read_input_tokens': 0, + 'input_tokens': 177, + 'output_tokens': 12, + }, + ), + model_name=IsStr(), + timestamp=IsNow(tz=timezone.utc), + provider_name='anthropic', + provider_details={'finish_reason': 'end_turn'}, + provider_response_id=IsStr(), + finish_reason='stop', + run_id=IsStr(), + ), + ] + ) + + +async def test_anthropic_native_output_with_tools( + allow_model_requests: None, + anthropic_sonnet_4_5: AnthropicModel, + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test native output combined with tool calls.""" + agent = make_agent(anthropic_sonnet_4_5, output_type=NativeOutput(city_location_schema)) + + @agent.tool_plain + async def get_user_country() -> str: + return 'Mexico' + + result = await agent.run('What is the largest city in the user country?') + assert result.output == snapshot(city_location_schema(city='Mexico City', country='Mexico')) + + +async def test_anthropic_no_structured_output_support_sonnet_4_0( + allow_model_requests: None, + anthropic_sonnet_4_0: AnthropicModel, + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test that claude-sonnet-4-0 raises when NativeOutput is used.""" + agent = make_agent(anthropic_sonnet_4_0, output_type=NativeOutput(city_location_schema)) + + with pytest.raises(UserError, match='Native structured output is not supported by this model.'): + await agent.run('What is the capital of France?') + + +async def test_anthropic_no_structured_output_support_haiku_4_5( + allow_model_requests: None, + anthropic_haiku_4_5: AnthropicModel, + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test haiku-4-5 behavior with native output (expected to raise).""" + agent = make_agent(anthropic_haiku_4_5, output_type=NativeOutput(city_location_schema)) + + with pytest.raises(UserError, match='Native structured output is not supported by this model.'): + await agent.run('What is the capital of France?') + + +def test_anthropic_native_output_strict_mode( + allow_model_requests: None, + mock_sonnet_4_5: tuple[AnthropicModel, AsyncAnthropic], + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test strict mode settings for native output.""" + model, mock_client = mock_sonnet_4_5 + + # Explicit strict=True + agent = make_agent(model, output_type=NativeOutput(city_location_schema, strict=True)) + agent.run_sync('What is the capital of Mexico?') + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[-1] + output_format = completion_kwargs['output_format'] + betas = completion_kwargs['betas'] + assert output_format == snapshot( + { + 'type': 'json_schema', + 'schema': snapshot( + { + 'type': 'object', + 'properties': {'city': {'type': 'string'}, 'country': {'type': 'string'}}, + 'additionalProperties': False, + 'required': ['city', 'country'], + } + ), + } + ) + assert betas == snapshot(['structured-outputs-2025-11-13']) + + # Explicit strict=False + agent = make_agent(model, output_type=NativeOutput(city_location_schema, strict=False)) + agent.run_sync('What is the capital of Mexico?') + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[-1] + output_format = completion_kwargs['output_format'] + betas = completion_kwargs['betas'] + assert output_format == snapshot( + { + 'type': 'json_schema', + 'schema': snapshot( + { + 'type': 'object', + 'properties': {'city': {'type': 'string'}, 'country': {'type': 'string'}}, + 'additionalProperties': False, + 'required': ['city', 'country'], + } + ), + } + ) + assert betas == snapshot(['structured-outputs-2025-11-13']) + + # Strict-compatible (should auto-enable) + agent = make_agent(model, output_type=NativeOutput(city_location_schema)) + agent.run_sync('What is the capital of Mexico?') + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[-1] + output_format = completion_kwargs['output_format'] + betas = completion_kwargs['betas'] + assert output_format == snapshot( + { + 'type': 'json_schema', + 'schema': snapshot( + { + 'type': 'object', + 'properties': {'city': {'type': 'string'}, 'country': {'type': 'string'}}, + 'additionalProperties': False, + 'required': ['city', 'country'], + } + ), + } + ) + assert betas == snapshot(['structured-outputs-2025-11-13']) + + # Strict-incompatible (with extras='allow') + city_location_schema.model_config = ConfigDict(extra='allow') + agent = make_agent(model, output_type=NativeOutput(city_location_schema)) + agent.run_sync('What is the capital of Mexico?') + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[-1] + output_format = completion_kwargs['output_format'] + betas = completion_kwargs['betas'] + assert output_format == snapshot( + { + 'type': 'json_schema', + 'schema': snapshot( + { + 'type': 'object', + 'properties': {'city': {'type': 'string'}, 'country': {'type': 'string'}}, + 'additionalProperties': False, + 'required': ['city', 'country'], + } + ), + } + ) + assert betas == snapshot(['structured-outputs-2025-11-13']) + + +def test_anthropic_strict_tools_sonnet_4_5(allow_model_requests: None, weather_tool_responses: list[BetaMessage]): + """Test that strict tool definitions are properly sent for supporting models.""" + 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: # pragma: no cover + 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'] + assert tools == snapshot( + [ + { + 'name': 'get_weather', + 'description': '', + 'input_schema': { + 'type': 'object', + 'properties': {'location': {'type': 'string'}}, + 'additionalProperties': False, + 'required': ['location'], + }, + 'strict': True, + } + ] + ) + assert betas == snapshot(['structured-outputs-2025-11-13']) + + +def test_anthropic_strict_tools_sonnet_4_0(allow_model_requests: None, weather_tool_responses: list[BetaMessage]): + """Test that strict is NOT sent for non-supporting models.""" + 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: # pragma: no cover + 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 tools == snapshot( + [ + { + 'name': 'get_weather', + 'description': '', + 'input_schema': { + 'type': 'object', + 'properties': {'location': {'type': 'string'}}, + 'additionalProperties': False, + 'required': ['location'], + }, + } + ] + ) + assert betas == snapshot(['structured-outputs-2025-11-13']) + + +async def test_anthropic_native_output_multiple( + allow_model_requests: None, + anthropic_sonnet_4_5: AnthropicModel, + city_location_schema: type[BaseModel], + country_language_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test native output with union of multiple schemas.""" + agent = make_agent(anthropic_sonnet_4_5, output_type=NativeOutput([city_location_schema, country_language_schema])) + + @agent.tool_plain + async def get_user_country() -> str: + return 'France' + + result = await agent.run('What is the capital of the user country?') + # Should return CityLocation since we asked about capital + assert isinstance(result.output, city_location_schema | country_language_schema) + if isinstance(result.output, city_location_schema): + assert result.output.city == 'Paris' # type: ignore[attr-defined] + assert result.output.country == 'France' # type: ignore[attr-defined] + + +async def test_anthropic_auto_mode_sonnet_4_5( + allow_model_requests: None, + anthropic_sonnet_4_5: AnthropicModel, + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test auto mode with sonnet-4.5 (should use native output automatically).""" + agent = make_agent(anthropic_sonnet_4_5, output_type=city_location_schema) + + result = await agent.run('What is the capital of France?') + assert result.output == snapshot(city_location_schema(city='Paris', country='France')) + + +async def test_anthropic_auto_mode_sonnet_4_0( + allow_model_requests: None, + anthropic_sonnet_4_0: AnthropicModel, + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test auto mode with sonnet-4.0 (should fall back to prompted output).""" + agent = make_agent(anthropic_sonnet_4_0, output_type=city_location_schema) + + result = await agent.run('What is the capital of France?') + assert result.output == snapshot(city_location_schema(city='Paris', country='France')) diff --git a/tests/models/cassettes/test_anthropic/test_anthropic_output_tool_with_thinking.yaml b/tests/models/cassettes/test_anthropic/test_anthropic_output_tool_with_thinking.yaml index 0ea57131be..6b85553d15 100644 --- a/tests/models/cassettes/test_anthropic/test_anthropic_output_tool_with_thinking.yaml +++ b/tests/models/cassettes/test_anthropic/test_anthropic_output_tool_with_thinking.yaml @@ -8,7 +8,7 @@ interactions: connection: - keep-alive content-length: - - '475' + - '358' content-type: - application/json host: @@ -21,15 +21,18 @@ interactions: - text: What is 3 + 3? type: text role: user - model: claude-sonnet-4-0 + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + response: + type: integer + required: + - response + type: object + type: json_schema stream: false - system: |2 - - Always respond with a JSON object that's compatible with this schema: - - {"properties": {"response": {"type": "integer"}}, "required": ["response"], "type": "object", "title": "int"} - - Don't include any text or Markdown fencing before or after. thinking: budget_tokens: 3000 type: enabled @@ -39,27 +42,35 @@ interactions: connection: - keep-alive content-length: - - '1150' + - '1563' content-type: - application/json retry-after: - - '54' + - '48' strict-transport-security: - max-age=31536000; includeSubDomains; preload transfer-encoding: - chunked parsed_body: content: - - signature: EuYCCkYICRgCKkBKb+DTJGUMVOQahj61RknYW0QUDawJfq0T0GPDVPY12LCBbS7YPklMKo29mW3gdTAfPBWgYGmOj51p1jkFst2/Egw0xpDI3vnsrcqx484aDB8G93CLqlAq112quyIwq1/wOAOxPiIRklQ/i2iN/UzmWwPrGHmSS+TAq7qh2VQdi32TUk2zVXlOmTJdOSquKs0BbVTmLPWPc7szqedimy5uTbErLKLALr6DH1RRXuvGeRNElsnJofVsDu48aqeZg36g3Pi9Hboj1oE/TpyclCbv9/CWrixeQ/L/GSggr3FxLJvDgpdtppZfRxWajS6DjTH0AOU2aEu1gvxGtrcIa8htRmo5ZwAxISkaiOAm1lY5pSMl31gRFwby3n/2Y32b3UbM4SSlidDCgOTrDtbJSuwygduhfu7OdPg/I737G+sLcB0RUq4rqnPQQ+T+NYuDHPOz5xyGooXi7UNygIrO2BgB + - signature: EpEECkYICRgCKkBevTTAKlgYyxh1U6olWdqLd5cV0KKViBdsaidSSqI55TeC59ngdwK+NwXALIJArf9ayGq7z2Y++6f9n4gS0MmhEgxbAJIseNnXz9uSgPsaDElQizdciEcDkG0xlCIwD9zwOZA4kqNsjKp8uNgHC97LxRmdo4tUu87KSvvHnR7x0eiGo01W6CV94W71xB6IKvgChpTBQeBGjGaP7mELZonHoh0LF4tQKNVL3LnRgL9sritYl7IxwJPvVWbojmSQEoGaFJtRJJPHSReFvYlD8HKABz24PSrRCboJYTA3O1/agsuHzIdZSCf3Nhd7ftnJ3fxx6wnBs79s9TL+dbgzkiJjXrb9ZnherjWdqFJ6aPTti26i88U2co/0Q+IKtUigzBaiAGABuc5LaIzNGqeg0yPQV4pbVjvjap5jRAbzwYmpxdZMrRwbSIQ6smjkYaRg2mxs0OxvhHoDKAuAyEplRHtIYTjnjUoogqaa4TttGX3vhLKJD1WTwcp6NJlZqr34SeW1PlfG0mmuPmG8N85zQfWXQpLVuUvxdrFYePMO7dqrYgthP69zuLLgI9jUn1TteF9mgbs8nMZ9oQ99R4v2qRf7P08KFb58xxqM+/Hu5c4CazeXJsw4kiBfdNcSL4zaCZEodgGH7Yp/9jNzRSOX0UsCrZ4SnmOr7wOS8usNYr+cWK/vVR3NRgzLYxgB thinking: |- - The user is asking me to calculate 3 + 3, which equals 6. They want me to respond with a JSON object that has a "response" field with an integer value. So I need to return: + The user is asking a simple math question: 3 + 3. + + 3 + 3 = 6 + + I need to respond with valid JSON that conforms to the schema provided. The schema requires: + - An object with a property called "response" + - The "response" property must be an integer + - No additional properties are allowed + - The "response" property is required - {"response": 6} + So I need to return: {"response": 6} type: thinking - - text: '{"response": 6}' + - text: '{"response":6}' type: text - id: msg_013vH3ViFyo8f85fA4HJuF8A - model: claude-sonnet-4-20250514 + id: msg_01AoK283YkWYce78oTzoF5Qz + model: claude-sonnet-4-5-20250929 role: assistant stop_reason: end_turn stop_sequence: null @@ -70,8 +81,8 @@ interactions: ephemeral_5m_input_tokens: 0 cache_creation_input_tokens: 0 cache_read_input_tokens: 0 - input_tokens: 106 - output_tokens: 71 + input_tokens: 187 + output_tokens: 113 service_tier: standard status: code: 200 diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index a3d2413b56..a9ff08711d 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -602,7 +602,7 @@ async def test_anthropic_incompatible_schema_disables_auto_strict(allow_model_re agent = Agent(m) @agent.tool_plain - def constrained_tool(value: Annotated[str, Field(min_length=2)]) -> str: # pragma: no cover - not executed + def constrained_tool(value: Annotated[str, Field(min_length=2)]) -> str: # pragma: no cover return value await agent.run('hello') @@ -630,7 +630,7 @@ async def capital_lookup(country: str) -> str: capital_called['value'] = True if country == 'Japan': return 'Tokyo' - return f'Unknown capital for {country}' + return f'Unknown capital for {country}' # pragma: no cover result = await agent.run('Use the registered tools and respond exactly as `Capital: `.') assert capital_called['value'] is True @@ -5424,7 +5424,7 @@ async def test_anthropic_output_tool_with_thinking(allow_model_requests: None, a with pytest.raises( UserError, match=re.escape( - 'Anthropic does not support thinking and output tools at the same time. Use `output_type=PromptedOutput(...)` instead.' + 'Anthropic does not support thinking and output tools at the same time. Use `output_type=NativeOutput(...)` instead.' ), ): await agent.run('What is 3 + 3?') diff --git a/tests/profiles/__init__.py b/tests/profiles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/profiles/test_anthropic.py b/tests/profiles/test_anthropic.py new file mode 100644 index 0000000000..f884a8f86e --- /dev/null +++ b/tests/profiles/test_anthropic.py @@ -0,0 +1,105 @@ +from __future__ import annotations as _annotations + +from typing import Annotated + +import pytest +from inline_snapshot import snapshot +from pydantic import BaseModel, Field + +from ..conftest import try_import + +with try_import() as imports_successful: + from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='anthropic not installed'), +] + + +def test_lossless_simple_model(): + """Simple BaseModel with basic types should be lossless.""" + + class Person(BaseModel): + name: str + age: int + + transformer = AnthropicJsonSchemaTransformer(Person.model_json_schema(), strict=True) + transformer.walk() + + assert transformer.is_strict_compatible is True + + +def test_lossless_nested_model(): + """Nested BaseModels should be lossless.""" + + class Address(BaseModel): + street: str + city: str + + class Person(BaseModel): + name: str + address: Address + + transformer = AnthropicJsonSchemaTransformer(Person.model_json_schema(), strict=True) + transformer.walk() + + assert transformer.is_strict_compatible is True + + +def test_lossy_string_constraints(): + """String with min_length constraint should be lossy (constraint gets dropped).""" + + class User(BaseModel): + username: Annotated[str, Field(min_length=3)] + + transformer = AnthropicJsonSchemaTransformer(User.model_json_schema(), strict=True) + transformer.walk() + + # SDK drops minLength, making it lossy + assert transformer.is_strict_compatible is False + + +def test_lossy_number_constraints(): + """Number with minimum constraint should be lossy (constraint gets dropped).""" + + class Product(BaseModel): + price: Annotated[float, Field(ge=0)] + + transformer = AnthropicJsonSchemaTransformer(Product.model_json_schema(), strict=True) + transformer.walk() + + # SDK drops minimum, making it lossy + assert transformer.is_strict_compatible is False + + +def test_lossy_pattern_constraint(): + """String with pattern constraint should be lossy (constraint gets dropped).""" + + class Email(BaseModel): + address: Annotated[str, Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')] + + transformer = AnthropicJsonSchemaTransformer(Email.model_json_schema(), strict=True) + transformer.walk() + + # SDK drops pattern, making it lossy + assert transformer.is_strict_compatible is False + + +def test_transformer_output(): + """Verify transformer produces expected output for a simple model.""" + + class SimpleModel(BaseModel): + name: str + count: int + + transformer = AnthropicJsonSchemaTransformer(SimpleModel.model_json_schema(), strict=True) + result = transformer.walk() + + assert result == snapshot( + { + 'type': 'object', + 'properties': {'name': {'type': 'string'}, 'count': {'type': 'integer'}}, + 'required': ['name', 'count'], + 'additionalProperties': False, + } + ) From 0b27ecf6eae94204f3f62f9f6bf688754b2da21e Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:47:40 -0500 Subject: [PATCH 06/14] update snapshots for coverage --- tests/models/test_anthropic.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index a9ff08711d..2622346b74 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -466,13 +466,11 @@ def tool_two() -> str: # pragma: no cover 'name': 'tool_one', 'description': '', 'input_schema': {'additionalProperties': False, 'properties': {}, 'type': 'object'}, - 'strict': True, }, { 'name': 'tool_two', 'description': '', 'input_schema': {'additionalProperties': False, 'properties': {}, 'type': 'object'}, - 'strict': True, 'cache_control': {'type': 'ephemeral', 'ttl': '5m'}, }, ] @@ -547,7 +545,6 @@ def my_tool(value: str) -> str: # pragma: no cover 'required': ['value'], 'type': 'object', }, - 'strict': True, 'cache_control': {'type': 'ephemeral', 'ttl': '5m'}, } ] From eb6edc68f0ae862c3d9ad55b2a350b7b84ccd0bb Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:18:14 -0500 Subject: [PATCH 07/14] rerun anthropic tests against api --- tests/models/test_anthropic.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 2622346b74..3af26b2a51 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -4964,21 +4964,7 @@ async def test_anthropic_server_tool_pass_history_to_another_provider( agent = Agent(anthropic_model, builtin_tools=[WebSearchTool()]) result = await agent.run('What day is today?') - assert result.output == snapshot("""\ -Based on the search results, today is Thursday, August 14, 2025. Here are some additional details about the date: - -It is the 226th day of the year 2025 in the Gregorian calendar, with 139 days remaining until the end of the year. - -Some interesting observances for today include: -It's being celebrated as: -- Color Book Day -- National Creamsicle Day -- National Financial Awareness Day -- National Navajo Code Talkers Day -- National Tattoo Removal Day -- National Wiffle Ball Day -- Social Security Day\ -""") + assert result.output == snapshot('Today is November 19, 2025.') result = await agent.run('What day is tomorrow?', model=openai_model, message_history=result.all_messages()) assert result.new_messages() == snapshot( [ @@ -4989,16 +4975,16 @@ async def test_anthropic_server_tool_pass_history_to_another_provider( ModelResponse( parts=[ TextPart( - content='Tomorrow will be **Friday, August 15, 2025**.', - id='msg_689dc4acfa488196a6b1ec0ebd3bd9520afe80ec3d42722e', + content='Tomorrow will be November 20, 2025.', + id='msg_0b7c351e904c86b300691e4f0dbb64819cbceace3a975191e4', ) ], - usage=RequestUsage(input_tokens=458, output_tokens=17, details={'reasoning_tokens': 0}), + usage=RequestUsage(input_tokens=329, output_tokens=13, details={'reasoning_tokens': 0}), model_name='gpt-4.1-2025-04-14', timestamp=IsDatetime(), provider_name='openai', provider_details={'finish_reason': 'completed'}, - provider_response_id='resp_689dc4abe31c81968ed493d15d8810fe0afe80ec3d42722e', + provider_response_id='resp_0b7c351e904c86b300691e4f0cc554819c934fb679b1a126be', finish_reason='stop', run_id=IsStr(), ), From 2446e6fe3db687439c00775c007aec342d77e05f Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:41:57 -0500 Subject: [PATCH 08/14] updated respective cassette --- ...tool_pass_history_to_another_provider.yaml | 169 +++--------------- tests/models/test_anthropic.py | 8 +- 2 files changed, 33 insertions(+), 144 deletions(-) diff --git a/tests/models/cassettes/test_anthropic/test_anthropic_server_tool_pass_history_to_another_provider.yaml b/tests/models/cassettes/test_anthropic/test_anthropic_server_tool_pass_history_to_another_provider.yaml index c3e8ee864a..9fa601a8a5 100644 --- a/tests/models/cassettes/test_anthropic/test_anthropic_server_tool_pass_history_to_another_provider.yaml +++ b/tests/models/cassettes/test_anthropic/test_anthropic_server_tool_pass_history_to_another_provider.yaml @@ -8,7 +8,7 @@ interactions: connection: - keep-alive content-length: - - '312' + - '321' content-type: - application/json host: @@ -28,6 +28,7 @@ interactions: tools: - allowed_domains: null blocked_domains: null + max_uses: null name: web_search type: web_search_20250305 user_location: null @@ -37,112 +38,20 @@ interactions: connection: - keep-alive content-length: - - '22237' + - '435' content-type: - application/json + retry-after: + - '58' strict-transport-security: - max-age=31536000; includeSubDomains; preload transfer-encoding: - chunked parsed_body: content: - - text: Let me search to find today's date. + - text: Today is November 19, 2025. type: text - - id: srvtoolu_01XS7jhb5cuvySCwMkqzaCZs - input: - query: today's date august 14 2025 - name: web_search - type: server_tool_use - - content: - - encrypted_content: Eu8CCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDDVX8QvgB5nuG4dIXxoMbQcR0MrfJ0qmFbMeIjDNIUWprY+mO5ziudCAEpog8aNm02OzWCrGrqYYDkL9NN3PyHyObunBTzKvqz2jwfMq8gEyjH27jH4q8hyFW+l47E7H2nNSm1M2USZ9KzD8SbNx2FdhFWL5vBvfbJZEoKAFbyf5h5IeVHf5IgevP9teYp9bic8lJv+spMomtV3MpbUpUnuq20quS6orYVTUWXANjOZG71uE0nZ+FrdgQHGZwoaW1HIPPNKXyEn8BOfndcpI1T56sCO/hlpDi5PFMLubERAA4kTKfBLV83Tk55C5cWU359szPzjghomr3wnkWK4vzsuVO5NLBhQ2W9OeDXj8lOoSeoSe3I2i2O1HTOd6YFZ94hmEUgfSepW61c/JMKvHe7LmA/i7J5jrZRd00Im3CwAOQhgD - page_age: null - title: August 14, 2025 Calendar with Holidays & Count Down - USA - type: web_search_result - url: https://www.wincalendar.com/Calendar/Date/August-14-2025 - - encrypted_content: EvUFCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDGi5R5LAyeIUIVSHRxoM43cSby7rkVpZZASWIjDoq+CJ+BP63k3QlL8e2PAk0upRfRq2WxcGyDZCJdxxy7JjRrg6EcK80ZxLzrAIaqEq+AT5oTxSfcGz+3Aq/LFZYAweyUgQ76LGgKNoDE7OeIUdx7YAhZvmhf8/MrmLsFu9oDUpxrPzi2ftA4BZzFNjCtLDr7+xaKuqHfmOodHh0cJoCNsW9XbwOCOBrFIqkrkLgYw3Jju9dZOvkpYE0DJ1rckO14NVOaUGxO9vaJWKlByAhVVhGJ1v041AFdrpLFVJhwpsP5cQFeKIbN8YrPyoUcU5gLc2cU6v4JwqdRwPhlSzdTv9Y5PxvfQvwtIBXtYm6LYeJKgmyx0DA8BaHdLBOdKj6Awp2tKf1qMYfMezrQOhU7j+cW5BvlWbhkbPc3I7MtPGKBKYehbOgrZry51m71eLzvsLUfUAOymZi4kV1Dkp20p0RwKVIO5oVdDUp5ADMjOy6fGt7qTD1dfTLfNq/fVIyaRtzoKjTB+kQGsIUHz426pF6xzirZ6ThUTeYiDzpMjCXywt79VBT6R+XypHCSJTGXwfVwxwLOcXnXkufXoo2POdDAMw8YHS9765Oxcqnr77cY9JvIznCtgcTsOAkMU4Ro3PlWu/RODsd1f52n8v94HWVdnUSzwo05po2TM8Vd6Gbi+ofeFKtuUuWXeDHm5aktAzcCjBB+5Xqs4YEcVA8G+HsN7tu2FSvypl5O9i61UJ/kUSEEyn6Ff40YLEkATomwVPxNhbpIsDxohHbybeGc70nSHWc6psWs5mnfjuCmR7jSdNjSZtwuzAW0zBSv+OmYTF+fdyGPH3PqlF+FBapYRRnPaW+MLOhTMaLfUbmLGZDceUkQqW2wCRLgMtsGClDQHbSuI8UOvK5kXckqBAIPVJ5HzELYfhn/gPI2jjVJhwGJDfK85mTBgD - page_age: null - title: How long until August 14th 2025? | howlongagogo.com - type: web_search_result - url: https://howlongagogo.com/date/2025/august/14 - - encrypted_content: ErYGCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDCz3OJhK3poJTguoMBoMo/tRhDq6TJ3e8JCAIjCC1UNWvlJHpBtB1lqoglwG1zThVF5VosJLm7Q9zJXY/YgaO3w0DG/s8PR11Bvy0kQquQWs1VysoI2K2kT2Fjsp49Jna4SXreEmt8TUDsntSvyoqssdieve5/GkOnvWLPjyBBYRtic2nyFdo0PYzgpx/QzcoVH+jsn+TSOKS9G3j7YE0fcP86Bq+AiMO8JQs8caeJkKFxtxISwcPPJ8xJTFFm1WXbYTqaRgONxwpad9TPxg5w30CtzHXamKx1McrhXEONeh6OAdtid88IAHKe4gL3uiuiFX6B/nVyo0bSmMsOj3kxQELvYwcyPm28+YJsupChI2DAq2qCtLBWj4qy2UnHqgENTDwe6oUh1PktUnGCqzpROgIl8leICvXx2Hl5qhaFOUn+wY619FbjUN1ENuyqgN9m358dV5TIYLIf4L9AZ7AtgNpOAxGiE95zFjyJL2DBumbCZ5Ik7t+bwjEdMlifzknNJqmAiRR2jAuKkxs5noSeu/N7PHhPMRy7ZxrBOdV5N1CY0of8b2lVMXfNdUE9iWAF1Fm8zBFPlcxcQyJEDGQkyIxeALs+53ylqYhiRsZXd8yoDPO1IGtJ7z5Brk4DmNrz3vcYQKe5wUOnqY5dGAcUBr3uo0Ll7Y1PXZlCsdpSK/sWYQyZOvBysm49w7zr2N1OPV9kLoq2mBnltbTLo5XUZ4Ukmu3nb+owbmfe2YtorsFOyfwRI4Zt46+chfFr5EE/3mfJ1F4sdJFXYi97cGGkDGChBw1ere2ptcwJybhDmHEqFbUD9T1bhX48Yzx3ATxGRRuz1bqKI/eBokSjxExp1H6PQwTtvguzq6e4Qv4wt4zKD+PhLqPOtw4ysxLgQNu2cCMwtjV9BVad/RWVabIQETqDd6x54yWp4m4RrHTrMCyw8nLkP8hPG6pckLZq+KTVPZmito6XID1mlbeOqFRA1EDVK+xfyPVBVKDMzcu72jMrl/2t/e99//XLk5rzqlICNAziBt0iBwGAM= - page_age: null - title: August 2025 Calendar - type: web_search_result - url: https://www.calendar-365.com/calendar/2025/August.html - - encrypted_content: ErgFCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDIkW36VDtOPzSfgmpBoMSZ+nlyuqy0yuIq1lIjCkRZIUxV91UR8uHnvXVth7d9DS0R5ZgSNBFQXl3SSd+26dSXOzCvzFq0Jq8/Xor+8quwTkX2ad5IdojEljGThehcAJDrESWUkjqJewbzAcpyZKJ8JF4L1h6vw/spTVBwwL/ffoLBXqT2yzPrjnGdimaA4J0FEYzLknWithI4G1vrB4Y7f/YsSLtd88adx4HWo60sWRjIoizm6sz2odQhUf6yatZfycqh2NLpQ1MOcEmlLZDFNkD3enbIYmBS01/eltjcQmH1CEp/aqE8gN9IkY7Rr2BRViYH11rXkWjP6MQYzhFhPWPmChEw3PouVv8W4kSsbrO4SbbWjnp72Q53fyeJjqda9TltlanR6c/iv/UWFcPXdmsCZhGUiXapfWGWRXABwixL0TsFCmC6VxhgTg4lj18EBk1wsRj5wqmECfzgjzupisXMBgMymwK6RlYGtQI+6BML2zfXNGaZkl7GU6meuJdCQ6Kx4heBkge9RtyAdeXZyPBicCTUGEAp4Fkk12wmS0qC0wu1NPtd+kl6NF2vBihNP6WX/nJQWmKG9JtC0UYnH9RNhd9H4N8Q9k9AY5++Fhen4b/uP1zPplPqrqMsrCHkeK9rPw9y/DlMn5wNTRQ931pLMQKuet14KGWz2buvgQXICCUZlQmOqMjrwdun8ehgWadLuKflHmJME/fM987OiDckCPcp//aRihDo8T4DL6wWzTPU4CQlNUU1butTXcdX415pZ1gMDZi8w9U2y2qigo982U9sMNARif4jMkQca2gyKECUlfnH/GBg/LYkzhwGWqeibrqTG5klvHg9SD+u8MYTxTmbXnTZXoGAM= - page_age: null - title: How many days until 14th August 2025? - type: web_search_result - url: https://days.to/14-august/2025 - - encrypted_content: ErwICioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDFalY5JZZv1KSYCilRoMHngZbao7hqbNY064IjCepPltLOs0hrwCFQ5TjRZCXu7uL+DLRfbXe3ts18qgGtJdPEA5L3uZWFusGnaZlQMqvweh6sxsxBYBjAzh9qMFDuhEyN5h9jDBCgu9zh7qlRy+Wm78asqsgTdoJTCkOu42zWl3Wm5YxDFD7SMFziV8Zr87nBhX9Qtc8U944HhBNzpgUlAHR3E0+TCrMTS3h1mepR2OiTWbisyktC3nuA1WN7KJf9hp0H+2WmugWJ3ktB8osZ4/Dri6X1uXxK38JYJ/3xinTjTHxJnh8/eieVv6Dz2AC2zhVux+dXUmNdmLpop7mOsSHCmY0M6loacof79BrGJcEt7Aeb56jIOM5nHqCtoXVwgu0H+dQZKE8jFR91Wz4a6GCJbDJ6EhC5h9wx0gS+YwXYjUFoB6NoQ9F1AlUPpO4oF4KzwdW8JzInkqa3GVr+C8Tab2IQyK9LOtQZv3kfbhPbPCY37IuPmc0xkDuBDXqRNRHU1f7tjLLXUSeYy0V+DSlpMFTouDjL0kWseU/68/dPg7cV2fcTWBdP+e9yVUohMCh9D8GnXPMgKlN4VlqX9MWHTtG06p2r8BIcqHAl3sBRrEG6rZyop6D7oAtzEQYKu9IMBY+rQumrCHQpSS2pQfNPO72ILZMePeogAOwiAzf5T1v5RptcKJVwCzlZGFkHK33tHZRRa7McPahnQes83fO+cUpcjsYgoYhUSArIAoxUP9G2hvImrwHvui4aoeH/VhW1cZUZYk2pFccKLcXxEzZoYnhreIRBRFAGRp4dLLwNVCLE8IR4ISeVEptG5ZSF9pG6jw0cbM+j8xDIllk88pPJp9MVJVZpFJu5Tm8rZnHKtMcrAK7OOtorKUPjpdshbZ0Cmdj27rzLc8MJ40sTZs+y/UqJmyddad4L7fWu/ekO5KgkSk2IXuE9P7YtkSJc1HYt4DG3cQfxCk0/8/r4eSFSMrTZHGpMyu+jp0UP68Arr9MR1qF+X3xRG3dEhf+HlN4lwHfqtsLMF2XpLwaFqPQ+f5fbotZjvtklMppjt5qsbARTJX3zWty1x5UlAEQhbp4+pCyncDzu2tR/6V4R9xpc3Nif6WCKbWcm8YnC0QCwhnyzz3DwQU7FWIh0qAmK98M80Hm9hz96YAZcdZo/c7/Rtm3pXmTto9YQAk596UrvcHpDM3KkUXOX45bZT8+oC8CQ5r7Op8Pj+jEswUM2fp8x3a7E1GpSxcccy7pV2HkidJPfgJ0mK9LsDeglcLlOlhEVClPKGndVH9t4aJQQJ2ExTLGqyWpznrvJ6AJHIf/I3HSaPMb9Lmo6LGLSQ/JitIeaNVLIlhV/wZMcc14jzenEj4Aod5E0JtUA5V0xgD - page_age: null - title: What Day of the Week Is August 14, 2025? - type: web_search_result - url: https://www.dayoftheweek.org/?m=August&d=14&y=2025&go=Go - - encrypted_content: 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 - page_age: null - title: Holidays for August 14th, 2025 | Checkiday.com - type: web_search_result - url: https://www.checkiday.com/8/14/2025 - - encrypted_content: Eo0GCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDCHvW5bllVlNwq3NXRoMJse9HFPZ6cP3pQAsIjCK+l/qeMt8XJUBOBZeTHaJ4MgkotNocV2WzjoNhcNA50iD5VLiigO2KBgXTOJ0qvMqkAXlsKgwnaHnJy8DZ6IA0vPv5QPTP2WB61EXJQMtbNQysOdA2+Z/1qqhQaedV1nOX8MOQDnGGF1PZ6/W174iBkhmkBJXem+e0JRIvTDT0CimRx9Dg0W0Km5q4sCV3cUfSDK8etpv5fWYes/txuoKC1ImPN3eb/tlEYuG4EkDgkK2/QdF2RPaANwpyOSaGz4XwuCWyeb6Rh1sG2RCAMPaGGnaxnaMy0j3u4FnHJtZ0CFmn/DkIVVXrZatiNdAvttuKgGB0ufqpQiBOki/+7DyJ1Q9LAgVFpEWnApFgZHClCd0DWTmxmAfMUMFYpx1x4otz/wSX0StbS+qIIFbBRn0brnEu0DBdIFh7fycyNZl0l3VPJ7j02NCcBcMW5PjE9KsFW+pCbGqcL1+tKTPCuvYRz7yiLvXBaXJJ+a505vHrShNNm9SGkMCl6cpCmzSbMGv06ss0PGdoUibjqb/Vy/Wh8cvLq6uZxEUzY4kMNJYCRoHfBGfqvKfwXG2GkWxoffqudHnW+e5bdR8Eswe/BSoBIrJE2f4AbxsTcMeX/VizoaCi/PWA2X+dpwmTU67yyGTO8bUxXuOw/Xj2jppC0xHh5FVcYHPvN7SAUshbkweiv4DvZAXRqL5XxUSz25lcK3v83SyrEM/KyqZQElVmTL9W99Ybu4UQzv/4FJUWceeJXPF3dN6qMVLjAeVnUZULMDNfSHfuTpGre1W88nwOAhqb05Yuq7pLHYvMp/qn9Ny2CTC9sC2WjqDqwTx0dmb5Hzy349Cs+GQBF4n+pae1j1ynM9ZSlRMigeXGJZuXdpk8ip6liWgiIaNIvoZD7eHMQb/SX8NSnxL5EEUPnKbnl9zJyiGMb1D4QZYjxgCASxrWraeJBgD - page_age: null - title: Countdown to Aug 14, 2025 in Washington DC, District of Columbia - type: web_search_result - url: https://www.timeanddate.com/countdown/to?msg=mrfoox's+birthday&year=2025&month=8&day=14&hour=0&min=0&sec=0&fromtheme=birthday - - encrypted_content: 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 - page_age: null - title: Daily Calendar for Thursday, August 14, 2025 | Almanac.com - type: web_search_result - url: https://www.almanac.com/calendar/date/2025-08-14 - - encrypted_content: EswGCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDH7gIkgK2zLs5pzDRRoMuF01Vpl3lHsp3Y3xIjB+VBq1OC0DYkQmH/JhVhO3cY4zmrYxp1cHTuuOJ99dAgZSzWU6+gUoQumtTQlpg7YqzwWLxX4UFPE4Jlqm6QKvQWIaUxZ+g5LcUicBxA5jksMx9vQE3yvy//a6ETOMyLT/2xyGWHQAIhwi59bU7fOv7SidTefS2pWxEs18Gsw+bYimcG4uWx1h6T57ha1qOANtuHt16P+1Z9rM67yiYdj9X1AObPTz2njMbMb02RR72SHrqaC9GUkQq9fNTAOp+pSVA7CH4wo/GihdmIUcYNlNkQPl2IiwUgoMOffTsyV+Ory7TRcwMWPYQkJRczOLE1AleAmd/RnJa68dUmQ0drJazp/lqy5S0pI98BrrC7TzuOxObBRbFiaLo9yLMQAbZg+pxmMTD/VzMoWYViMk5uPT4p7h3qIJDvZA0/Qw8lOvr3VfPi6Jekj5h8qdGVR4AY56IVIGs3yBnxiG3/0AHq4+9nMUuZt1sNjagljGXP1A/JJIjketxefbLQ+z/t2ndvIbMrWTjDQMt0t+Lur9FaSW1WHW9+eUrxoXhS2bj2EUorTD6Xrg0W+Ugzvv6ius6DIuWr/DITDO7j/hWZtOT+4MycYv15x05CFfytv5l9YyKTxmBm7DXxyOwz4tfE+QTehb32D9yPyh2JRUx5AYHla/qT4AZxqPE20aUA3NFMztY433ljxGK9KMA0okSEgIy0gEuznq1soKACa5BhmIP9RiqekHCu/uW1/jVyWxWLgYdaZRyEp24ricn8yOTzsTO+ygP/OeYGMUFZmRG+mBwwOXcv4xYyHecU2qEtX+dmaVqxQX8u4OcBgksDbVI95OAUBL9Te7KTcm1A1Vg2sekUdiKZyHEzHLXjl8O0PJ5vzs4ziYcXXbmsykdisYoMcUoMIpcsmKi2fVaF1zu+VY9scRjTLPLHGx1LRYD/iaPhFbfgvFP2LDuflpcBKgTEZfdWmx9pci2IJksrUh5tvVTV+xdSWAhoMNfyzFCpqtZOUEh9IXs/Kn8yyiJ2kZCFejJ/0EhxgD - page_age: January 1, 2019 - title: Thursday 14th August 2025 | There is a Day for that! - type: web_search_result - url: https://www.thereisadayforthat.com/calendars/2025/8/14 - - encrypted_content: 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 - page_age: null - title: National Holidays on August 14th, 2025 | Days Of The Year - type: web_search_result - url: https://www.daysoftheyear.com/days/aug/14/ - tool_use_id: srvtoolu_01XS7jhb5cuvySCwMkqzaCZs - type: web_search_tool_result - - text: |+ - Based on the search results, today is Thursday, August 14, 2025. Here are some additional details about the date: - - type: text - - citations: - - cited_text: 'August 14, 2025 is the 226th day of the year 2025 in the Gregorian calendar. There are 139 days remaining - until the end of the year. ' - encrypted_index: EpEBCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDA6TeKmtBQt51f1xAxoMaWdsRvtoCUf61xo2IjBD2HcmHKG10DqXjPSp3iOapscYmcwPOgouSLR5uZxu7eD1v8NIZaaIsMt4/Nu+VOUqFSmD/TNQJvjtMBgPNaqVgo1Z5ZBRpBgE - title: What Day of the Week Is August 14, 2025? - type: web_search_result_location - url: https://www.dayoftheweek.org/?m=August&d=14&y=2025&go=Go - text: It is the 226th day of the year 2025 in the Gregorian calendar, with 139 days remaining until the end of the - year - type: text - - text: | - . - - Some interesting observances for today include: - type: text - - citations: - - cited_text: August 14, 2025 - Today's holidays are Color Book Day, National Creamsicle Day, National Financial - Awareness Day, National Navajo Code Talkers Da... - encrypted_index: Eo8BCioIBhgCIiQ0NGFlNjc2Yy05NThmLTRkNjgtOTEwOC1lYWU5ZGU3YjM2NmISDNdN0S9eDsUEbs+abxoMEGR4NTi3doqud4VgIjCoPglUsXpHf8Rg8Sz2u2+gz0O00oDnOVM4QoTITPteuIbRLCdkSCl2kToynWQ88jIqE3u6Byx4FCxWXa+h108og1Dfkk0YBA== - title: Holidays for August 14th, 2025 | Checkiday.com - type: web_search_result_location - url: https://www.checkiday.com/8/14/2025 - text: |- - It's being celebrated as: - - Color Book Day - - National Creamsicle Day - - National Financial Awareness Day - - National Navajo Code Talkers Day - - National Tattoo Removal Day - - National Wiffle Ball Day - - Social Security Day - type: text - id: msg_015yJEtDXJUq55EfSpwxam1f + id: msg_01SXTNg1wjy1KU6g4DM3b5h3 model: claude-sonnet-4-5-20250929 role: assistant stop_reason: end_turn @@ -154,10 +63,8 @@ interactions: ephemeral_5m_input_tokens: 0 cache_creation_input_tokens: 0 cache_read_input_tokens: 0 - input_tokens: 11432 - output_tokens: 244 - server_tool_use: - web_search_requests: 1 + input_tokens: 2218 + output_tokens: 13 service_tier: standard status: code: 200 @@ -171,7 +78,7 @@ interactions: connection: - keep-alive content-length: - - '946' + - '286' content-type: - application/json host: @@ -181,29 +88,7 @@ interactions: input: - content: What day is today? role: user - - content: Let me search to find today's date. - role: assistant - - content: |+ - Based on the search results, today is Thursday, August 14, 2025. Here are some additional details about the date: - - role: assistant - - content: It is the 226th day of the year 2025 in the Gregorian calendar, with 139 days remaining until the end of - the year - role: assistant - - content: | - . - - Some interesting observances for today include: - role: assistant - - content: |- - It's being celebrated as: - - Color Book Day - - National Creamsicle Day - - National Financial Awareness Day - - National Navajo Code Talkers Day - - National Tattoo Removal Day - - National Wiffle Ball Day - - Social Security Day + - content: Today is November 19, 2025. role: assistant - content: What day is tomorrow? role: user @@ -212,7 +97,7 @@ interactions: tool_choice: auto tools: - search_context_size: medium - type: web_search_preview + type: web_search uri: https://api.openai.com/v1/responses response: headers: @@ -221,15 +106,15 @@ interactions: connection: - keep-alive content-length: - - '1653' + - '1736' content-type: - application/json openai-organization: - - pydantic-28gund + - user-grnwlxd1653lxdzp921aoihz openai-processing-ms: - - '1344' + - '1712' openai-project: - - proj_dKobscVY9YJxeEaDJen54e3d + - proj_FYsIItHHgnSPdHBVMzhNBWGa openai-version: - '2020-10-01' strict-transport-security: @@ -238,9 +123,11 @@ interactions: - chunked parsed_body: background: false - created_at: 1755169964 + billing: + payer: developer + created_at: 1763595668 error: null - id: resp_689dc4abe31c81968ed493d15d8810fe0afe80ec3d42722e + id: resp_0dcd74f01910b54500691e5594957481a0ac36dde76eca939f incomplete_details: null instructions: null max_output_tokens: null @@ -252,15 +139,16 @@ interactions: - content: - annotations: [] logprobs: [] - text: Tomorrow will be **Friday, August 15, 2025**. + text: Tomorrow is November 20, 2025. type: output_text - id: msg_689dc4acfa488196a6b1ec0ebd3bd9520afe80ec3d42722e + id: msg_0dcd74f01910b54500691e5596124081a087e8fa7b2ca19d5a role: assistant status: completed type: message parallel_tool_calls: true previous_response_id: null prompt_cache_key: null + prompt_cache_retention: null reasoning: effort: null summary: null @@ -275,8 +163,9 @@ interactions: verbosity: medium tool_choice: auto tools: - - search_context_size: medium - type: web_search_preview + - filters: null + search_context_size: medium + type: web_search user_location: city: null country: US @@ -287,13 +176,13 @@ interactions: top_p: 1.0 truncation: disabled usage: - input_tokens: 458 + input_tokens: 329 input_tokens_details: cached_tokens: 0 - output_tokens: 17 + output_tokens: 12 output_tokens_details: reasoning_tokens: 0 - total_tokens: 475 + total_tokens: 341 user: null status: code: 200 diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 3af26b2a51..2044cd00ac 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -4975,16 +4975,16 @@ async def test_anthropic_server_tool_pass_history_to_another_provider( ModelResponse( parts=[ TextPart( - content='Tomorrow will be November 20, 2025.', - id='msg_0b7c351e904c86b300691e4f0dbb64819cbceace3a975191e4', + content='Tomorrow is November 20, 2025.', + id='msg_0dcd74f01910b54500691e5596124081a087e8fa7b2ca19d5a', ) ], - usage=RequestUsage(input_tokens=329, output_tokens=13, details={'reasoning_tokens': 0}), + usage=RequestUsage(input_tokens=329, output_tokens=12, details={'reasoning_tokens': 0}), model_name='gpt-4.1-2025-04-14', timestamp=IsDatetime(), provider_name='openai', provider_details={'finish_reason': 'completed'}, - provider_response_id='resp_0b7c351e904c86b300691e4f0cc554819c934fb679b1a126be', + provider_response_id='resp_0dcd74f01910b54500691e5594957481a0ac36dde76eca939f', finish_reason='stop', run_id=IsStr(), ), From c2aa94f3b0b661d051b3a82e78ed6054c89e8e50 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:01:02 -0500 Subject: [PATCH 09/14] add tests --- .../pydantic_ai/profiles/anthropic.py | 7 +- tests/profiles/test_anthropic.py | 97 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py index 6d58893074..44c4320866 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py @@ -14,7 +14,7 @@ def _schema_is_lossless(schema: JsonSchema) -> bool: # noqa: C901 from anthropic.lib._parse._transform import SupportedStringFormats def _walk(node: JsonSchema) -> bool: # noqa: C901 - if not isinstance(node, dict): + if not isinstance(node, dict): # pragma: no cover return False node = dict(node) @@ -41,7 +41,8 @@ def _walk(node: JsonSchema) -> bool: # noqa: C901 # the wrapper object must not have any other unsupported fields -> `and not node` if isinstance(any_of, list): return all(_walk(item) for item in any_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] - if isinstance(one_of, list): + if isinstance(one_of, list): # pragma: no cover + # pydantic generates anyOf for Union types, leaving this here for JSON schemas that don't come from pydantic.BaseModel return all(_walk(item) for item in one_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] if isinstance(all_of, list): return all(_walk(item) for item in all_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] @@ -68,7 +69,7 @@ def _walk(node: JsonSchema) -> bool: # noqa: C901 return False elif type_ == 'string': format_ = node.pop('format', None) - if format_ is not None and format_ not in SupportedStringFormats: + if format_ is not None and format_ not in SupportedStringFormats: # pragma: no cover return False elif type_ in {'integer', 'number', 'boolean', 'null'}: pass diff --git a/tests/profiles/test_anthropic.py b/tests/profiles/test_anthropic.py index f884a8f86e..759bda2d12 100644 --- a/tests/profiles/test_anthropic.py +++ b/tests/profiles/test_anthropic.py @@ -103,3 +103,100 @@ class SimpleModel(BaseModel): 'additionalProperties': False, } ) + + +def test_lossy_array_with_constrained_items(): + """Array with lossy item schema should be lossy.""" + + class Container(BaseModel): + items: list[Annotated[str, Field(min_length=5)]] + + transformer = AnthropicJsonSchemaTransformer(Container.model_json_schema(), strict=True) + transformer.walk() + + # Array items with constraints are lossy + assert transformer.is_strict_compatible is False + assert transformer.schema == snapshot( + { + 'properties': {'items': {'items': {'minLength': 5, 'type': 'string'}, 'title': 'Items', 'type': 'array'}}, + 'required': ['items'], + 'title': 'Container', + 'type': 'object', + } + ) + + +def test_lossy_array_min_items(): + """Array with minItems > 1 should be lossy (constraint gets dropped).""" + + class ItemList(BaseModel): + items: Annotated[list[str], Field(min_length=2)] + + transformer = AnthropicJsonSchemaTransformer(ItemList.model_json_schema(), strict=True) + transformer.walk() + + # SDK drops minItems > 1, making it lossy + assert transformer.is_strict_compatible is False + assert transformer.schema == snapshot( + { + 'properties': {'items': {'items': {'type': 'string'}, 'minItems': 2, 'title': 'Items', 'type': 'array'}}, + 'required': ['items'], + 'title': 'ItemList', + 'type': 'object', + } + ) + + +def test_lossy_unsupported_string_format(): + """String with unsupported format should be lossy (format gets dropped).""" + # Note: Using raw schema because Pydantic doesn't expose custom format generation in normal API + schema = { + 'type': 'object', + 'properties': { + 'value': { + 'type': 'string', + 'format': 'regex', # Unsupported format (not in SupportedStringFormats) + } + }, + 'required': ['value'], + } + + transformer = AnthropicJsonSchemaTransformer(schema, strict=True) + transformer.walk() + + # SDK drops unsupported formats, making it lossy + assert transformer.is_strict_compatible is False + assert transformer.schema == snapshot( + {'type': 'object', 'properties': {'value': {'type': 'string', 'format': 'regex'}}, 'required': ['value']} + ) + + +def test_lossy_nested_defs(): + """Schema with $defs containing nested schemas with constraints should be lossy.""" + + class ConstrainedString(BaseModel): + value: Annotated[str, Field(min_length=5)] + + class Container(BaseModel): + item: ConstrainedString + + transformer = AnthropicJsonSchemaTransformer(Container.model_json_schema(), strict=True) + transformer.walk() + + # Nested schema in $defs has constraints, making it lossy + assert transformer.is_strict_compatible is False + assert transformer.schema == snapshot( + { + '$defs': { + 'ConstrainedString': { + 'properties': {'value': {'minLength': 5, 'type': 'string'}}, + 'required': ['value'], + 'type': 'object', + } + }, + 'properties': {'item': {'$ref': '#/$defs/ConstrainedString'}}, + 'required': ['item'], + 'title': 'Container', + 'type': 'object', + } + ) From dea5f0fcd6db1198d16605b5b42ea550a0cbcf78 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:23:52 -0500 Subject: [PATCH 10/14] check compatibility for strict tool defs --- .../pydantic_ai/models/anthropic.py | 2 +- .../pydantic_ai/profiles/anthropic.py | 28 ++++++++++++ tests/models/anthropic/test_output.py | 6 +-- tests/profiles/test_anthropic.py | 44 +++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 8608e02c94..45e118bca6 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -490,7 +490,7 @@ def _get_tools( strict_tools_requested = False for tool_def in model_request_parameters.tool_defs.values(): tools.append(self._map_tool_definition(tool_def)) - if tool_def.strict: + if tool_def.strict and self.profile.supports_json_schema_output: strict_tools_requested = True # Add cache_control to the last tool if enabled diff --git a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py index 44c4320866..c99ba52254 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py @@ -9,7 +9,35 @@ def _schema_is_lossless(schema: JsonSchema) -> bool: # noqa: C901 """Return True when `anthropic.transform_schema` won't need to drop constraints. + Anthropic's structured output API only supports a subset of JSON Schema features. + This function detects whether a schema uses only supported features, allowing us + to safely enable strict mode for guaranteed server-side validation. + Checks are performed based on https://docs.claude.com/en/docs/build-with-claude/structured-outputs#how-sdk-transformation-works + + Args: + schema: JSON Schema dictionary (typically from BaseModel.model_json_schema()) + + Returns: + True if schema is lossless (all constraints preserved), False if lossy + + Examples: + Lossless schemas (constraints preserved): + >>> _schema_is_lossless({'type': 'string'}) + True + >>> _schema_is_lossless({'type': 'object', 'properties': {'name': {'type': 'string'}}}) + True + + Lossy schemas (constraints dropped): + >>> _schema_is_lossless({'type': 'string', 'minLength': 5}) + False + >>> _schema_is_lossless({'type': 'array', 'items': {'type': 'string'}, 'minItems': 2}) + False + + Note: + Some checks handle edge cases that rarely occur with Pydantic-generated schemas: + - oneOf: Pydantic generates anyOf for Union types + - Custom formats: Pydantic doesn't expose custom format generation in normal API """ from anthropic.lib._parse._transform import SupportedStringFormats diff --git a/tests/models/anthropic/test_output.py b/tests/models/anthropic/test_output.py index 5b32641842..803aca42b5 100644 --- a/tests/models/anthropic/test_output.py +++ b/tests/models/anthropic/test_output.py @@ -20,7 +20,7 @@ from ..test_anthropic import MockAnthropic, get_mock_chat_completion_kwargs with try_import() as imports_successful: - from anthropic import AsyncAnthropic + from anthropic import AsyncAnthropic, omit as OMIT from anthropic.types.beta import BetaMessage from pydantic_ai.models.anthropic import AnthropicModel @@ -268,7 +268,7 @@ def get_weather(location: str) -> str: # pragma: no cover completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[0] tools = completion_kwargs['tools'] - betas = completion_kwargs.get('betas', []) + betas = completion_kwargs.get('betas') assert tools == snapshot( [ { @@ -283,7 +283,7 @@ def get_weather(location: str) -> str: # pragma: no cover } ] ) - assert betas == snapshot(['structured-outputs-2025-11-13']) + assert betas is OMIT async def test_anthropic_native_output_multiple( diff --git a/tests/profiles/test_anthropic.py b/tests/profiles/test_anthropic.py index 759bda2d12..585b49622b 100644 --- a/tests/profiles/test_anthropic.py +++ b/tests/profiles/test_anthropic.py @@ -1,3 +1,21 @@ +"""Tests for Anthropic JSON schema transformer and strict compatibility detection. + +The AnthropicJsonSchemaTransformer checks whether schemas are 'lossless' - meaning +Anthropic's SDK won't drop validation constraints during transformation to their +structured output format. + +When constraints would be dropped (making the schema 'lossy'), `is_strict_compatible` +is set to False. This prevents automatic use of strict mode, which would make +server-side validation impossible since the constraints wouldn't be enforced. + +Key concepts: +- **Lossless**: Schema constraints are fully preserved by Anthropic's transformer +- **Lossy**: SDK drops constraints (e.g., minLength, pattern, minItems > 1) +- **Strict compatible**: Schema can safely use strict=True for guaranteed validation + +See: https://docs.claude.com/en/docs/build-with-claude/structured-outputs +""" + from __future__ import annotations as _annotations from typing import Annotated @@ -57,6 +75,14 @@ class User(BaseModel): # SDK drops minLength, making it lossy assert transformer.is_strict_compatible is False + assert transformer.schema == snapshot( + { + 'properties': {'username': {'minLength': 3, 'title': 'Username', 'type': 'string'}}, + 'required': ['username'], + 'title': 'User', + 'type': 'object', + } + ) def test_lossy_number_constraints(): @@ -70,6 +96,14 @@ class Product(BaseModel): # SDK drops minimum, making it lossy assert transformer.is_strict_compatible is False + assert transformer.schema == snapshot( + { + 'properties': {'price': {'minimum': 0.0, 'title': 'Price', 'type': 'number'}}, + 'required': ['price'], + 'title': 'Product', + 'type': 'object', + } + ) def test_lossy_pattern_constraint(): @@ -83,6 +117,16 @@ class Email(BaseModel): # SDK drops pattern, making it lossy assert transformer.is_strict_compatible is False + assert transformer.schema == snapshot( + { + 'properties': { + 'address': {'pattern': '^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$', 'title': 'Address', 'type': 'string'} + }, + 'required': ['address'], + 'title': 'Email', + 'type': 'object', + } + ) def test_transformer_output(): From cbcb7838e81038510b02c8d6579f3cb186c5ddbc Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:54:40 -0500 Subject: [PATCH 11/14] - adds pragmas to transformer - makes schema tests clearer by showing original and transofrmed - handles multiple beta-heards (wasn't hadnled previously) - unifies betas logic around sdk's betas parameter --- .../pydantic_ai/models/anthropic.py | 98 +++++++++++----- .../pydantic_ai/profiles/anthropic.py | 6 +- ...ropic_native_output_multiple_language.yaml | 107 ++++++++++++++++++ tests/models/anthropic/conftest.py | 12 -- tests/models/anthropic/test_output.py | 59 ++++++++++ tests/models/test_anthropic.py | 8 +- tests/profiles/test_anthropic.py | 92 +++++++++------ 7 files changed, 303 insertions(+), 79 deletions(-) create mode 100644 tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 45e118bca6..014974e667 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -330,23 +330,19 @@ async def _messages_create( model_request_parameters: ModelRequestParameters, ) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]: # standalone function to make it easier to override - tools, strict_tools_requested = self._get_tools(model_request_parameters, model_settings) - tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters) + tools = self._get_tools(model_request_parameters, model_settings) + tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, model_request_parameters) native_format = self._native_output_format(model_request_parameters) tool_choice = self._infer_tool_choice(tools, model_settings, model_request_parameters) system_prompt, anthropic_messages = await self._map_message(messages, model_request_parameters, model_settings) - # Build betas list for SDK - betas: list[str] = list(beta_features) - if strict_tools_requested or native_format: - betas.append('structured-outputs-2025-11-13') + betas = self._get_required_betas(model_request_parameters) + betas.update(builtin_tool_betas) try: - # We use SDK's betas parameter instead of manual header manipulation - extra_headers = model_settings.get('extra_headers', {}) - extra_headers.setdefault('User-Agent', get_user_agent()) + betas_list, extra_headers = self._prepare_betas_and_headers(betas, model_settings) return await self.client.beta.messages.create( max_tokens=model_settings.get('max_tokens', 4096), @@ -357,7 +353,7 @@ async def _messages_create( tool_choice=tool_choice or OMIT, mcp_servers=mcp_servers or OMIT, output_format=native_format or OMIT, - betas=betas or OMIT, + betas=betas_list or OMIT, stream=stream, thinking=model_settings.get('anthropic_thinking', OMIT), stop_sequences=model_settings.get('stop_sequences', OMIT), @@ -383,15 +379,18 @@ async def _messages_count_tokens( raise UserError('AsyncAnthropicBedrock client does not support `count_tokens` api.') # standalone function to make it easier to override - tools, _ = self._get_tools(model_request_parameters, model_settings) - tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters) + tools = self._get_tools(model_request_parameters, model_settings) + tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, model_request_parameters) tool_choice = self._infer_tool_choice(tools, model_settings, model_request_parameters) system_prompt, anthropic_messages = await self._map_message(messages, model_request_parameters, model_settings) + betas = self._get_required_betas(model_request_parameters) + betas.update(builtin_tool_betas) + try: - extra_headers = self._map_extra_headers(beta_features, model_settings) + betas_list, extra_headers = self._prepare_betas_and_headers(betas, model_settings) return await self.client.beta.messages.count_tokens( system=system_prompt or OMIT, @@ -400,6 +399,7 @@ async def _messages_count_tokens( tools=tools or OMIT, tool_choice=tool_choice or OMIT, mcp_servers=mcp_servers or OMIT, + betas=betas_list or OMIT, thinking=model_settings.get('anthropic_thinking', OMIT), timeout=model_settings.get('timeout', NOT_GIVEN), extra_headers=extra_headers, @@ -485,13 +485,10 @@ async def _process_streamed_response( def _get_tools( self, model_request_parameters: ModelRequestParameters, model_settings: AnthropicModelSettings - ) -> tuple[list[BetaToolUnionParam], bool]: + ) -> list[BetaToolUnionParam]: tools: list[BetaToolUnionParam] = [] - strict_tools_requested = False for tool_def in model_request_parameters.tool_defs.values(): tools.append(self._map_tool_definition(tool_def)) - if tool_def.strict and self.profile.supports_json_schema_output: - strict_tools_requested = True # Add cache_control to the last tool if enabled if tools and (cache_tool_defs := model_settings.get('anthropic_cache_tool_definitions')): @@ -500,12 +497,35 @@ def _get_tools( last_tool = tools[-1] last_tool['cache_control'] = BetaCacheControlEphemeralParam(type='ephemeral', ttl=ttl) - return tools, strict_tools_requested + return tools + + def _get_required_betas(self, model_request_parameters: ModelRequestParameters) -> set[str]: + """Determine which beta features are needed based on tools and output format. + + Args: + model_request_parameters: Model request parameters containing tools and output settings + + Returns: + Set of beta feature strings (naturally deduplicated) + """ + betas: set[str] = set() + + has_strict_tools = any( + tool_def.strict and self.profile.supports_json_schema_output + for tool_def in model_request_parameters.tool_defs.values() + ) + + has_native_output = model_request_parameters.output_mode == 'native' + + if has_strict_tools or has_native_output: + betas.add('structured-outputs-2025-11-13') + + return betas def _add_builtin_tools( self, tools: list[BetaToolUnionParam], model_request_parameters: ModelRequestParameters - ) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], list[str]]: - beta_features: list[str] = [] + ) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], set[str]]: + beta_features: set[str] = set() mcp_servers: list[BetaRequestMCPServerURLDefinitionParam] = [] for tool in model_request_parameters.builtin_tools: if isinstance(tool, WebSearchTool): @@ -522,14 +542,14 @@ def _add_builtin_tools( ) elif isinstance(tool, CodeExecutionTool): # pragma: no branch tools.append(BetaCodeExecutionTool20250522Param(name='code_execution', type='code_execution_20250522')) - beta_features.append('code-execution-2025-05-22') + beta_features.add('code-execution-2025-05-22') elif isinstance(tool, MemoryTool): # pragma: no branch if 'memory' not in model_request_parameters.tool_defs: raise UserError("Built-in `MemoryTool` requires a 'memory' tool to be defined.") # Replace the memory tool definition with the built-in memory tool tools = [tool for tool in tools if tool['name'] != 'memory'] tools.append(BetaMemoryTool20250818Param(name='memory', type='memory_20250818')) - beta_features.append('context-management-2025-06-27') + beta_features.add('context-management-2025-06-27') elif isinstance(tool, MCPServerTool) and tool.url: mcp_server_url_definition_param = BetaRequestMCPServerURLDefinitionParam( type='url', @@ -544,7 +564,7 @@ def _add_builtin_tools( if tool.authorization_token: # pragma: no cover mcp_server_url_definition_param['authorization_token'] = tool.authorization_token mcp_servers.append(mcp_server_url_definition_param) - beta_features.append('mcp-client-2025-04-04') + beta_features.add('mcp-client-2025-04-04') else: # pragma: no cover raise UserError( f'`{tool.__class__.__name__}` is not supported by `AnthropicModel`. If it should be, please file an issue.' @@ -572,15 +592,33 @@ def _infer_tool_choice( return tool_choice - def _map_extra_headers(self, beta_features: list[str], model_settings: AnthropicModelSettings) -> dict[str, str]: - """Apply beta_features to extra_headers in model_settings.""" + def _prepare_betas_and_headers( + self, betas: set[str], model_settings: AnthropicModelSettings + ) -> tuple[list[str], dict[str, str]]: + """Prepare beta features list and extra headers for API request. + + Handles merging custom anthropic-beta header from extra_headers into betas set + and ensuring User-Agent is set. + + Args: + betas: Set of beta feature strings (naturally deduplicated) + model_settings: Model settings containing extra_headers + + Returns: + Tuple of (betas list, extra_headers dict) + """ extra_headers = model_settings.get('extra_headers', {}) extra_headers.setdefault('User-Agent', get_user_agent()) - if beta_features: - if 'anthropic-beta' in extra_headers: - beta_features.insert(0, extra_headers['anthropic-beta']) - extra_headers['anthropic-beta'] = ','.join(beta_features) - return extra_headers + + if 'anthropic-beta' in extra_headers: + beta_value = extra_headers['anthropic-beta'] + for beta in beta_value.split(','): + beta_stripped = beta.strip() + if beta_stripped: + betas.add(beta_stripped) + del extra_headers['anthropic-beta'] + + return sorted(betas), extra_headers async def _map_message( # noqa: C901 self, diff --git a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py index c99ba52254..03e8d47611 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py @@ -72,10 +72,10 @@ def _walk(node: JsonSchema) -> bool: # noqa: C901 if isinstance(one_of, list): # pragma: no cover # pydantic generates anyOf for Union types, leaving this here for JSON schemas that don't come from pydantic.BaseModel return all(_walk(item) for item in one_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] - if isinstance(all_of, list): + if isinstance(all_of, list): # pragma: no cover return all(_walk(item) for item in all_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] - if type_ is None: + if type_ is None: # pragma: no cover return False if type_ == 'object': @@ -101,7 +101,7 @@ def _walk(node: JsonSchema) -> bool: # noqa: C901 return False elif type_ in {'integer', 'number', 'boolean', 'null'}: pass - else: + else: # pragma: no cover return False return not node diff --git a/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml new file mode 100644 index 0000000000..67ff613b4a --- /dev/null +++ b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml @@ -0,0 +1,107 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '1162' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What language is spoken in the user country? + type: text + role: user + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + result: + anyOf: + - additionalProperties: false + description: A city and its country. + properties: + data: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + kind: + description: '{const: CityLocation}' + type: string + required: + - kind + - data + type: object + - additionalProperties: false + properties: + data: + additionalProperties: false + properties: + country: + type: string + language: + type: string + required: + - country + - language + type: object + kind: + description: '{const: CountryLanguage}' + type: string + required: + - kind + - data + type: object + required: + - result + type: object + type: json_schema + stream: false + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: get_user_country + strict: true + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '130' + content-type: + - application/json + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + parsed_body: + error: + message: invalid x-api-key + type: authentication_error + request_id: req_011CVKJbTXMN68Pzm15QEJpK + type: error + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/tests/models/anthropic/conftest.py b/tests/models/anthropic/conftest.py index 968f6374f2..e0fe84bc7a 100644 --- a/tests/models/anthropic/conftest.py +++ b/tests/models/anthropic/conftest.py @@ -53,18 +53,6 @@ def mock_sonnet_4_5(allow_model_requests: None) -> tuple[AnthropicModel, AsyncAn return model, mock_client -@pytest.fixture -def mock_sonnet_4_0(allow_model_requests: None) -> tuple[AnthropicModel, AsyncAnthropic]: - """Mock claude-sonnet-4-0 model for unit tests.""" - c = completion_message( - [BetaTextBlock(text='response', type='text')], - BetaUsage(input_tokens=5, output_tokens=10), - ) - mock_client = MockAnthropic.create_mock(c) - model = AnthropicModel('claude-sonnet-4-0', provider=AnthropicProvider(anthropic_client=mock_client)) - return model, mock_client - - # Schema fixtures @pytest.fixture def city_location_schema() -> type[BaseModel]: diff --git a/tests/models/anthropic/test_output.py b/tests/models/anthropic/test_output.py index 803aca42b5..c76b387230 100644 --- a/tests/models/anthropic/test_output.py +++ b/tests/models/anthropic/test_output.py @@ -221,6 +221,62 @@ def test_anthropic_native_output_strict_mode( assert betas == snapshot(['structured-outputs-2025-11-13']) +def test_anthropic_native_output_merge_beta_headers( + allow_model_requests: None, + mock_sonnet_4_5: tuple[AnthropicModel, AsyncAnthropic], + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test that custom anthropic-beta headers are merged with structured output beta features.""" + from pydantic_ai.models.anthropic import AnthropicModelSettings + + model, mock_client = mock_sonnet_4_5 + + # User provides their own beta feature via extra_headers + agent = make_agent( + model, + output_type=NativeOutput(city_location_schema), + model_settings=AnthropicModelSettings(extra_headers={'anthropic-beta': 'custom-feature-2025-01-01'}), + ) + agent.run_sync('What is the capital of France?') + + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[-1] + betas = completion_kwargs['betas'] + + # Should merge custom beta with structured-outputs beta + assert betas == snapshot(['custom-feature-2025-01-01', 'structured-outputs-2025-11-13']) + + +def test_anthropic_native_output_merge_beta_headers_comma_separated( + allow_model_requests: None, + mock_sonnet_4_5: tuple[AnthropicModel, AsyncAnthropic], + city_location_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test that comma-separated custom anthropic-beta headers are properly split and merged.""" + from pydantic_ai.models.anthropic import AnthropicModelSettings + + model, mock_client = mock_sonnet_4_5 + + # User provides multiple beta features via comma-separated header + agent = make_agent( + model, + output_type=NativeOutput(city_location_schema), + model_settings=AnthropicModelSettings( + extra_headers={'anthropic-beta': 'custom-feature-1, custom-feature-2, custom-feature-3'} + ), + ) + agent.run_sync('What is the capital of France?') + + completion_kwargs = get_mock_chat_completion_kwargs(mock_client)[-1] + betas = completion_kwargs['betas'] + + # Should split comma-separated values and merge with structured-outputs beta (sorted) + assert betas == snapshot( + ['custom-feature-1', 'custom-feature-2', 'custom-feature-3', 'structured-outputs-2025-11-13'] + ) + + def test_anthropic_strict_tools_sonnet_4_5(allow_model_requests: None, weather_tool_responses: list[BetaMessage]): """Test that strict tool definitions are properly sent for supporting models.""" mock_client = MockAnthropic.create_mock(weather_tool_responses) @@ -306,6 +362,9 @@ async def get_user_country() -> str: if isinstance(result.output, city_location_schema): assert result.output.city == 'Paris' # type: ignore[attr-defined] assert result.output.country == 'France' # type: ignore[attr-defined] + else: # pragma: no cover + # This branch is not hit in this test, but we keep the structure for completeness + pass async def test_anthropic_auto_mode_sonnet_4_5( diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 2044cd00ac..cc7cce63d0 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -77,6 +77,7 @@ BetaMemoryTool20250818ViewCommand, BetaMessage, BetaMessageDeltaUsage, + BetaMessageTokensCount, BetaRawContentBlockDeltaEvent, BetaRawContentBlockStartEvent, BetaRawContentBlockStopEvent, @@ -144,7 +145,7 @@ def beta(self) -> AsyncBeta: @cached_property def messages(self) -> Any: - return type('Messages', (), {'create': self.messages_create}) + return type('Messages', (), {'create': self.messages_create, 'count_tokens': self.messages_count_tokens}) @classmethod def create_mock(cls, messages_: MockAnthropicMessage | Sequence[MockAnthropicMessage]) -> AsyncAnthropic: @@ -180,6 +181,11 @@ async def messages_create( self.index += 1 return response + async def messages_count_tokens(self, *_args: Any, **kwargs: Any) -> BetaMessageTokensCount: + self.chat_completion_kwargs.append({k: v for k, v in kwargs.items() if v is not NOT_GIVEN}) + # Return a mock token count + return BetaMessageTokensCount(input_tokens=10) + def completion_message(content: list[BetaContentBlock], usage: BetaUsage) -> BetaMessage: return BetaMessage( diff --git a/tests/profiles/test_anthropic.py b/tests/profiles/test_anthropic.py index 585b49622b..a3429f399f 100644 --- a/tests/profiles/test_anthropic.py +++ b/tests/profiles/test_anthropic.py @@ -70,12 +70,15 @@ def test_lossy_string_constraints(): class User(BaseModel): username: Annotated[str, Field(min_length=3)] - transformer = AnthropicJsonSchemaTransformer(User.model_json_schema(), strict=True) - transformer.walk() + original_schema = User.model_json_schema() + transformer = AnthropicJsonSchemaTransformer(original_schema, strict=True) + result = transformer.walk() # SDK drops minLength, making it lossy assert transformer.is_strict_compatible is False - assert transformer.schema == snapshot( + + # Original schema has minLength constraint + assert original_schema == snapshot( { 'properties': {'username': {'minLength': 3, 'title': 'Username', 'type': 'string'}}, 'required': ['username'], @@ -84,6 +87,16 @@ class User(BaseModel): } ) + # Transformed schema has constraint dropped and moved to description + assert result == snapshot( + { + 'type': 'object', + 'properties': {'username': {'type': 'string', 'description': '{minLength: 3}'}}, + 'required': ['username'], + 'additionalProperties': False, + } + ) + def test_lossy_number_constraints(): """Number with minimum constraint should be lossy (constraint gets dropped).""" @@ -92,16 +105,17 @@ class Product(BaseModel): price: Annotated[float, Field(ge=0)] transformer = AnthropicJsonSchemaTransformer(Product.model_json_schema(), strict=True) - transformer.walk() + result = transformer.walk() # SDK drops minimum, making it lossy assert transformer.is_strict_compatible is False - assert transformer.schema == snapshot( + # Transformed schema has constraint dropped and moved to description + assert result == snapshot( { - 'properties': {'price': {'minimum': 0.0, 'title': 'Price', 'type': 'number'}}, - 'required': ['price'], - 'title': 'Product', 'type': 'object', + 'properties': {'price': {'type': 'number', 'description': '{minimum: 0}'}}, + 'required': ['price'], + 'additionalProperties': False, } ) @@ -112,12 +126,15 @@ def test_lossy_pattern_constraint(): class Email(BaseModel): address: Annotated[str, Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')] - transformer = AnthropicJsonSchemaTransformer(Email.model_json_schema(), strict=True) - transformer.walk() + original_schema = Email.model_json_schema() + transformer = AnthropicJsonSchemaTransformer(original_schema, strict=True) + result = transformer.walk() # SDK drops pattern, making it lossy assert transformer.is_strict_compatible is False - assert transformer.schema == snapshot( + + # Original schema has pattern constraint + assert original_schema == snapshot( { 'properties': { 'address': {'pattern': '^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$', 'title': 'Address', 'type': 'string'} @@ -128,6 +145,16 @@ class Email(BaseModel): } ) + # Transformed schema has constraint dropped and moved to description + assert result == snapshot( + { + 'type': 'object', + 'properties': {'address': {'type': 'string', 'description': '{pattern: ^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$}'}}, + 'required': ['address'], + 'additionalProperties': False, + } + ) + def test_transformer_output(): """Verify transformer produces expected output for a simple model.""" @@ -160,14 +187,6 @@ class Container(BaseModel): # Array items with constraints are lossy assert transformer.is_strict_compatible is False - assert transformer.schema == snapshot( - { - 'properties': {'items': {'items': {'minLength': 5, 'type': 'string'}, 'title': 'Items', 'type': 'array'}}, - 'required': ['items'], - 'title': 'Container', - 'type': 'object', - } - ) def test_lossy_array_min_items(): @@ -181,14 +200,6 @@ class ItemList(BaseModel): # SDK drops minItems > 1, making it lossy assert transformer.is_strict_compatible is False - assert transformer.schema == snapshot( - { - 'properties': {'items': {'items': {'type': 'string'}, 'minItems': 2, 'title': 'Items', 'type': 'array'}}, - 'required': ['items'], - 'title': 'ItemList', - 'type': 'object', - } - ) def test_lossy_unsupported_string_format(): @@ -210,9 +221,6 @@ def test_lossy_unsupported_string_format(): # SDK drops unsupported formats, making it lossy assert transformer.is_strict_compatible is False - assert transformer.schema == snapshot( - {'type': 'object', 'properties': {'value': {'type': 'string', 'format': 'regex'}}, 'required': ['value']} - ) def test_lossy_nested_defs(): @@ -224,12 +232,14 @@ class ConstrainedString(BaseModel): class Container(BaseModel): item: ConstrainedString - transformer = AnthropicJsonSchemaTransformer(Container.model_json_schema(), strict=True) - transformer.walk() + original = Container.model_json_schema() + transformer = AnthropicJsonSchemaTransformer(original, strict=True) + result = transformer.walk() # Nested schema in $defs has constraints, making it lossy assert transformer.is_strict_compatible is False - assert transformer.schema == snapshot( + + assert original == snapshot( { '$defs': { 'ConstrainedString': { @@ -244,3 +254,19 @@ class Container(BaseModel): 'type': 'object', } ) + assert result == snapshot( + { + '$defs': { + 'ConstrainedString': { + 'type': 'object', + 'properties': {'value': {'type': 'string', 'description': '{minLength: 5}'}}, + 'additionalProperties': False, + 'required': ['value'], + } + }, + 'type': 'object', + 'properties': {'item': {'$ref': '#/$defs/ConstrainedString'}}, + 'additionalProperties': False, + 'required': ['item'], + } + ) From b25da577ab064a341d1f86f30b861e7996e300c9 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:57:49 -0500 Subject: [PATCH 12/14] fix coverage and fix cassette --- .../pydantic_ai/models/anthropic.py | 2 +- ...ropic_native_output_multiple_language.yaml | 169 +++++++++++++++++- tests/models/anthropic/test_output.py | 29 ++- tests/models/test_anthropic.py | 17 ++ 4 files changed, 204 insertions(+), 13 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 014974e667..0c7fd90624 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -614,7 +614,7 @@ def _prepare_betas_and_headers( beta_value = extra_headers['anthropic-beta'] for beta in beta_value.split(','): beta_stripped = beta.strip() - if beta_stripped: + if beta_stripped: # pragma: no branch betas.add(beta_stripped) del extra_headers['anthropic-beta'] diff --git a/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml index 67ff613b4a..9b135b6e8c 100644 --- a/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml +++ b/tests/models/anthropic/cassettes/test_output/test_anthropic_native_output_multiple_language.yaml @@ -90,18 +90,171 @@ interactions: connection: - keep-alive content-length: - - '130' + - '477' content-type: - application/json + retry-after: + - '18' strict-transport-security: - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked parsed_body: - error: - message: invalid x-api-key - type: authentication_error - request_id: req_011CVKJbTXMN68Pzm15QEJpK - type: error + content: + - id: toolu_019JQjJerVPXLfa3AcmLrjuH + input: {} + name: get_user_country + type: tool_use + id: msg_01DhgrVv5EJDsccFZ587B9bN + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: tool_use + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 1047 + output_tokens: 38 + service_tier: standard + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '1424' + content-type: + - application/json + host: + - api.anthropic.com + method: POST + parsed_body: + max_tokens: 4096 + messages: + - content: + - text: What language is spoken in the user country? + type: text + role: user + - content: + - id: toolu_019JQjJerVPXLfa3AcmLrjuH + input: {} + name: get_user_country + type: tool_use + role: assistant + - content: + - content: France + is_error: false + tool_use_id: toolu_019JQjJerVPXLfa3AcmLrjuH + type: tool_result + role: user + model: claude-sonnet-4-5 + output_format: + schema: + additionalProperties: false + properties: + result: + anyOf: + - additionalProperties: false + description: A city and its country. + properties: + data: + additionalProperties: false + properties: + city: + type: string + country: + type: string + required: + - city + - country + type: object + kind: + description: '{const: CityLocation}' + type: string + required: + - kind + - data + type: object + - additionalProperties: false + properties: + data: + additionalProperties: false + properties: + country: + type: string + language: + type: string + required: + - country + - language + type: object + kind: + description: '{const: CountryLanguage}' + type: string + required: + - kind + - data + type: object + required: + - result + type: object + type: json_schema + stream: false + tool_choice: + type: auto + tools: + - description: '' + input_schema: + additionalProperties: false + properties: {} + type: object + name: get_user_country + strict: true + uri: https://api.anthropic.com/v1/messages?beta=true + response: + headers: + connection: + - keep-alive + content-length: + - '509' + content-type: + - application/json + retry-after: + - '15' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + content: + - text: '{"result":{"kind":"CountryLanguage","data":{"country":"France","language":"French"}}}' + type: text + id: msg_01LeExcS3t3YwwYs1tram43F + model: claude-sonnet-4-5-20250929 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: + cache_creation: + ephemeral_1h_input_tokens: 0 + ephemeral_5m_input_tokens: 0 + cache_creation_input_tokens: 0 + cache_read_input_tokens: 0 + input_tokens: 1099 + output_tokens: 26 + service_tier: standard status: - code: 401 - message: Unauthorized + code: 200 + message: OK version: 1 diff --git a/tests/models/anthropic/test_output.py b/tests/models/anthropic/test_output.py index c76b387230..791ddab508 100644 --- a/tests/models/anthropic/test_output.py +++ b/tests/models/anthropic/test_output.py @@ -359,12 +359,31 @@ async def get_user_country() -> str: result = await agent.run('What is the capital of the user country?') # Should return CityLocation since we asked about capital assert isinstance(result.output, city_location_schema | country_language_schema) - if isinstance(result.output, city_location_schema): + if isinstance(result.output, city_location_schema): # pragma: no branch assert result.output.city == 'Paris' # type: ignore[attr-defined] assert result.output.country == 'France' # type: ignore[attr-defined] - else: # pragma: no cover - # This branch is not hit in this test, but we keep the structure for completeness - pass + + +async def test_anthropic_native_output_multiple_language( + allow_model_requests: None, + anthropic_sonnet_4_5: AnthropicModel, + city_location_schema: type[BaseModel], + country_language_schema: type[BaseModel], + make_agent: MakeAgentType, +): + """Test native output with union returns the second schema type.""" + agent = make_agent(anthropic_sonnet_4_5, output_type=NativeOutput([city_location_schema, country_language_schema])) + + @agent.tool_plain + async def get_user_country() -> str: + return 'France' + + result = await agent.run('What language is spoken in the user country?') + # Should return CountryLanguage since we asked about language + assert isinstance(result.output, city_location_schema | country_language_schema) + if isinstance(result.output, country_language_schema): + assert result.output.country == 'France' # type: ignore[attr-defined] + assert result.output.language == 'French' # type: ignore[attr-defined] async def test_anthropic_auto_mode_sonnet_4_5( @@ -375,6 +394,7 @@ async def test_anthropic_auto_mode_sonnet_4_5( ): """Test auto mode with sonnet-4.5 (should use native output automatically).""" agent = make_agent(anthropic_sonnet_4_5, output_type=city_location_schema) + assert agent.model.profile.supports_json_schema_output # pyright: ignore[reportUnknownMemberType,reportAttributeAccessIssue,reportOptionalMemberAccess] result = await agent.run('What is the capital of France?') assert result.output == snapshot(city_location_schema(city='Paris', country='France')) @@ -388,6 +408,7 @@ async def test_anthropic_auto_mode_sonnet_4_0( ): """Test auto mode with sonnet-4.0 (should fall back to prompted output).""" agent = make_agent(anthropic_sonnet_4_0, output_type=city_location_schema) + assert agent.model.profile.supports_json_schema_output is False # pyright: ignore[reportUnknownMemberType,reportAttributeAccessIssue,reportOptionalMemberAccess] result = await agent.run('What is the capital of France?') assert result.output == snapshot(city_location_schema(city='Paris', country='France')) diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index cc7cce63d0..94fdfce73d 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -6518,6 +6518,23 @@ async def test_anthropic_model_usage_limit_not_exceeded( ) +async def test_anthropic_count_tokens_with_mock(allow_model_requests: None): + """Test that count_tokens is called on the mock client.""" + c = completion_message( + [BetaTextBlock(text='hello world', type='text')], BetaUsage(input_tokens=5, output_tokens=10) + ) + mock_client = MockAnthropic.create_mock(c) + m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + agent = Agent(m) + + result = await agent.run('hello', usage_limits=UsageLimits(input_tokens_limit=20, count_tokens_before_request=True)) + assert result.output == 'hello world' + assert len(mock_client.chat_completion_kwargs) == 2 # type: ignore + count_tokens_kwargs = mock_client.chat_completion_kwargs[0] # type: ignore + assert 'model' in count_tokens_kwargs + assert 'messages' in count_tokens_kwargs + + @pytest.mark.vcr() async def test_anthropic_count_tokens_error(allow_model_requests: None, anthropic_api_key: str): """Test that errors convert to ModelHTTPError.""" From 8f278edadd3baa8a7281fe695a9796fe38f7957e Mon Sep 17 00:00:00 2001 From: David <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:55:05 -0500 Subject: [PATCH 13/14] beautify beta header merging Co-authored-by: Douwe Maan --- pydantic_ai_slim/pydantic_ai/models/anthropic.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 0c7fd90624..1569f4717b 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -610,13 +610,12 @@ def _prepare_betas_and_headers( extra_headers = model_settings.get('extra_headers', {}) extra_headers.setdefault('User-Agent', get_user_agent()) - if 'anthropic-beta' in extra_headers: - beta_value = extra_headers['anthropic-beta'] - for beta in beta_value.split(','): - beta_stripped = beta.strip() - if beta_stripped: # pragma: no branch - betas.add(beta_stripped) - del extra_headers['anthropic-beta'] + if beta_header := extra_headers.pop('anthropic-beta', None): + betas.update({ + stripped_beta + for beta in beta_header.split(',') + if (stripped_beta := beta.strip()) + }) return sorted(betas), extra_headers From 9ab40a372664a91b4f452ff4afa5edfd60827837 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:51:07 -0500 Subject: [PATCH 14/14] coverage --- tests/models/anthropic/test_output.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/models/anthropic/test_output.py b/tests/models/anthropic/test_output.py index 791ddab508..2b64e26d12 100644 --- a/tests/models/anthropic/test_output.py +++ b/tests/models/anthropic/test_output.py @@ -380,10 +380,9 @@ async def get_user_country() -> str: result = await agent.run('What language is spoken in the user country?') # Should return CountryLanguage since we asked about language - assert isinstance(result.output, city_location_schema | country_language_schema) - if isinstance(result.output, country_language_schema): - assert result.output.country == 'France' # type: ignore[attr-defined] - assert result.output.language == 'French' # type: ignore[attr-defined] + assert isinstance(result.output, country_language_schema), 're run test until llm outputs country_language_schema' + assert result.output.country == 'France' # type: ignore[attr-defined] + assert result.output.language == 'French' # type: ignore[attr-defined] async def test_anthropic_auto_mode_sonnet_4_5(