Skip to content

Commit cbcb783

Browse files
committed
- adds pragmas to transformer
- makes schema tests clearer by showing original and transofrmed - handles multiple beta-heards (wasn't hadnled previously) - unifies betas logic around sdk's betas parameter
1 parent e3b67f7 commit cbcb783

File tree

7 files changed

+303
-79
lines changed

7 files changed

+303
-79
lines changed

pydantic_ai_slim/pydantic_ai/models/anthropic.py

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -330,23 +330,19 @@ async def _messages_create(
330330
model_request_parameters: ModelRequestParameters,
331331
) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]:
332332
# standalone function to make it easier to override
333-
tools, strict_tools_requested = self._get_tools(model_request_parameters, model_settings)
334-
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
333+
tools = self._get_tools(model_request_parameters, model_settings)
334+
tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, model_request_parameters)
335335
native_format = self._native_output_format(model_request_parameters)
336336

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

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

341-
# Build betas list for SDK
342-
betas: list[str] = list(beta_features)
343-
if strict_tools_requested or native_format:
344-
betas.append('structured-outputs-2025-11-13')
341+
betas = self._get_required_betas(model_request_parameters)
342+
betas.update(builtin_tool_betas)
345343

346344
try:
347-
# We use SDK's betas parameter instead of manual header manipulation
348-
extra_headers = model_settings.get('extra_headers', {})
349-
extra_headers.setdefault('User-Agent', get_user_agent())
345+
betas_list, extra_headers = self._prepare_betas_and_headers(betas, model_settings)
350346

