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
20 changes: 12 additions & 8 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 All @@ -994,13 +998,13 @@ def _customize_tool_def(transformer: type[JsonSchemaTransformer], t: ToolDefinit
)


def _customize_output_object(transformer: type[JsonSchemaTransformer], o: OutputObjectDefinition):
schema_transformer = transformer(o.json_schema, strict=o.strict)
def _customize_output_object(transformer: type[JsonSchemaTransformer], output_object: OutputObjectDefinition):
schema_transformer = transformer(output_object.json_schema, strict=output_object.strict)
json_schema = schema_transformer.walk()
return replace(
o,
output_object,
json_schema=json_schema,
strict=schema_transformer.is_strict_compatible if o.strict is None else o.strict,
strict=schema_transformer.is_strict_compatible if output_object.strict is None else output_object.strict,
)


Expand Down
110 changes: 89 additions & 21 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,14 +291,27 @@ 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.'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mode we recommend should be dynamic based on supports_json_schema_output; see Google where we do the same thing

)

if (
model_request_parameters.output_mode == 'native' and model_request_parameters.output_object is not None
): # pragma: no branch
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pragma: no branch is weird, as it means we never get here with an output mode other than native. There should definitely be cases where we get here with tool or prompted

# force strict=True for native output (Anthropic requires it)
# this needs to be done here because `super().prepare_request` calls
# -> Model.customize_request_parameters(model_request_parameters) which calls
# -> -> _customize_output_object(transformer: type[JsonSchemaTransformer], output_object: OutputObjectDefinition)
# which finally calls sets `json_schema = schema_transformer.walk()`
model_request_parameters = replace(
model_request_parameters, output_object=replace(model_request_parameters.output_object, strict=True)
)
return super().prepare_request(model_settings, model_request_parameters)

@overload
Expand Down Expand Up @@ -328,14 +343,18 @@ async def _messages_create(
) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]:
# standalone function to make it easier to override
tools = self._get_tools(model_request_parameters, model_settings)
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, model_request_parameters)
output_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)

betas = self._get_required_betas(model_request_parameters)
betas.update(builtin_tool_betas)

try:
extra_headers = self._map_extra_headers(beta_features, model_settings)
betas_list, extra_headers = self._prepare_betas_and_headers(betas, model_settings)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having both betas and betas_list is a bit weird. I'd rather have just betas as a set, and then turn it into a list when we pass it into the method below


