Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ _(This example is complete, it can be run "as is")_

#### Native Output

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

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.

Expand Down
12 changes: 8 additions & 4 deletions pydantic_ai_slim/pydantic_ai/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@
Literal[
'anthropic:claude-3-5-haiku-20241022',
'anthropic:claude-3-5-haiku-latest',
'anthropic:claude-3-5-sonnet-20240620',
'anthropic:claude-3-5-sonnet-20241022',
'anthropic:claude-3-5-sonnet-latest',
'anthropic:claude-3-7-sonnet-20250219',
'anthropic:claude-3-7-sonnet-latest',
'anthropic:claude-3-haiku-20240307',
Expand Down Expand Up @@ -380,7 +377,10 @@ async def request(
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
"""Make a request to the model."""
"""Make a request to the model.

This is ultimately called by `pydantic_ai._agent_graph.ModelRequestNode._make_request(...)`.
"""
raise NotImplementedError()

async def count_tokens(
Expand Down Expand Up @@ -985,6 +985,10 @@ def get_user_agent() -> str:


def _customize_tool_def(transformer: type[JsonSchemaTransformer], t: ToolDefinition):
"""Customize the tool definition using the given transformer.

If the tool definition has `strict` set to None, the strictness will be inferred from the transformer.
"""
schema_transformer = transformer(t.parameters_json_schema, strict=t.strict)
parameters_json_schema = schema_transformer.walk()
return replace(
Expand Down
54 changes: 40 additions & 14 deletions pydantic_ai_slim/pydantic_ai/models/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
BetaContentBlockParam,
BetaImageBlockParam,
BetaInputJSONDelta,
BetaJSONOutputFormatParam,
BetaMCPToolResultBlock,
BetaMCPToolUseBlock,
BetaMCPToolUseBlockParam,
Expand Down Expand Up @@ -198,8 +199,9 @@ def __init__(
model_name: The name of the Anthropic model to use. List of model names available
[here](https://docs.anthropic.com/en/docs/about-claude/models).
provider: The provider to use for the Anthropic API. Can be either the string 'anthropic' or an
instance of `Provider[AsyncAnthropicClient]`. If not provided, the other parameters will be used.
instance of `Provider[AsyncAnthropicClient]`. Defaults to 'anthropic'.
profile: The model profile to use. Defaults to a profile picked by the provider based on the model name.
The default 'anthropic' provider will use the default `..profiles.anthropic_model_profile`.
settings: Default model settings for this model instance.
"""
self._model_name = model_name
Expand Down Expand Up @@ -289,13 +291,14 @@ def prepare_request(
and thinking.get('type') == 'enabled'
):
if model_request_parameters.output_mode == 'auto':
model_request_parameters = replace(model_request_parameters, output_mode='prompted')
output_mode = 'native' if self.profile.supports_json_schema_output else 'prompted'
model_request_parameters = replace(model_request_parameters, output_mode=output_mode)
elif (
model_request_parameters.output_mode == 'tool' and not model_request_parameters.allow_text_output
): # pragma: no branch
# This would result in `tool_choice=required`, which Anthropic does not support with thinking.
raise UserError(
'Anthropic does not support thinking and output tools at the same time. Use `output_type=PromptedOutput(...)` instead.'
'Anthropic does not support thinking and output tools at the same time. Use `output_type=NativeOutput(...)` instead.'
)
return super().prepare_request(model_settings, model_request_parameters)

Expand Down Expand Up @@ -327,15 +330,23 @@ async def _messages_create(
model_request_parameters: ModelRequestParameters,
) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]:
# standalone function to make it easier to override
tools = self._get_tools(model_request_parameters, model_settings)
tools, strict_tools_requested = self._get_tools(model_request_parameters, model_settings)
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
native_format = self._native_output_format(model_request_parameters)

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

system_prompt, anthropic_messages = await self._map_message(messages, model_request_parameters, model_settings)

# Build betas list for SDK
betas: list[str] = list(beta_features)
if strict_tools_requested or native_format:
betas.append('structured-outputs-2025-11-13')

try:
extra_headers = self._map_extra_headers(beta_features, model_settings)
# We use SDK's betas parameter instead of manual header manipulation
extra_headers = model_settings.get('extra_headers', {})
extra_headers.setdefault('User-Agent', get_user_agent())

return await self.client.beta.messages.create(
max_tokens=model_settings.get('max_tokens', 4096),
Expand All @@ -345,6 +356,8 @@ async def _messages_create(
tools=tools or OMIT,
tool_choice=tool_choice or OMIT,
mcp_servers=mcp_servers or OMIT,
output_format=native_format or OMIT,
betas=betas or OMIT,
stream=stream,
thinking=model_settings.get('anthropic_thinking', OMIT),
stop_sequences=model_settings.get('stop_sequences', OMIT),
Expand All @@ -370,7 +383,7 @@ async def _messages_count_tokens(
raise UserError('AsyncAnthropicBedrock client does not support `count_tokens` api.')

# standalone function to make it easier to override
tools = self._get_tools(model_request_parameters, model_settings)
tools, _ = self._get_tools(model_request_parameters, model_settings)
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)

tool_choice = self._infer_tool_choice(tools, model_settings, model_request_parameters)
Expand Down Expand Up @@ -472,10 +485,13 @@ async def _process_streamed_response(

def _get_tools(
self, model_request_parameters: ModelRequestParameters, model_settings: AnthropicModelSettings
) -> list[BetaToolUnionParam]:
tools: list[BetaToolUnionParam] = [
self._map_tool_definition(r) for r in model_request_parameters.tool_defs.values()
]
) -> tuple[list[BetaToolUnionParam], bool]:
tools: list[BetaToolUnionParam] = []
strict_tools_requested = False
for tool_def in model_request_parameters.tool_defs.values():
tools.append(self._map_tool_definition(tool_def))
if tool_def.strict and self.profile.supports_json_schema_output:
strict_tools_requested = True

# Add cache_control to the last tool if enabled
if tools and (cache_tool_defs := model_settings.get('anthropic_cache_tool_definitions')):
Expand All @@ -484,7 +500,7 @@ def _get_tools(
last_tool = tools[-1]
last_tool['cache_control'] = BetaCacheControlEphemeralParam(type='ephemeral', ttl=ttl)

return tools
return tools, strict_tools_requested

def _add_builtin_tools(
self, tools: list[BetaToolUnionParam], model_request_parameters: ModelRequestParameters
Expand Down Expand Up @@ -835,13 +851,23 @@ 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:
tool_param: BetaToolParam = {
'name': f.name,
'description': f.description or '',
'input_schema': f.parameters_json_schema,
}
if f.strict and self.profile.supports_json_schema_output: # pragma: no branch
tool_param['strict'] = f.strict
return tool_param

@staticmethod
def _native_output_format(model_request_parameters: ModelRequestParameters) -> BetaJSONOutputFormatParam | None:
if model_request_parameters.output_mode != 'native':
return None
output_object = model_request_parameters.output_object
assert output_object is not None
return {'type': 'json_schema', 'schema': output_object.json_schema}


def _map_usage(
Expand Down
14 changes: 12 additions & 2 deletions pydantic_ai_slim/pydantic_ai/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ class ModelProfile:
supports_tools: bool = True
"""Whether the model supports tools."""
supports_json_schema_output: bool = False
"""Whether the model supports JSON schema output."""
"""Whether the model supports JSON schema output.

This is also referred to as 'native' support for structured output.
This is the preferred way to get structured output from the model when available.
Relates to the `NativeOutput` output type.
"""
supports_json_object_output: bool = False
"""Whether the model supports JSON object output."""
"""Whether the model supports JSON object output.

This is different from `supports_json_schema_output` in that it indicates whether the model can return arbitrary JSON objects,
rather than only JSON objects that conform to a provided JSON schema.
Relates to the `PromptedOutput` output type.
"""
supports_image_output: bool = False
"""Whether the model supports image output."""
default_structured_output_mode: StructuredOutputMode = 'tool'
Expand Down
138 changes: 137 additions & 1 deletion pydantic_ai_slim/pydantic_ai/profiles/anthropic.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,144 @@
from __future__ import annotations as _annotations

from dataclasses import dataclass

from .._json_schema import JsonSchema, JsonSchemaTransformer
from . import ModelProfile


def _schema_is_lossless(schema: JsonSchema) -> bool: # noqa: C901
"""Return True when `anthropic.transform_schema` won't need to drop constraints.

Anthropic's structured output API only supports a subset of JSON Schema features.
This function detects whether a schema uses only supported features, allowing us
to safely enable strict mode for guaranteed server-side validation.

Checks are performed based on https://docs.claude.com/en/docs/build-with-claude/structured-outputs#how-sdk-transformation-works

Args:
schema: JSON Schema dictionary (typically from BaseModel.model_json_schema())

Returns:
True if schema is lossless (all constraints preserved), False if lossy

Examples:
Lossless schemas (constraints preserved):
>>> _schema_is_lossless({'type': 'string'})
True
>>> _schema_is_lossless({'type': 'object', 'properties': {'name': {'type': 'string'}}})
True

Lossy schemas (constraints dropped):
>>> _schema_is_lossless({'type': 'string', 'minLength': 5})
False
>>> _schema_is_lossless({'type': 'array', 'items': {'type': 'string'}, 'minItems': 2})
False

Note:
Some checks handle edge cases that rarely occur with Pydantic-generated schemas:
- oneOf: Pydantic generates anyOf for Union types
- Custom formats: Pydantic doesn't expose custom format generation in normal API
"""
from anthropic.lib._parse._transform import SupportedStringFormats

def _walk(node: JsonSchema) -> bool: # noqa: C901
if not isinstance(node, dict): # pragma: no cover
return False

node = dict(node)

if '$ref' in node:
node.pop('$ref')
return not node

defs = node.pop('$defs', None)
if defs:
for value in defs.values():
if not _walk(value):
return False

type_ = node.pop('type', None)
any_of = node.pop('anyOf', None)
one_of = node.pop('oneOf', None)
all_of = node.pop('allOf', None)

node.pop('description', None)
node.pop('title', None)

# every sub-schema in the list must itself be lossless -> `all(_walk(item) for item in any_of)`
# the wrapper object must not have any other unsupported fields -> `and not node`
if isinstance(any_of, list):
return all(_walk(item) for item in any_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
if isinstance(one_of, list): # pragma: no cover
# pydantic generates anyOf for Union types, leaving this here for JSON schemas that don't come from pydantic.BaseModel
return all(_walk(item) for item in one_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
if isinstance(all_of, list):
return all(_walk(item) for item in all_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]

if type_ is None:
return False

if type_ == 'object':
properties = node.pop('properties', None)
if properties:
for value in properties.values():
if not _walk(value):
return False
additional = node.pop('additionalProperties', None)
if additional not in (None, False):
return False
node.pop('required', None)
elif type_ == 'array':
items = node.pop('items', None)
if items and not _walk(items):
return False
min_items = node.pop('minItems', None)
if min_items not in (None, 0, 1):
return False
elif type_ == 'string':
format_ = node.pop('format', None)
if format_ is not None and format_ not in SupportedStringFormats: # pragma: no cover
return False
elif type_ in {'integer', 'number', 'boolean', 'null'}:
pass
else:
return False

return not node

return _walk(schema)


@dataclass(init=False)
class AnthropicJsonSchemaTransformer(JsonSchemaTransformer):
"""Transforms schemas to the subset supported by Anthropic structured outputs."""

def walk(self) -> JsonSchema:
from anthropic import transform_schema

schema = super().walk()
if self.is_strict_compatible and not _schema_is_lossless(schema):
# check compatibility before calling anthropic's transformer
# so we don't auto-enable strict when the SDK would drop constraints
self.is_strict_compatible = False
transformed = transform_schema(schema)
return transformed

def transform(self, schema: JsonSchema) -> JsonSchema:
# for consistency with other transformers (openai,google)
schema.pop('title', None)
schema.pop('$schema', None)
return schema


def anthropic_model_profile(model_name: str) -> ModelProfile | None:
"""Get the model profile for an Anthropic model."""
return ModelProfile(thinking_tags=('<thinking>', '</thinking>'))
models_that_support_json_schema_output = ('claude-sonnet-4-5', 'claude-opus-4-1')
# anthropic introduced support for both structured outputs and strict tool use
# https://docs.claude.com/en/docs/build-with-claude/structured-outputs#example-usage
supports_json_schema_output = model_name.startswith(models_that_support_json_schema_output)
return ModelProfile(
thinking_tags=('<thinking>', '</thinking>'),
supports_json_schema_output=supports_json_schema_output,
json_schema_transformer=AnthropicJsonSchemaTransformer,
)
2 changes: 1 addition & 1 deletion pydantic_ai_slim/pydantic_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ class ToolDefinition:
When `False`, the model may be free to generate other properties or types (depending on the vendor).
When `None` (the default), the value will be inferred based on the compatibility of the parameters_json_schema.

Note: this is currently only supported by OpenAI models.
Note: this is currently supported by OpenAI and Anthropic models.
"""

sequential: bool = False
Expand Down
2 changes: 1 addition & 1 deletion pydantic_ai_slim/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ openai = ["openai>=1.107.2"]
cohere = ["cohere>=5.18.0; platform_system != 'Emscripten'"]
vertexai = ["google-auth>=2.36.0", "requests>=2.32.2"]
google = ["google-genai>=1.51.0"]
anthropic = ["anthropic>=0.70.0"]
anthropic = ["anthropic>=0.74.0"]
groq = ["groq>=0.25.0"]
mistral = ["mistralai>=1.9.10"]
bedrock = ["boto3>=1.40.14"]
Expand Down
Loading
Loading