diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py
index de33a08f7a..38f4c34bcb 100644
--- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py
+++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py
@@ -835,14 +835,31 @@ 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:
- return {
+ def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam:
+ """Map tool definition to Anthropic format with strict mode support.
+
+ Follows the same pattern as OpenAI (line 768): checks if f.strict is truthy,
+ not just checking if it's not None (which was PR #3457's critical mistake).
+
+ Args:
+ f: Tool definition to map
+
+ Returns:
+ BetaToolParam with strict mode set if applicable
+ """
+ tool_param: BetaToolParam = {
'name': f.name,
'description': f.description or '',
'input_schema': f.parameters_json_schema,
}
+ # ✅ CRITICAL: Only add strict if explicitly True (not None, not False)
+ # This prevents the strict field from leaking into tools that don't explicitly set it
+ if f.strict is True:
+ tool_param['strict'] = True # type: ignore[typeddict-item]
+
+ return tool_param
+
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 f6a2755819..0fe0fa25fa 100644
--- a/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py
+++ b/pydantic_ai_slim/pydantic_ai/profiles/anthropic.py
@@ -1,8 +1,92 @@
from __future__ import annotations as _annotations
+from dataclasses import dataclass
+from typing import Any
+
+from .._json_schema import JsonSchema, JsonSchemaTransformer
from . import ModelProfile
+try:
+ from anthropic.lib.tools import transform_schema as anthropic_transform_schema # type: ignore[import-not-found]
+except ImportError:
+ anthropic_transform_schema = None
+
+
+@dataclass(init=False)
+class AnthropicJsonSchemaTransformer(JsonSchemaTransformer):
+ """Transform JSON schemas for Anthropic structured outputs.
+
+ Anthropic requires schemas to not include 'title', '$schema', or 'discriminator' at root level.
+
+ `additionalProperties` is set to false for objects to ensure strict mode compatibility.
+
+ optionally use Anthropic's transform_schema helper for validation
+
+ see: https://docs.claude.com/en/docs/build-with-claude/structured-outputs
+ """
+
+ def transform(self, schema: JsonSchema) -> JsonSchema:
+ """Apply Anthropic-specific schema transformations.
+
+ Removes 'title', '$schema', and 'discriminator' fields which are not supported by Anthropic API,
+ and sets `additionalProperties` to false for objects to ensure strict mode compatibility.
+
+ If available, also validates the schema using Anthropic's transform_schema helper from their SDK.
+
+ Args:
+ schema: The JSON schema to transform
+
+ Returns:
+ Transformed schema compatible with Anthropic's structured outputs API
+ """
+ # remove fields not supported by Anthropic
+ schema.pop('title', None)
+ schema.pop('$schema', None)
+ schema.pop('discriminator', None)
+
+ schema_type = schema.get('type')
+ if schema_type == 'object':
+ schema['additionalProperties'] = False
+ if self.strict is True:
+ if 'properties' not in schema:
+ schema['properties'] = dict[str, Any]()
+ schema['required'] = list(schema['properties'].keys())
+ elif self.strict is None:
+ if schema.get('additionalProperties', None) not in (None, False):
+ self.is_strict_compatible = False
+ else:
+ schema['additionalProperties'] = False
+
+ if 'properties' not in schema or 'required' not in schema:
+ self.is_strict_compatible = False
+ else:
+ required = schema['required']
+ for k in schema['properties'].keys():
+ if k not in required:
+ self.is_strict_compatible = False
+ else:
+ if 'additionalProperties' not in schema:
+ schema['additionalProperties'] = False
+
+ if anthropic_transform_schema is not None:
+ try:
+ validated_schema = anthropic_transform_schema(schema) # pyright: ignore[reportUnknownVariableType]
+ if isinstance(validated_schema, dict):
+ schema = validated_schema
+ except Exception:
+ pass
+
+ if self.strict is True:
+ self.is_strict_compatible = True
+
+ return schema
+
-def anthropic_model_profile(model_name: str) -> ModelProfile | None:
+def anthropic_model_profile(model_name: str) -> ModelProfile:
"""Get the model profile for an Anthropic model."""
- return ModelProfile(thinking_tags=('', ''))
+ return ModelProfile(
+ json_schema_transformer=AnthropicJsonSchemaTransformer,
+ supports_json_schema_output=True,
+ supports_json_object_output=True,
+ thinking_tags=('', ''),
+ )
diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py
index 86ba5a68d3..c08664fc54 100644
--- a/tests/models/test_anthropic.py
+++ b/tests/models/test_anthropic.py
@@ -545,6 +545,7 @@ def my_tool(value: str) -> str: # pragma: no cover
'required': ['value'],
'type': 'object',
},
+ 'strict': True,
'cache_control': {'type': 'ephemeral', 'ttl': '5m'},
}
]
@@ -6516,3 +6517,101 @@ async def test_anthropic_bedrock_count_tokens_not_supported(env: TestEnv):
with pytest.raises(UserError, match='AsyncAnthropicBedrock client does not support `count_tokens` api.'):
await agent.run('hello', usage_limits=UsageLimits(input_tokens_limit=20, count_tokens_before_request=True))
+
+
+def test_anthropic_json_schema_transformer():
+ """
+ Test AnthropicJsonSchemaTransformer removes unsupported fields.
+ """
+ from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer
+
+ schema = {
+ 'type': 'object',
+ 'title': 'testSchema',
+ '$schema': 'http://json-schema.org/draft-07/schema#',
+ 'discriminator': 'some_value',
+ 'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}},
+ 'required': ['name'],
+ }
+
+ transformer = AnthropicJsonSchemaTransformer(schema, strict=True)
+ result = transformer.walk()
+
+ assert 'title' not in result
+ assert '$schema' not in result
+ assert 'discriminator' not in result
+ assert result['type'] == 'object'
+ assert result['additionalProperties'] is False
+ assert result['properties'] == schema['properties']
+ # In strict mode, Anthropic requires ALL properties to be in required list
+ assert result['required'] == ['name', 'age']
+ assert transformer.is_strict_compatible is True
+
+ # test auto-detect mode (strict=False)
+ schema2 = {
+ 'type': 'object',
+ 'properties': {'id': {'type': 'integer'}},
+ 'required': ['id'],
+ }
+ transformer2 = AnthropicJsonSchemaTransformer(schema2, strict=False)
+ result2 = transformer2.walk()
+ assert result2['additionalProperties'] is False
+ assert transformer2.is_strict_compatible is True
+
+
+def test_anthropic_strict_mode_tool_mapping():
+ """
+ Test AnthropicModel._map_tool_definition() supports strict mode.
+ """
+ from pydantic_ai.models.anthropic import AnthropicModel
+ from pydantic_ai.providers.anthropic import AnthropicProvider
+ from pydantic_ai.tools import ToolDefinition
+
+ # Use mock provider to avoid requiring real API key
+ model = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(api_key='test-key'))
+
+ tool_def_strict = ToolDefinition(
+ name='test_tool',
+ description='a test tool',
+ parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}, 'required': ['x']},
+ strict=True,
+ )
+
+ result_strict = model._map_tool_definition(tool_def_strict) # pyright: ignore[reportPrivateUsage]
+
+ assert result_strict['strict'] is True # pyright: ignore[reportGeneralTypeIssues]
+ assert result_strict['name'] == 'test_tool'
+ assert result_strict.get('description') == 'a test tool'
+
+ tool_def_no_strict = ToolDefinition(
+ name='test_tool',
+ description='a test tool',
+ parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}, 'required': ['x']},
+ strict=False,
+ )
+
+ result_no_strict = model._map_tool_definition(tool_def_no_strict) # pyright: ignore[reportPrivateUsage]
+ assert 'strict' not in result_no_strict
+ assert result_no_strict['name'] == 'test_tool'
+ assert result_no_strict.get('description') == 'a test tool'
+
+
+async def test_anthropic_strucutred_output_with_test_model():
+ from pydantic import BaseModel
+
+ from pydantic_ai import Agent
+ from pydantic_ai.models.test import TestModel
+
+ class CityInfo(BaseModel):
+ city: str
+ country: str
+ population: int
+
+ test_model = TestModel(custom_output_args={'city': 'Tokyo', 'country': 'Japan', 'population': 14000000})
+ agent = Agent(test_model, output_type=CityInfo)
+
+ result = await agent.run('Tell me about Tokyo')
+ assert isinstance(result.output, CityInfo)
+ assert result.output.city == 'Tokyo'
+ assert result.output.country == 'Japan'
+ assert result.output.population == 14000000