Skip to content

Commit b26f93b

Browse files
committed
adds native json output and strict tool call support for the anthropic model - addresses #3428
1 parent 359c6d2 commit b26f93b

File tree

5 files changed

+298
-22
lines changed

5 files changed

+298
-22
lines changed

docs/models/anthropic.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,31 @@ async def main():
144144
print(f'Cache write tokens: {usage.cache_write_tokens}')
145145
print(f'Cache read tokens: {usage.cache_read_tokens}')
146146
```
147+
148+
## Structured outputs & strict tool calls
149+
150+
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:
151+
152+
- 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.
153+
- 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.
154+
155+
```python {test="skip"}
156+
from pydantic import BaseModel
157+
from pydantic_ai import Agent, NativeOutput
158+
from pydantic_ai.models.anthropic import AnthropicModel
159+
160+
161+
class Contact(BaseModel):
162+
name: str
163+
email: str
164+
165+
166+
model = AnthropicModel('claude-sonnet-4-5')
167+
agent = Agent(model, output_type=NativeOutput(Contact))
168+
result = agent.run_sync('Extract the contact info for Ada Lovelace.')
169+
print(result.output)
170+
#> Contact(name='Ada Lovelace', email='[email protected]')
171+
```
172+
173+
!!! note
174+
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.

docs/output.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ _(This example is complete, it can be run "as is")_
308308

309309
#### Native Output
310310

311-
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.
311+
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.
312312

313313
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.
314314

pydantic_ai_slim/pydantic_ai/models/anthropic.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
'refusal': 'content_filter',
5454
}
5555

56+
# TODO: remove once anthropic moves it out of beta
57+
_STRUCTURED_OUTPUTS_BETA = 'structured-outputs-2025-11-13'
58+
5659

5760
try:
5861
from anthropic import NOT_GIVEN, APIStatusError, AsyncStream, omit as OMIT
@@ -307,8 +310,10 @@ async def _messages_create(
307310
model_request_parameters: ModelRequestParameters,
308311
) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]:
309312
# standalone function to make it easier to override
310-
tools = self._get_tools(model_request_parameters, model_settings)
313+
tools, strict_tools_requested = self._get_tools(model_request_parameters, model_settings)
311314
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
315+
output_format = self._build_output_format(model_request_parameters)
316+
structured_output_beta_required = strict_tools_requested or bool(output_format)
312317

313318
tool_choice: BetaToolChoiceParam | None
314319

@@ -328,10 +333,18 @@ async def _messages_create(
328333
try:
329334
extra_headers = model_settings.get('extra_headers', {})
330335
extra_headers.setdefault('User-Agent', get_user_agent())
331-
if beta_features:
332-
if 'anthropic-beta' in extra_headers:
333-
beta_features.insert(0, extra_headers['anthropic-beta'])
334-
extra_headers['anthropic-beta'] = ','.join(beta_features)
336+
if beta_features or structured_output_beta_required:
337+
new_features = list(beta_features)
338+
if structured_output_beta_required:
339+
new_features.append(_STRUCTURED_OUTPUTS_BETA)
340+
extra_headers['anthropic-beta'] = self._format_beta_header(
341+
extra_headers.get('anthropic-beta'),
342+
new_features,
343+
)
344+
345+
extra_body = cast(dict[str, Any] | None, model_settings.get('extra_body'))
346+
if output_format is not None:
347+
extra_body = self._merge_output_format_extra_body(extra_body, output_format)
335348

336349
return await self.client.beta.messages.create(
337350
max_tokens=model_settings.get('max_tokens', 4096),
@@ -349,7 +362,7 @@ async def _messages_create(
349362
timeout=model_settings.get('timeout', NOT_GIVEN),
350363
metadata=model_settings.get('anthropic_metadata', OMIT),
351364
extra_headers=extra_headers,
352-
extra_body=model_settings.get('extra_body'),
365+
extra_body=extra_body,
353366
)
354367
except APIStatusError as e:
355368
if (status_code := e.status_code) >= 400:
@@ -431,17 +444,20 @@ async def _process_streamed_response(
431444

432445
def _get_tools(
433446
self, model_request_parameters: ModelRequestParameters, model_settings: AnthropicModelSettings
434-
) -> list[BetaToolUnionParam]:
435-
tools: list[BetaToolUnionParam] = [
436-
self._map_tool_definition(r) for r in model_request_parameters.tool_defs.values()
437-
]
447+
) -> tuple[list[BetaToolUnionParam], bool]:
448+
tools: list[BetaToolUnionParam] = []
449+
strict_tools_requested = False
450+
for tool_def in model_request_parameters.tool_defs.values():
451+
tools.append(self._map_tool_definition(tool_def))
452+
if tool_def.strict:
453+
strict_tools_requested = True
438454

439455
# Add cache_control to the last tool if enabled
440456
if tools and model_settings.get('anthropic_cache_tool_definitions'):
441457
last_tool = tools[-1]
442458
last_tool['cache_control'] = BetaCacheControlEphemeralParam(type='ephemeral')
443459

444-
return tools
460+
return tools, strict_tools_requested
445461

446462
def _add_builtin_tools(
447463
self, tools: list[BetaToolUnionParam], model_request_parameters: ModelRequestParameters
@@ -759,11 +775,49 @@ async def _map_user_prompt(
759775

760776
@staticmethod
761777
def _map_tool_definition(f: ToolDefinition) -> BetaToolParam:
762-
return {
778+
tool_param: BetaToolParam = {
763779
'name': f.name,
764780
'description': f.description or '',
765781
'input_schema': f.parameters_json_schema,
766782
}
783+
if f.strict is not None:
784+
tool_param['strict'] = f.strict # type: ignore[assignment]
785+
return tool_param
786+
787+
@staticmethod
788+
def _build_output_format(model_request_parameters: ModelRequestParameters) -> dict[str, Any] | None:
789+
if model_request_parameters.output_mode != 'native':
790+
return None
791+
output_object = model_request_parameters.output_object
792+
if output_object is None:
793+
return None
794+
return {'type': 'json_schema', 'schema': output_object.json_schema}
795+
796+
@staticmethod
797+
def _merge_output_format_extra_body(
798+
existing: dict[str, Any] | None, output_format: dict[str, Any]
799+
) -> dict[str, Any]:
800+
merged = dict(existing or {})
801+
if 'output_format' in merged:
802+
raise UserError(
803+
'`model_settings.extra_body` cannot define `output_format` when using native structured output.'
804+
)
805+
merged['output_format'] = output_format
806+
return merged
807+
808+
@staticmethod
809+
def _format_beta_header(existing: str | None, new_features: list[str]) -> str:
810+
values: list[str] = []
811+
if existing:
812+
values.extend(value.strip() for value in existing.split(',') if value.strip())
813+
values.extend(new_features)
814+
ordered: list[str] = []
815+
seen: set[str] = set()
816+
for value in values:
817+
if value not in seen:
818+
ordered.append(value)
819+
seen.add(value)
820+
return ','.join(ordered)
767821

768822

769823
def _map_usage(
Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,51 @@
11
from __future__ import annotations as _annotations
22

3+
import importlib
4+
from collections.abc import Callable
5+
from dataclasses import dataclass
6+
from typing import Any, cast
7+
8+
from .._json_schema import JsonSchema, JsonSchemaTransformer
39
from . import ModelProfile
410

11+
TransformSchemaFunc = Callable[[Any], JsonSchema]
12+
13+
try: # pragma: no cover
14+
_anthropic_module = importlib.import_module('anthropic')
15+
except Exception:
16+
_anthropic_transform_schema: TransformSchemaFunc | None = None
17+
else:
18+
_anthropic_transform_schema = cast(TransformSchemaFunc | None, getattr(_anthropic_module, 'transform_schema', None))
19+
20+
21+
@dataclass(init=False)
22+
class AnthropicJsonSchemaTransformer(JsonSchemaTransformer):
23+
"""Transforms schemas to the subset supported by Anthropic structured outputs."""
24+
25+
def walk(self) -> JsonSchema:
26+
schema = super().walk()
27+
helper = _anthropic_transform_schema
28+
if helper is None:
29+
return schema
30+
try: # pragma: no branch
31+
# helper may raise if schema already transformed
32+
transformed = helper(schema)
33+
except Exception:
34+
return schema
35+
if isinstance(transformed, dict):
36+
return transformed
37+
return schema
38+
39+
def transform(self, schema: JsonSchema) -> JsonSchema:
40+
schema.pop('title', None)
41+
schema.pop('$schema', None)
42+
return schema
43+
544

645
def anthropic_model_profile(model_name: str) -> ModelProfile | None:
746
"""Get the model profile for an Anthropic model."""
8-
return ModelProfile(thinking_tags=('<thinking>', '</thinking>'))
47+
return ModelProfile(
48+
thinking_tags=('<thinking>', '</thinking>'),
49+
supports_json_schema_output=True,
50+
json_schema_transformer=AnthropicJsonSchemaTransformer,
51+
)

0 commit comments

Comments
 (0)