351347
return await self.client.beta.messages.create(
352348
max_tokens=model_settings.get('max_tokens', 4096),
@@ -357,7 +353,7 @@ async def _messages_create(
357353
tool_choice=tool_choice or OMIT,
358354
mcp_servers=mcp_servers or OMIT,
359355
output_format=native_format or OMIT,
360-
betas=betas or OMIT,
356+
betas=betas_list or OMIT,
361357
stream=stream,
362358
thinking=model_settings.get('anthropic_thinking', OMIT),
363359
stop_sequences=model_settings.get('stop_sequences', OMIT),
@@ -383,15 +379,18 @@ async def _messages_count_tokens(
383379
raise UserError('AsyncAnthropicBedrock client does not support `count_tokens` api.')
384380

385381
# standalone function to make it easier to override
386-
tools, _ = self._get_tools(model_request_parameters, model_settings)
387-
tools, mcp_servers, beta_features = self._add_builtin_tools(tools, model_request_parameters)
382+
tools = self._get_tools(model_request_parameters, model_settings)
383+
tools, mcp_servers, builtin_tool_betas = self._add_builtin_tools(tools, model_request_parameters)
388384

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

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

389+
betas = self._get_required_betas(model_request_parameters)
390+
betas.update(builtin_tool_betas)
391+
393392
try:
394-
extra_headers = self._map_extra_headers(beta_features, model_settings)
393+
betas_list, extra_headers = self._prepare_betas_and_headers(betas, model_settings)
395394

396395
return await self.client.beta.messages.count_tokens(
397396
system=system_prompt or OMIT,
@@ -400,6 +399,7 @@ async def _messages_count_tokens(
400399
tools=tools or OMIT,
401400
tool_choice=tool_choice or OMIT,
402401
mcp_servers=mcp_servers or OMIT,
402+
betas=betas_list or OMIT,
403403
thinking=model_settings.get('anthropic_thinking', OMIT),
404404
timeout=model_settings.get('timeout', NOT_GIVEN),
405405
extra_headers=extra_headers,
@@ -485,13 +485,10 @@ async def _process_streamed_response(
485485

486486
def _get_tools(
487487
self, model_request_parameters: ModelRequestParameters, model_settings: AnthropicModelSettings
488-
) -> tuple[list[BetaToolUnionParam], bool]:
488+
) -> list[BetaToolUnionParam]:
489489
tools: list[BetaToolUnionParam] = []
490-
strict_tools_requested = False
491490
for tool_def in model_request_parameters.tool_defs.values():
492491
tools.append(self._map_tool_definition(tool_def))
493-
if tool_def.strict and self.profile.supports_json_schema_output:
494-
strict_tools_requested = True
495492

496493
# Add cache_control to the last tool if enabled
497494
if tools and (cache_tool_defs := model_settings.get('anthropic_cache_tool_definitions')):
@@ -500,12 +497,35 @@ def _get_tools(
500497
last_tool = tools[-1]
501498
last_tool['cache_control'] = BetaCacheControlEphemeralParam(type='ephemeral', ttl=ttl)
502499

503-
return tools, strict_tools_requested
500+
return tools
501+
502+
def _get_required_betas(self, model_request_parameters: ModelRequestParameters) -> set[str]:
503+
"""Determine which beta features are needed based on tools and output format.
504+
505+
Args:
506+
model_request_parameters: Model request parameters containing tools and output settings
507+
508+
Returns:
509+
Set of beta feature strings (naturally deduplicated)
510+
"""
511+
betas: set[str] = set()
512+
513+
has_strict_tools = any(
514+
tool_def.strict and self.profile.supports_json_schema_output
515+
for tool_def in model_request_parameters.tool_defs.values()
516+
)
517+
518+
has_native_output = model_request_parameters.output_mode == 'native'
519+
520+
if has_strict_tools or has_native_output:
521+
betas.add('structured-outputs-2025-11-13')
522+
523+
return betas
504524

505525
def _add_builtin_tools(
506526
self, tools: list[BetaToolUnionParam], model_request_parameters: ModelRequestParameters
507-
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], list[str]]:
508-
beta_features: list[str] = []
527+
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], set[str]]:
528+
beta_features: set[str] = set()
509529
mcp_servers: list[BetaRequestMCPServerURLDefinitionParam] = []
510530
for tool in model_request_parameters.builtin_tools:
511531
if isinstance(tool, WebSearchTool):
@@ -522,14 +542,14 @@ def _add_builtin_tools(
522542
)
523543
elif isinstance(tool, CodeExecutionTool): # pragma: no branch
524544
tools.append(BetaCodeExecutionTool20250522Param(name='code_execution', type='code_execution_20250522'))
525-
beta_features.append('code-execution-2025-05-22')
545+
beta_features.add('code-execution-2025-05-22')
526546
elif isinstance(tool, MemoryTool): # pragma: no branch
527547
if 'memory' not in model_request_parameters.tool_defs:
528548
raise UserError("Built-in `MemoryTool` requires a 'memory' tool to be defined.")
529549
# Replace the memory tool definition with the built-in memory tool
530550
tools = [tool for tool in tools if tool['name'] != 'memory']
531551
tools.append(BetaMemoryTool20250818Param(name='memory', type='memory_20250818'))
532-
beta_features.append('context-management-2025-06-27')
552+
beta_features.add('context-management-2025-06-27')
533553
elif isinstance(tool, MCPServerTool) and tool.url:
534554
mcp_server_url_definition_param = BetaRequestMCPServerURLDefinitionParam(
535555
type='url',
@@ -544,7 +564,7 @@ def _add_builtin_tools(
544564
if tool.authorization_token: # pragma: no cover
545565
mcp_server_url_definition_param['authorization_token'] = tool.authorization_token
546566
mcp_servers.append(mcp_server_url_definition_param)
547-
beta_features.append('mcp-client-2025-04-04')
567+
beta_features.add('mcp-client-2025-04-04')
548568
else: # pragma: no cover
549569
raise UserError(
550570
f'`{tool.__class__.__name__}` is not supported by `AnthropicModel`. If it should be, please file an issue.'
@@ -572,15 +592,33 @@ def _infer_tool_choice(
572592

573593
return tool_choice
574594

575-
def _map_extra_headers(self, beta_features: list[str], model_settings: AnthropicModelSettings) -> dict[str, str]:
576-
"""Apply beta_features to extra_headers in model_settings."""
595+
def _prepare_betas_and_headers(
596+
self, betas: set[str], model_settings: AnthropicModelSettings
597+
) -> tuple[list[str], dict[str, str]]:
598+
"""Prepare beta features list and extra headers for API request.
599+
600+
Handles merging custom anthropic-beta header from extra_headers into betas set
601+
and ensuring User-Agent is set.
602+
603+
Args:
604+
betas: Set of beta feature strings (naturally deduplicated)
605+
model_settings: Model settings containing extra_headers
606+
607+
Returns:
608+
Tuple of (betas list, extra_headers dict)
609+
"""
577610
extra_headers = model_settings.get('extra_headers', {})
578611
extra_headers.setdefault('User-Agent', get_user_agent())
579-
if beta_features:
580-
if 'anthropic-beta' in extra_headers:
581-
beta_features.insert(0, extra_headers['anthropic-beta'])
582-
extra_headers['anthropic-beta'] = ','.join(beta_features)
583-
return extra_headers
612+
613+
if 'anthropic-beta' in extra_headers:
614+
beta_value = extra_headers['anthropic-beta']
615+
for beta in beta_value.split(','):
616+
beta_stripped = beta.strip()
617+
if beta_stripped:
618+
betas.add(beta_stripped)
619+
del extra_headers['anthropic-beta']
620+
621+
return sorted(betas), extra_headers
584622

585623
async def _map_message( # noqa: C901
586624
self,

pydantic_ai_slim/pydantic_ai/profiles/anthropic.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ def _walk(node: JsonSchema) -> bool: # noqa: C901
7272
if isinstance(one_of, list): # pragma: no cover
7373
# pydantic generates anyOf for Union types, leaving this here for JSON schemas that don't come from pydantic.BaseModel
7474
return all(_walk(item) for item in one_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
75-
if isinstance(all_of, list):
75+
if isinstance(all_of, list): # pragma: no cover
7676
return all(_walk(item) for item in all_of) and not node # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
7777

78-
if type_ is None:
78+
if type_ is None: # pragma: no cover
7979
return False
8080

8181
if type_ == 'object':
@@ -101,7 +101,7 @@ def _walk(node: JsonSchema) -> bool: # noqa: C901
101101
return False
102102
elif type_ in {'integer', 'number', 'boolean', 'null'}:
103103
pass
104-
else:
104+
else: # pragma: no cover
105105
return False
106106

107107
return not node
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
interactions:
2+
- request:
3+
headers:
4+
accept:
5+
- application/json
6+
accept-encoding:
7+
- gzip, deflate
8+
connection:
9+
- keep-alive
10+
content-length:
11+
- '1162'
12+
content-type:
13+
- application/json
14+
host:
15+
- api.anthropic.com
16+
method: POST
17+
parsed_body:
18+
max_tokens: 4096
19+
messages:
20+
- content:
21+
- text: What language is spoken in the user country?
22+
type: text
23+
role: user
24+
model: claude-sonnet-4-5
25+
output_format:
26+
schema:
27+
additionalProperties: false
28+
properties:
29+
result:
30+
anyOf:
31+
- additionalProperties: false
32+
description: A city and its country.
33+
properties:
34+
data:
35+
additionalProperties: false
36+
properties:
37+
city:
38+
type: string
39+
country:
40+
type: string
41+
required:
42+
- city
43+
- country
44+
type: object
45+
kind:
46+
description: '{const: CityLocation}'
47+
type: string
48+
required:
49+
- kind
50+
- data
51+
type: object
52+
- additionalProperties: false
53+
properties:
54+
data:
55+
additionalProperties: false
56+
properties:
57+
country:
58+
type: string
59+
language:
60+
type: string
61+
required:
62+
- country
63+
- language
64+
type: object
65+
kind:
66+
description: '{const: CountryLanguage}'
67+
type: string
68+
required:
69+
- kind
70+
- data
71+
type: object
72+
required:
73+
- result
74+
type: object
75+
type: json_schema
76+
stream: false
77+
tool_choice:
78+
type: auto
79+
tools:
80+
- description: ''
81+
input_schema:
82+
additionalProperties: false
83+
properties: {}
84+
type: object
85+
name: get_user_country
86+
strict: true
87+
uri: https://api.anthropic.com/v1/messages?beta=true
88+
response:
89+
headers:
90+
connection:
91+
- keep-alive
92+
content-length:
93+
- '130'
94+
content-type:
95+
- application/json
96+
strict-transport-security:
97+
- max-age=31536000; includeSubDomains; preload
98+
parsed_body:
99+
error:
100+
message: invalid x-api-key
101+
type: authentication_error
102+
request_id: req_011CVKJbTXMN68Pzm15QEJpK
103+
type: error
104+
status:
105+
code: 401
106+
message: Unauthorized
107+
version: 1

tests/models/anthropic/conftest.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,6 @@ def mock_sonnet_4_5(allow_model_requests: None) -> tuple[AnthropicModel, AsyncAn
5353
return model, mock_client
5454

5555

56-
@pytest.fixture
57-
def mock_sonnet_4_0(allow_model_requests: None) -> tuple[AnthropicModel, AsyncAnthropic]:
58-
"""Mock claude-sonnet-4-0 model for unit tests."""
59-
c = completion_message(
60-
[BetaTextBlock(text='response', type='text')],
61-
BetaUsage(input_tokens=5, output_tokens=10),
62-
)
63-
mock_client = MockAnthropic.create_mock(c)
64-
model = AnthropicModel('claude-sonnet-4-0', provider=AnthropicProvider(anthropic_client=mock_client))
65-
return model, mock_client
66-
67-
6856
# Schema fixtures
6957
@pytest.fixture
7058
def city_location_schema() -> type[BaseModel]:

0 commit comments

Comments
 (0)