Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
114 changes: 89 additions & 25 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 @@ -328,14 +331,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)
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)

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 +352,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_list or OMIT,
stream=stream,
thinking=model_settings.get('anthropic_thinking', OMIT),
stop_sequences=model_settings.get('stop_sequences', OMIT),
Expand All @@ -371,14 +380,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 +399,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 @@ -473,9 +486,9 @@ 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()
]
tools: list[BetaToolUnionParam] = []
for tool_def in model_request_parameters.tool_defs.values():
tools.append(self._map_tool_definition(tool_def))

# Add cache_control to the last tool if enabled
if tools and (cache_tool_defs := model_settings.get('anthropic_cache_tool_definitions')):
Expand All @@ -486,10 +499,33 @@ 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 = any(
tool_def.strict and self.profile.supports_json_schema_output
for tool_def in model_request_parameters.tool_defs.values()
)

has_native_output = model_request_parameters.output_mode == 'native'

if has_strict_tools or has_native_output:
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 +542,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 +564,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 +592,33 @@ 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 'anthropic-beta' in extra_headers:
beta_value = extra_headers['anthropic-beta']
for beta in beta_value.split(','):
beta_stripped = beta.strip()
if beta_stripped: # pragma: no branch
betas.add(beta_stripped)
del extra_headers['anthropic-beta']

return sorted(betas), extra_headers

async def _map_message( # noqa: C901
self,
Expand Down Expand Up @@ -835,13 +889,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.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Technically, if a model can return JSON conforming to a schema it can also return arbitrary JSON, as the schema could be defined to accept any object. SO it's not so much about whether it "can", than if it has a dedicated mode to use to enforce JSON output, without sending the whole schema along yet. It's probably easiest to specifically refer to OpenAI's JSON Mode as opposed to strict/structured output

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