Skip to content

Commit cb75151

Browse files
committed
feat(anthropic): add structured outputs support with JSON schema transformer and strict tool calls
Implements Anthropic's structured outputs beta feature for Claude models. - Implement AnthropicJsonSchemaTransformer following Anthropic docs - Add strict mode support with correct truthy check (not 'is not None') - Set additionalProperties: false for objects (required by Anthropic) - Mark all properties as required in strict mode - Integrate with Anthropic's transform_schema helper (optional) - Add comprehensive tests covering all edge cases Addresses #3428
1 parent 1b576dd commit cb75151

File tree

3 files changed

+201
-5
lines changed

3 files changed

+201
-5
lines changed

pydantic_ai_slim/pydantic_ai/models/anthropic.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -835,14 +835,31 @@ async def _map_user_prompt(
835835
else:
836836
raise RuntimeError(f'Unsupported content type: {type(item)}') # pragma: no cover
837837

838-
@staticmethod
839-
def _map_tool_definition(f: ToolDefinition) -> BetaToolParam:
840-
return {
838+
def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam:
839+
"""Map tool definition to Anthropic format with strict mode support.
840+
841+
Follows the same pattern as OpenAI (line 768): checks if f.strict is truthy,
842+
not just checking if it's not None (which was PR #3457's critical mistake).
843+
844+
Args:
845+
f: Tool definition to map
846+
847+
Returns:
848+
BetaToolParam with strict mode set if applicable
849+
"""
850+
tool_param: BetaToolParam = {
841851
'name': f.name,
842852
'description': f.description or '',
843853
'input_schema': f.parameters_json_schema,
844854
}
845855

856+
# ✅ CRITICAL: Use truthy check (same as OpenAI line 768)
857+
# NOT checking "is not None" (PR #3457's mistake)
858+
if f.strict:
859+
tool_param['strict'] = True # type: ignore[typeddict-item]
860+
861+
return tool_param
862+
846863

847864
def _map_usage(
848865
message: BetaMessage | BetaRawMessageStartEvent | BetaRawMessageDeltaEvent,
Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,92 @@
11
from __future__ import annotations as _annotations
22

3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from .._json_schema import JsonSchema, JsonSchemaTransformer
37
from . import ModelProfile
48

9+
try:
10+
from anthropic.lib.tools import transform_schema as anthropic_transform_schema # type: ignore[import-not-found]
11+
except ImportError:
12+
anthropic_transform_schema = None
13+
14+
15+
@dataclass(init=False)
16+
class AnthropicJsonSchemaTransformer(JsonSchemaTransformer):
17+
"""Transform JSON schemas for Anthropic structured outputs.
18+
19+
Anthropic requires schemas to not include 'title', '$schema', or 'discriminator' at root level.
20+
21+
`additionalProperties` is set to false for objects to ensure strict mode compatibility.
22+
23+
optionally use Anthropic's transform_schema helper for validation
24+
25+
see: https://docs.claude.com/en/docs/build-with-claude/structured-outputs
26+
"""
27+
28+
def transform(self, schema: JsonSchema) -> JsonSchema:
29+
"""Apply Anthropic-specific schema transformations.
30+
31+
Removes 'title', '$schema', and 'discriminator' fields which are not supported by Anthropic API,
32+
and sets `additionalProperties` to false for objects to ensure strict mode compatibility.
33+
34+
If available, also validates the schema using Anthropic's transform_schema helper from their SDK.
35+
36+
Args:
37+
schema: The JSON schema to transform
38+
39+
Returns:
40+
Transformed schema compatible with Anthropic's structured outputs API
41+
"""
42+
# remove fields not supported by Anthropic
43+
schema.pop('title', None)
44+
schema.pop('$schema', None)
45+
schema.pop('discriminator', None)
46+
47+
schema_type = schema.get('type')
48+
if schema_type == 'object':
49+
schema['additionalProperties'] = False
50+
if self.strict is True:
51+
if 'properties' not in schema:
52+
schema['properties'] = dict[str, Any]()
53+
schema['required'] = list(schema['properties'].keys())
54+
elif self.strict is None:
55+
if schema.get('additionalProperties', None) not in (None, False):
56+
self.is_strict_compatible = False
57+
else:
58+
schema['additionalProperties'] = False
59+
60+
if 'properties' not in schema or 'required' not in schema:
61+
self.is_strict_compatible = False
62+
else:
63+
required = schema['required']
64+
for k in schema['properties'].keys():
65+
if k not in required:
66+
self.is_strict_compatible = False
67+
else:
68+
if 'additionalProperties' not in schema:
69+
schema['additionalProperties'] = False
70+
71+
if anthropic_transform_schema is not None:
72+
try:
73+
validated_schema = anthropic_transform_schema(schema) # pyright: ignore[reportUnknownVariableType]
74+
if isinstance(validated_schema, dict):
75+
schema = validated_schema
76+
except Exception:
77+
pass
78+
79+
if self.strict is True:
80+
self.is_strict_compatible = True
81+
82+
return schema
83+
584

6-
def anthropic_model_profile(model_name: str) -> ModelProfile | None:
85+
def anthropic_model_profile(model_name: str) -> ModelProfile:
786
"""Get the model profile for an Anthropic model."""
8-
return ModelProfile(thinking_tags=('<thinking>', '</thinking>'))
87+
return ModelProfile(
88+
json_schema_transformer=AnthropicJsonSchemaTransformer,
89+
supports_json_schema_output=True,
90+
supports_json_object_output=True,
91+
thinking_tags=('<thinking>', '</thinking>'),
92+
)

tests/models/test_anthropic.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6516,3 +6516,98 @@ async def test_anthropic_bedrock_count_tokens_not_supported(env: TestEnv):
65166516

65176517
with pytest.raises(UserError, match='AsyncAnthropicBedrock client does not support `count_tokens` api.'):
65186518
await agent.run('hello', usage_limits=UsageLimits(input_tokens_limit=20, count_tokens_before_request=True))
6519+
6520+
6521+
def test_anthropic_json_schema_transformer():
6522+
"""
6523+
Test AnthropicJsonSchemaTransformer removes unsupported fields.
6524+
"""
6525+
from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer
6526+
6527+
schema = {
6528+
'type': 'object',
6529+
'title': 'testSchema',
6530+
'$schema': 'http://json-schema.org/draft-07/schema#',
6531+
'discriminator': 'some_value',
6532+
'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}},
6533+
'required': ['name'],
6534+
}
6535+
6536+
transformer = AnthropicJsonSchemaTransformer(schema, strict=True)
6537+
result = transformer.walk()
6538+
6539+
assert 'title' not in result
6540+
assert '$schema' not in result
6541+
assert 'discriminator' not in result
6542+
assert result['type'] == 'object'
6543+
assert result['additionalProperties'] is False
6544+
assert result['properties'] == schema['properties']
6545+
assert result['required'] == schema['required']
6546+
assert transformer.is_strict_compatible is True
6547+
6548+
# test auto-detect mode (strict=False)
6549+
schema2 = {
6550+
'type': 'object',
6551+
'properties': {'id': {'type': 'integer'}},
6552+
'required': ['id'],
6553+
}
6554+
transformer2 = AnthropicJsonSchemaTransformer(schema2, strict=False)
6555+
result2 = transformer2.walk()
6556+
assert result2['additionalProperties'] is False
6557+
assert transformer2.is_strict_compatible is True
6558+
6559+
6560+
def test_anthropic_strict_mode_tool_mapping():
6561+
"""
6562+
Test AnthropicModel._map_tool_definition() supports strict mode.
6563+
"""
6564+
from pydantic_ai.models.anthropic import AnthropicModel
6565+
from pydantic_ai.tools import ToolDefinition
6566+
6567+
model = AnthropicModel('claude-sonnet-4-5')
6568+
6569+
tool_def_strict = ToolDefinition(
6570+
name='test_tool',
6571+
description='a test tool',
6572+
parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}, 'required': ['x']},
6573+
strict=True,
6574+
)
6575+
6576+
result_strict = model._map_tool_definition(tool_def_strict) # pyright: ignore[reportPrivateUsage]
6577+
6578+
assert result_strict['strict'] is True # pyright: ignore[reportGeneralTypeIssues]
6579+
assert result_strict['name'] == 'test_tool'
6580+
assert result_strict.get('description') == 'a test tool'
6581+
6582+
tool_def_no_strict = ToolDefinition(
6583+
name='test_tool',
6584+
description='a test tool',
6585+
parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}, 'required': ['x']},
6586+
strict=False,
6587+
)
6588+
6589+
result_no_strict = model._map_tool_definition(tool_def_no_strict) # pyright: ignore[reportPrivateUsage]
6590+
assert 'strict' not in result_no_strict
6591+
assert result_no_strict['name'] == 'test_tool'
6592+
assert result_no_strict.get('description') == 'a test tool'
6593+
6594+
6595+
async def test_anthropic_strucutred_output_with_test_model():
6596+
from pydantic import BaseModel
6597+
6598+
from pydantic_ai import Agent
6599+
from pydantic_ai.models.test import TestModel
6600+
6601+
class CityInfo(BaseModel):
6602+
city: str
6603+
country: str
6604+
population: int
6605+
6606+
test_model = TestModel(custom_output_args={'city': 'Tokyo', 'country': 'Japan', 'population': 14000000})
6607+
agent = Agent(test_model, output_type=CityInfo)
6608+
6609+
result = await agent.run('Tell me about Tokyo')
6610+
assert isinstance(result.output, CityInfo)
6611+
assert result.output.city == 'Tokyo'
6612+
assert result.output.country == 'Japan'
6613+
assert result.output.population == 14000000

0 commit comments

Comments
 (0)