Skip to content

Commit 9106868

Browse files
dsfacciniDouweM
andauthored
Support native JSON output and strict tool calls for Anthropic (#3457)
Co-authored-by: Douwe Maan <[email protected]>
1 parent d8f01f0 commit 9106868

40 files changed

+4514
-293
lines changed

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, 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/_json_schema.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
class JsonSchemaTransformer(ABC):
1616
"""Walks a JSON schema, applying transformations to it at each level.
1717
18+
The transformer is called during a model's prepare_request() step to build the JSON schema
19+
before it is sent to the model provider.
20+
1821
Note: We may eventually want to rework tools to build the JSON schema from the type directly, using a subclass of
1922
pydantic.json_schema.GenerateJsonSchema, rather than making use of this machinery.
2023
"""
@@ -30,8 +33,15 @@ def __init__(
3033
self.schema = schema
3134

3235
self.strict = strict
33-
self.is_strict_compatible = True # Can be set to False by subclasses to set `strict` on `ToolDefinition` when set not set by user explicitly
36+
"""The `strict` parameter forces the conversion of the original JSON schema (`self.schema`) of a `ToolDefinition` or `OutputObjectDefinition` to a format supported by the model provider.
37+
38+
The "strict mode" offered by model providers ensures that the model's output adheres closely to the defined schema. However, not all model providers offer it, and their support for various schema features may differ. For example, a model provider's required schema may not support certain validation constraints like `minLength` or `pattern`.
39+
"""
40+
self.is_strict_compatible = True
41+
"""Whether the schema is compatible with strict mode.
3442
43+
This value is used to set `ToolDefinition.strict` or `OutputObjectDefinition.strict` when their values are `None`.
44+
"""
3545
self.prefer_inlined_defs = prefer_inlined_defs
3646
self.simplify_nullable_unions = simplify_nullable_unions
3747

pydantic_ai_slim/pydantic_ai/models/__init__.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,6 @@
5757
Literal[
5858
'anthropic:claude-3-5-haiku-20241022',
5959
'anthropic:claude-3-5-haiku-latest',
60-
'anthropic:claude-3-5-sonnet-20240620',
61-
'anthropic:claude-3-5-sonnet-20241022',
62-
'anthropic:claude-3-5-sonnet-latest',
6360
'anthropic:claude-3-7-sonnet-20250219',
6461
'anthropic:claude-3-7-sonnet-latest',
6562
'anthropic:claude-3-haiku-20240307',
@@ -72,6 +69,8 @@
7269
'anthropic:claude-opus-4-0',
7370
'anthropic:claude-opus-4-1-20250805',
7471
'anthropic:claude-opus-4-20250514',
72+
'anthropic:claude-opus-4-5',
73+
'anthropic:claude-opus-4-5-20251101',
7574
'anthropic:claude-sonnet-4-0',
7675
'anthropic:claude-sonnet-4-20250514',
7776
'anthropic:claude-sonnet-4-5',
@@ -100,6 +99,7 @@
10099
'bedrock:eu.anthropic.claude-haiku-4-5-20251001-v1:0',
101100
'bedrock:eu.anthropic.claude-sonnet-4-20250514-v1:0',
102101
'bedrock:eu.anthropic.claude-sonnet-4-5-20250929-v1:0',
102+
'bedrock:global.anthropic.claude-opus-4-5-20251101-v1:0',
103103
'bedrock:meta.llama3-1-405b-instruct-v1:0',
104104
'bedrock:meta.llama3-1-70b-instruct-v1:0',
105105
'bedrock:meta.llama3-1-8b-instruct-v1:0',
@@ -380,7 +380,10 @@ async def request(
380380
model_settings: ModelSettings | None,
381381
model_request_parameters: ModelRequestParameters,
382382
) -> ModelResponse:
383-
"""Make a request to the model."""
383+
"""Make a request to the model.
384+
385+
This is ultimately called by `pydantic_ai._agent_graph.ModelRequestNode._make_request(...)`.
386+
"""
384387
raise NotImplementedError()
385388

386389
async def count_tokens(
@@ -987,23 +990,27 @@ def get_user_agent() -> str:
987990
return f'pydantic-ai/{__version__}'
988991

989992

990-
def _customize_tool_def(transformer: type[JsonSchemaTransformer], t: ToolDefinition):
991-
schema_transformer = transformer(t.parameters_json_schema, strict=t.strict)
993+
def _customize_tool_def(transformer: type[JsonSchemaTransformer], tool_def: ToolDefinition):
994+
"""Customize the tool definition using the given transformer.
995+
996+
If the tool definition has `strict` set to None, the strictness will be inferred from the transformer.
997+
"""
998+
schema_transformer = transformer(tool_def.parameters_json_schema, strict=tool_def.strict)
992999
parameters_json_schema = schema_transformer.walk()
9931000
return replace(
994-
t,
1001+
tool_def,
9951002
parameters_json_schema=parameters_json_schema,
996-
strict=schema_transformer.is_strict_compatible if t.strict is None else t.strict,
1003+
strict=schema_transformer.is_strict_compatible if tool_def.strict is None else tool_def.strict,
9971004
)
9981005

9991006

1000-
def _customize_output_object(transformer: type[JsonSchemaTransformer], o: OutputObjectDefinition):
1001-
schema_transformer = transformer(o.json_schema, strict=o.strict)
1007+
def _customize_output_object(transformer: type[JsonSchemaTransformer], output_object: OutputObjectDefinition):
1008+
schema_transformer = transformer(output_object.json_schema, strict=output_object.strict)
10021009
json_schema = schema_transformer.walk()
10031010
return replace(
1004-
o,
1011+
output_object,
10051012
json_schema=json_schema,
1006-
strict=schema_transformer.is_strict_compatible if o.strict is None else o.strict,
1013+
strict=schema_transformer.is_strict_compatible if output_object.strict is None else output_object.strict,
10071014
)
10081015

10091016

pydantic_ai_slim/pydantic_ai/models/anthropic.py

Lines changed: 83 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
BetaContentBlockParam,
7979
BetaImageBlockParam,
8080
BetaInputJSONDelta,
81+
BetaJSONOutputFormatParam,
8182
BetaMCPToolResultBlock,
8283
BetaMCPToolUseBlock,
8384
BetaMCPToolUseBlockParam,
@@ -225,8 +226,9 @@ def __init__(
225226
model_name: The name of the Anthropic model to use. List of model names available
226227
[here](https://docs.anthropic.com/en/docs/about-claude/models).
227228
provider: The provider to use for the Anthropic API. Can be either the string 'anthropic' or an
228-
instance of `Provider[AsyncAnthropicClient]`. If not provided, the other parameters will be used.
229+
instance of `Provider[AsyncAnthropicClient]`. Defaults to 'anthropic'.
229230
profile: The model profile to use. Defaults to a profile picked by the provider based on the model name.
231+
The default 'anthropic' provider will use the default `..profiles.anthropic.anthropic_model_profile`.
230232
settings: Default model settings for this model instance.
231233
"""
232234
self._model_name = model_name
@@ -316,14 +318,26 @@ def prepare_request(
316318
and thinking.get('type') == 'enabled'
317319
):
318320
if model_request_parameters.output_mode == 'auto':
319-
model_request_parameters = replace(model_request_parameters, output_mode='prompted')
321+
output_mode = 'native' if self.profile.supports_json_schema_output else 'prompted'
322+
model_request_parameters = replace(model_request_parameters, output_mode=output_mode)
320323
elif (
321324
model_request_parameters.output_mode == 'tool' and not model_request_parameters.allow_text_output
322325
): # pragma: no branch
323326
# This would result in `tool_choice=required`, which Anthropic does not support with thinking.
327+
suggested_output_type = 'NativeOutput' if self.profile.supports_json_schema_output else 'PromptedOutput'
324328
raise UserError(
325-
'Anthropic does not support thinking and output tools at the same time. Use `output_type=PromptedOutput(...)` instead.'
329+
f'Anthropic does not support thinking and output tools at the same time. Use `output_type={suggested_output_type}(...)` instead.'
326330
)
331+
332+
if model_request_parameters.output_mode == 'native':
333+
assert model_request_parameters.output_object is not None
334+
if model_request_parameters.output_object.strict is False:
335+
raise UserError(
336+
'Setting `strict=False` on `output_type=NativeOutput(...)` is not allowed for Anthropic models.'
337+
)
338+
model_request_parameters = replace(
339+
model_request_parameters, output_object=replace(model_request_parameters.output_object, strict=True)
340+
)
327341
return super().prepare_request(model_settings, model_request_parameters)
328342

329343
@overload
@@ -353,17 +367,22 @@ async def _messages_create(
353367
model_settings: AnthropicModelSettings,
354368
model_request_parameters: ModelRequestParameters,
355369
) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]:
356-
# standalone function to make it easier to override
370+
"""Calls the Anthropic API to create a message.
371+
372+
This is the last step before sending the request to the API.
373+
Most preprocessing has happened in `prepare_request()`.
374+
"""
357375
tools = self._get_tools(model_request_parameters, model_settings)
358-
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
376+
tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, model_request_parameters)
359377

360378
tool_choice = self._infer_tool_choice(tools, model_settings, model_request_parameters)
361379

362380
system_prompt, anthropic_messages = await self._map_message(messages, model_request_parameters, model_settings)
363381
self._limit_cache_points(system_prompt, anthropic_messages, tools)
382+
output_format = self._native_output_format(model_request_parameters)
383+
betas, extra_headers = self._get_betas_and_extra_headers(tools, model_request_parameters, model_settings)
384+
betas.update(builtin_tool_betas)
364385
try:
365-
extra_headers = self._map_extra_headers(beta_features, model_settings)
366-
367386
return await self.client.beta.messages.create(
368387
max_tokens=model_settings.get('max_tokens', 4096),
369388
system=system_prompt or OMIT,
@@ -372,6 +391,8 @@ async def _messages_create(
372391
tools=tools or OMIT,
373392
tool_choice=tool_choice or OMIT,
374393
mcp_servers=mcp_servers or OMIT,
394+
output_format=output_format or OMIT,
395+
betas=sorted(betas) or OMIT,
375396
stream=stream,
376397
thinking=model_settings.get('anthropic_thinking', OMIT),
377398
stop_sequences=model_settings.get('stop_sequences', OMIT),
@@ -389,6 +410,32 @@ async def _messages_create(
389410
except APIConnectionError as e:
390411
raise ModelAPIError(model_name=self.model_name, message=e.message) from e
391412

413+
def _get_betas_and_extra_headers(
414+
self,
415+
tools: list[BetaToolUnionParam],
416+
model_request_parameters: ModelRequestParameters,
417+
model_settings: AnthropicModelSettings,
418+
) -> tuple[set[str], dict[str, str]]:
419+
"""Prepare beta features list and extra headers for API request.
420+
421+
Handles merging custom `anthropic-beta` header from `extra_headers` into betas set
422+
and ensuring `User-Agent` is set.
423+
"""
424+
extra_headers = model_settings.get('extra_headers', {})
425+
extra_headers.setdefault('User-Agent', get_user_agent())
426+
427+
betas: set[str] = set()
428+
429+
has_strict_tools = any(tool.get('strict') for tool in tools)
430+
431+
if has_strict_tools or model_request_parameters.output_mode == 'native':
432+
betas.add('structured-outputs-2025-11-13')
433+
434+
if beta_header := extra_headers.pop('anthropic-beta', None):
435+
betas.update({stripped_beta for beta in beta_header.split(',') if (stripped_beta := beta.strip())})
436+
437+
return betas, extra_headers
438+
392439
async def _messages_count_tokens(
393440
self,
394441
messages: list[ModelMessage],
@@ -400,22 +447,25 @@ async def _messages_count_tokens(
400447

401448
# standalone function to make it easier to override
402449
tools = self._get_tools(model_request_parameters, model_settings)
403-
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
450+
tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, model_request_parameters)
404451

405452
tool_choice = self._infer_tool_choice(tools, model_settings, model_request_parameters)
406453

407454
system_prompt, anthropic_messages = await self._map_message(messages, model_request_parameters, model_settings)
408455
self._limit_cache_points(system_prompt, anthropic_messages, tools)
456+
output_format = self._native_output_format(model_request_parameters)
457+
betas, extra_headers = self._get_betas_and_extra_headers(tools, model_request_parameters, model_settings)
458+
betas.update(builtin_tool_betas)
409459
try:
410-
extra_headers = self._map_extra_headers(beta_features, model_settings)
411-
412460
return await self.client.beta.messages.count_tokens(
413461
system=system_prompt or OMIT,
414462
messages=anthropic_messages,
415463
model=self._model_name,
416464
tools=tools or OMIT,
417465
tool_choice=tool_choice or OMIT,
418466
mcp_servers=mcp_servers or OMIT,
467+
betas=sorted(betas) or OMIT,
468+
output_format=output_format or OMIT,
419469
thinking=model_settings.get('anthropic_thinking', OMIT),
420470
timeout=model_settings.get('timeout', NOT_GIVEN),
421471
extra_headers=extra_headers,
@@ -521,8 +571,8 @@ def _get_tools(
521571

522572
def _add_builtin_tools(
523573
self, tools: list[BetaToolUnionParam], model_request_parameters: ModelRequestParameters
524-
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], list[str]]:
525-
beta_features: list[str] = []
574+
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], set[str]]:
575+
beta_features: set[str] = set()
526576
mcp_servers: list[BetaRequestMCPServerURLDefinitionParam] = []
527577
for tool in model_request_parameters.builtin_tools:
528578
if isinstance(tool, WebSearchTool):
@@ -539,7 +589,7 @@ def _add_builtin_tools(
539589
)
540590
elif isinstance(tool, CodeExecutionTool): # pragma: no branch
541591
tools.append(BetaCodeExecutionTool20250522Param(name='code_execution', type='code_execution_20250522'))
542-
beta_features.append('code-execution-2025-05-22')
592+
beta_features.add('code-execution-2025-05-22')
543593
elif isinstance(tool, WebFetchTool): # pragma: no branch
544594
citations = BetaCitationsConfigParam(enabled=tool.enable_citations) if tool.enable_citations else None
545595
tools.append(
@@ -553,14 +603,14 @@ def _add_builtin_tools(
553603
max_content_tokens=tool.max_content_tokens,
554604
)
555605
)
556-
beta_features.append('web-fetch-2025-09-10')
606+
beta_features.add('web-fetch-2025-09-10')
557607
elif isinstance(tool, MemoryTool): # pragma: no branch
558608
if 'memory' not in model_request_parameters.tool_defs:
559609
raise UserError("Built-in `MemoryTool` requires a 'memory' tool to be defined.")
560610
# Replace the memory tool definition with the built-in memory tool
561-
tools = [tool for tool in tools if tool['name'] != 'memory']
611+
tools = [tool for tool in tools if tool.get('name') != 'memory']
562612
tools.append(BetaMemoryTool20250818Param(name='memory', type='memory_20250818'))
563-
beta_features.append('context-management-2025-06-27')
613+
beta_features.add('context-management-2025-06-27')
564614
elif isinstance(tool, MCPServerTool) and tool.url:
565615
mcp_server_url_definition_param = BetaRequestMCPServerURLDefinitionParam(
566616
type='url',
@@ -575,7 +625,7 @@ def _add_builtin_tools(
575625
if tool.authorization_token: # pragma: no cover
576626
mcp_server_url_definition_param['authorization_token'] = tool.authorization_token
577627
mcp_servers.append(mcp_server_url_definition_param)
578-
beta_features.append('mcp-client-2025-04-04')
628+
beta_features.add('mcp-client-2025-04-04')
579629
else: # pragma: no cover
580630
raise UserError(
581631
f'`{tool.__class__.__name__}` is not supported by `AnthropicModel`. If it should be, please file an issue.'
@@ -603,16 +653,6 @@ def _infer_tool_choice(
603653

604654
return tool_choice
605655

606-
def _map_extra_headers(self, beta_features: list[str], model_settings: AnthropicModelSettings) -> dict[str, str]:
607-
"""Apply beta_features to extra_headers in model_settings."""
608-
extra_headers = model_settings.get('extra_headers', {})
609-
extra_headers.setdefault('User-Agent', get_user_agent())
610-
if beta_features:
611-
if 'anthropic-beta' in extra_headers:
612-
beta_features.insert(0, extra_headers['anthropic-beta'])
613-
extra_headers['anthropic-beta'] = ','.join(beta_features)
614-
return extra_headers
615-
616656
async def _map_message( # noqa: C901
617657
self,
618658
messages: list[ModelMessage],
@@ -992,13 +1032,23 @@ async def _map_user_prompt(
9921032
else:
9931033
raise RuntimeError(f'Unsupported content type: {type(item)}') # pragma: no cover
9941034

995-
@staticmethod
996-
def _map_tool_definition(f: ToolDefinition) -> BetaToolParam:
997-
return {
1035+
def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam:
1036+
"""Maps a `ToolDefinition` dataclass to an Anthropic `BetaToolParam` dictionary."""
1037+
tool_param: BetaToolParam = {
9981038
'name': f.name,
9991039
'description': f.description or '',
10001040
'input_schema': f.parameters_json_schema,
10011041
}
1042+
if f.strict and self.profile.supports_json_schema_output:
1043+
tool_param['strict'] = f.strict
1044+
return tool_param
1045+
1046+
@staticmethod
1047+
def _native_output_format(model_request_parameters: ModelRequestParameters) -> BetaJSONOutputFormatParam | None:
1048+
if model_request_parameters.output_mode != 'native':
1049+
return None
1050+
assert model_request_parameters.output_object is not None
1051+
return {'type': 'json_schema', 'schema': model_request_parameters.output_object.json_schema}
10021052

10031053

10041054
def _map_usage(
@@ -1221,6 +1271,9 @@ def _map_server_tool_use_block(item: BetaServerToolUseBlock, provider_name: str)
12211271
)
12221272
elif item.name in ('bash_code_execution', 'text_editor_code_execution'): # pragma: no cover
12231273
raise NotImplementedError(f'Anthropic built-in tool {item.name!r} is not currently supported.')
1274+
elif item.name in ('tool_search_tool_regex', 'tool_search_tool_bm25'): # pragma: no cover
1275+
# NOTE this is being implemented in https://github.com/pydantic/pydantic-ai/pull/3550
1276+
raise NotImplementedError(f'Anthropic built-in tool {item.name!r} is not currently supported.')
12241277
else:
12251278
assert_never(item.name)
12261279

0 commit comments

Comments
 (0)