return await self.client.beta.messages.create(
max_tokens=model_settings.get('max_tokens', 4096),
Expand All @@ -345,6 +364,8 @@ async def _messages_create(
tools=tools or OMIT,
tool_choice=tool_choice or OMIT,
mcp_servers=mcp_servers or OMIT,
output_format=output_format or OMIT,
betas=betas_list or OMIT,
stream=stream,
thinking=model_settings.get('anthropic_thinking', OMIT),
stop_sequences=model_settings.get('stop_sequences', OMIT),
Expand All @@ -371,14 +392,17 @@ async def _messages_count_tokens(

# standalone function to make it easier to override
tools = self._get_tools(model_request_parameters, model_settings)
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, 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)

betas = self._get_required_betas(model_request_parameters)
betas.update(builtin_tool_betas)

try:
extra_headers = self._map_extra_headers(beta_features, model_settings)
betas_list, extra_headers = self._prepare_betas_and_headers(betas, model_settings)

return await self.client.beta.messages.count_tokens(
system=system_prompt or OMIT,
Expand All @@ -387,6 +411,7 @@ async def _messages_count_tokens(
tools=tools or OMIT,
tool_choice=tool_choice or OMIT,
mcp_servers=mcp_servers or OMIT,
betas=betas_list or OMIT,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird that we don't have to pass output_format here, as it does contribute to token usage. Can you make an explicit comment about that, so it doesn't look like an oversight?

thinking=model_settings.get('anthropic_thinking', OMIT),
timeout=model_settings.get('timeout', NOT_GIVEN),
extra_headers=extra_headers,
Expand Down Expand Up @@ -486,10 +511,30 @@ def _get_tools(

return tools

def _get_required_betas(self, model_request_parameters: ModelRequestParameters) -> set[str]:
"""Determine which beta features are needed based on tools and output format.

Args:
model_request_parameters: Model request parameters containing tools and output settings

Returns:
Set of beta feature strings (naturally deduplicated)
"""
betas: set[str] = set()

has_strict_tools = self.profile.supports_json_schema_output and any(
tool_def.strict for tool_def in model_request_parameters.tool_defs.values()
)

if has_strict_tools or model_request_parameters.output_mode == 'native':
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a scenario where we can send a tool def with strict=True, without also sending the structured output beta: if ToolDefinition.strict is None (by default), has_strict_tools will be False, but customize_request_parameters will set strict=schema_transformer.is_strict_compatible, which maybe True.

So we should really add this beta depending on the result of _get_tools/_map_tool_definition, not the original ToolDefinitions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That means we also don't need to check self.profile.supports_json_schema_output here anymore, as the tool dicts only get strict=True if that value is enabled

betas.add('structured-outputs-2025-11-13')

return betas

def _add_builtin_tools(
self, tools: list[BetaToolUnionParam], model_request_parameters: ModelRequestParameters
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], list[str]]:
beta_features: list[str] = []
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], set[str]]:
beta_features: set[str] = set()
mcp_servers: list[BetaRequestMCPServerURLDefinitionParam] = []
for tool in model_request_parameters.builtin_tools:
if isinstance(tool, WebSearchTool):
Expand All @@ -506,14 +551,14 @@ def _add_builtin_tools(
)
elif isinstance(tool, CodeExecutionTool): # pragma: no branch
tools.append(BetaCodeExecutionTool20250522Param(name='code_execution', type='code_execution_20250522'))
beta_features.append('code-execution-2025-05-22')
beta_features.add('code-execution-2025-05-22')
elif isinstance(tool, MemoryTool): # pragma: no branch
if 'memory' not in model_request_parameters.tool_defs:
raise UserError("Built-in `MemoryTool` requires a 'memory' tool to be defined.")
# Replace the memory tool definition with the built-in memory tool
tools = [tool for tool in tools if tool['name'] != 'memory']
tools.append(BetaMemoryTool20250818Param(name='memory', type='memory_20250818'))
beta_features.append('context-management-2025-06-27')
beta_features.add('context-management-2025-06-27')
elif isinstance(tool, MCPServerTool) and tool.url:
mcp_server_url_definition_param = BetaRequestMCPServerURLDefinitionParam(
type='url',
Expand All @@ -528,7 +573,7 @@ def _add_builtin_tools(
if tool.authorization_token: # pragma: no cover
mcp_server_url_definition_param['authorization_token'] = tool.authorization_token
mcp_servers.append(mcp_server_url_definition_param)
beta_features.append('mcp-client-2025-04-04')
beta_features.add('mcp-client-2025-04-04')
else: # pragma: no cover
raise UserError(
f'`{tool.__class__.__name__}` is not supported by `AnthropicModel`. If it should be, please file an issue.'
Expand Down Expand Up @@ -556,15 +601,28 @@ def _infer_tool_choice(

return tool_choice

def _map_extra_headers(self, beta_features: list[str], model_settings: AnthropicModelSettings) -> dict[str, str]:
"""Apply beta_features to extra_headers in model_settings."""
def _prepare_betas_and_headers(
self, betas: set[str], model_settings: AnthropicModelSettings
) -> tuple[list[str], dict[str, str]]:
"""Prepare beta features list and extra headers for API request.

Handles merging custom anthropic-beta header from extra_headers into betas set
and ensuring User-Agent is set.

Args:
betas: Set of beta feature strings (naturally deduplicated)
model_settings: Model settings containing extra_headers

Returns:
Tuple of (betas list, extra_headers dict)
"""
extra_headers = model_settings.get('extra_headers', {})
extra_headers.setdefault('User-Agent', get_user_agent())
if beta_features:
if 'anthropic-beta' in extra_headers:
beta_features.insert(0, extra_headers['anthropic-beta'])
extra_headers['anthropic-beta'] = ','.join(beta_features)
return extra_headers

if beta_header := extra_headers.pop('anthropic-beta', None):
betas.update({stripped_beta for beta in beta_header.split(',') if (stripped_beta := beta.strip())})

return sorted(betas), extra_headers

async def _map_message( # noqa: C901
self,
Expand Down Expand Up @@ -835,13 +893,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
assert model_request_parameters.output_object is not None
model_request_parameters.output_object.strict = True
return {'type': 'json_schema', 'schema': model_request_parameters.output_object.json_schema}


def _map_usage(
Expand Down
12 changes: 10 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,17 @@ 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.
Relates to the `NativeOutput` output type.
"""
supports_json_object_output: bool = False
"""Whether the model supports JSON object output."""
"""Whether the model supports a dedicated mode to enforce JSON output, without necessarily sending a schema.

E.g. [OpenAI's JSON mode](https://platform.openai.com/docs/guides/structured-outputs#json-mode)
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
51 changes: 50 additions & 1 deletion pydantic_ai_slim/pydantic_ai/profiles/anthropic.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,57 @@
from __future__ import annotations as _annotations

from copy import deepcopy
from dataclasses import dataclass

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


@dataclass(init=False)
class AnthropicJsonSchemaTransformer(JsonSchemaTransformer):
"""Transforms schemas to the subset supported by Anthropic structured outputs.
Anthropic's SDK `transform_schema()` automatically:
- Adds `additionalProperties: false` to all objects (required by API)
- Removes unsupported constraints (minLength, pattern, etc.)
- Moves removed constraints to description field
- Removes title and $schema fields
When `strict=None`, we compare before/after to detect if constraints were dropped.
"""

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

schema = super().walk()

if self.strict is False:
# no transformation if specifically non-strict
return schema

if self.strict is None:
before = deepcopy(schema)
transformed = transform_schema(schema)
if before != transformed:
self.is_strict_compatible = False
return transformed
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we return the unmodified before if self.is_strict_compatible is False?


return transform_schema(schema)

def transform(self, schema: JsonSchema) -> JsonSchema:
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