Skip to content

Commit 860ed1c

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 359c6d2 commit 860ed1c

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
@@ -757,14 +757,31 @@ async def _map_user_prompt(
757757
else:
758758
raise RuntimeError(f'Unsupported content type: {type(item)}') # pragma: no cover
759759

760-
@staticmethod
761-
def _map_tool_definition(f: ToolDefinition) -> BetaToolParam:
762-
return {
760+
def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam:
761+
"""Map tool definition to Anthropic format with strict mode support.
762+
763+
Follows the same pattern as OpenAI (line 768): checks if f.strict is truthy,
764+
not just checking if it's not None (which was PR #3457's critical mistake).
765+
766+
Args:
767+
f: Tool definition to map
768+
769+
Returns:
770+
BetaToolParam with strict mode set if applicable
771+
"""
772+
tool_param: BetaToolParam = {
763773
'name': f.name,
764774
'description': f.description or '',
765775
'input_schema': f.parameters_json_schema,
766776
}
767777

778+
# ✅ CRITICAL: Use truthy check (same as OpenAI line 768)
779+
# NOT checking "is not None" (PR #3457's mistake)
780+
if f.strict:
781+
tool_param['strict'] = True # type: ignore[typeddict-item]
782+
783+
return tool_param
784+
768785

769786
def _map_usage(
770787
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
@@ -6405,3 +6405,98 @@ def memory(**command: Any) -> Any:
64056405
64066406
According to my memory, you live in **Mexico City**.\
64076407
""")
6408+
6409+
6410+
def test_anthropic_json_schema_transformer():
6411+
"""
6412+
Test AnthropicJsonSchemaTransformer removes unsupported fields.
6413+
"""
6414+
from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer
6415+
6416+
schema = {
6417+
'type': 'object',
6418+
'title': 'testSchema',
6419+
'$schema': 'http://json-schema.org/draft-07/schema#',
6420+
'discriminator': 'some_value',
6421+
'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}},
6422+
'required': ['name'],
6423+
}
6424+
6425+
transformer = AnthropicJsonSchemaTransformer(schema, strict=True)
6426+
result = transformer.walk()
6427+
6428+
assert 'title' not in result
6429+
assert '$schema' not in result
6430+
assert 'discriminator' not in result
6431+
assert result['type'] == 'object'
6432+
assert result['additionalProperties'] is False
6433+
assert result['properties'] == schema['properties']
6434+
assert result['required'] == schema['required']
6435+
assert transformer.is_strict_compatible is True
6436+
6437+
# test auto-detect mode (strict=False)
6438+
schema2 = {
6439+
'type': 'object',
6440+
'properties': {'id': {'type': 'integer'}},
6441+
'required': ['id'],
6442+
}
6443+
transformer2 = AnthropicJsonSchemaTransformer(schema2, strict=False)
6444+
result2 = transformer2.walk()
6445+
assert result2['additionalProperties'] is False
6446+
assert transformer2.is_strict_compatible is True
6447+
6448+
6449+
def test_anthropic_strict_mode_tool_mapping():
6450+
"""
6451+
Test AnthropicModel._map_tool_definition() supports strict mode.
6452+
"""
6453+
from pydantic_ai.models.anthropic import AnthropicModel
6454+
from pydantic_ai.tools import ToolDefinition
6455+
6456+
model = AnthropicModel('claude-sonnet-4-5')
6457+
6458+
tool_def_strict = ToolDefinition(
6459+
name='test_tool',
6460+
description='a test tool',
6461+
parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}, 'required': ['x']},
6462+
strict=True,
6463+
)
6464+
6465+
result_strict = model._map_tool_definition(tool_def_strict) # pyright: ignore[reportPrivateUsage]
6466+
6467+
assert result_strict['strict'] is True # pyright: ignore[reportGeneralTypeIssues]
6468+
assert result_strict['name'] == 'test_tool'
6469+
assert result_strict.get('description') == 'a test tool'
6470+
6471+
tool_def_no_strict = ToolDefinition(
6472+
name='test_tool',
6473+
description='a test tool',
6474+
parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}, 'required': ['x']},
6475+
strict=False,
6476+
)
6477+
6478+
result_no_strict = model._map_tool_definition(tool_def_no_strict) # pyright: ignore[reportPrivateUsage]
6479+
assert 'strict' not in result_no_strict
6480+
assert result_no_strict['name'] == 'test_tool'
6481+
assert result_no_strict.get('description') == 'a test tool'
6482+
6483+
6484+
async def test_anthropic_strucutred_output_with_test_model():
6485+
from pydantic import BaseModel
6486+
6487+
from pydantic_ai import Agent
6488+
from pydantic_ai.models.test import TestModel
6489+
6490+
class CityInfo(BaseModel):
6491+
city: str
6492+
country: str
6493+
population: int
6494+
6495+
test_model = TestModel(custom_output_args={'city': 'Tokyo', 'country': 'Japan', 'population': 14000000})
6496+
agent = Agent(test_model, output_type=CityInfo)
6497+
6498+
result = await agent.run('Tell me about Tokyo')
6499+
assert isinstance(result.output, CityInfo)
6500+
assert result.output.city == 'Tokyo'
6501+
assert result.output.country == 'Japan'
6502+
assert result.output.population == 14000000

0 commit comments

Comments
 (0)