Skip to content

Commit 34314b3

Browse files
authored
Don't require anthropic dependency when using Anthropic model with other provider (#3652)
1 parent f42e523 commit 34314b3

File tree

6 files changed

+69
-59
lines changed

6 files changed

+69
-59
lines changed
Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
from __future__ import annotations as _annotations
22

3-
from dataclasses import dataclass
4-
5-
from .._json_schema import JsonSchema, JsonSchemaTransformer
63
from . import ModelProfile
74

85

@@ -22,54 +19,4 @@ def anthropic_model_profile(model_name: str) -> ModelProfile | None:
2219
return ModelProfile(
2320
thinking_tags=('<thinking>', '</thinking>'),
2421
supports_json_schema_output=supports_json_schema_output,
25-
json_schema_transformer=AnthropicJsonSchemaTransformer,
2622
)
27-
28-
29-
@dataclass(init=False)
30-
class AnthropicJsonSchemaTransformer(JsonSchemaTransformer):
31-
"""Transforms schemas to the subset supported by Anthropic structured outputs.
32-
33-
Transformation is applied when:
34-
- `NativeOutput` is used as the `output_type` of the Agent
35-
- `strict=True` is set on the `Tool`
36-
37-
The behavior of this transformer differs from the OpenAI one in that it sets `Tool.strict=False` by default when not explicitly set to True.
38-
39-
Example:
40-
```python
41-
from pydantic_ai import Agent
42-
43-
agent = Agent('anthropic:claude-sonnet-4-5')
44-
45-
@agent.tool_plain # -> defaults to strict=False
46-
def my_tool(x: str) -> dict[str, int]:
47-
...
48-
```
49-
50-
Anthropic's SDK `transform_schema()` automatically:
51-
- Adds `additionalProperties: false` to all objects (required by API)
52-
- Removes unsupported constraints (minLength, pattern, etc.)
53-
- Moves removed constraints to description field
54-
- Removes title and $schema fields
55-
"""
56-
57-
def walk(self) -> JsonSchema:
58-
from anthropic import transform_schema
59-
60-
schema = super().walk()
61-
62-
# The caller (pydantic_ai.models._customize_tool_def or _customize_output_object) coalesces
63-
# - output_object.strict = self.is_strict_compatible
64-
# - tool_def.strict = self.is_strict_compatible
65-
# the reason we don't default to `strict=True` is that the transformation could be lossy
66-
# so in order to change the behavior (default to True), we need to come up with logic that will check for lossiness
67-
# https://github.com/pydantic/pydantic-ai/issues/3541
68-
self.is_strict_compatible = self.strict is True # not compatible when strict is False/None
69-
70-
return transform_schema(schema) if self.strict is True else schema
71-
72-
def transform(self, schema: JsonSchema) -> JsonSchema:
73-
schema.pop('title', None)
74-
schema.pop('$schema', None)
75-
return schema

pydantic_ai_slim/pydantic_ai/providers/anthropic.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations as _annotations
22

33
import os
4+
from dataclasses import dataclass
45
from typing import TypeAlias, overload
56

67
import httpx
@@ -11,6 +12,8 @@
1112
from pydantic_ai.profiles.anthropic import anthropic_model_profile
1213
from pydantic_ai.providers import Provider
1314

15+
from .._json_schema import JsonSchema, JsonSchemaTransformer
16+
1417
try:
1518
from anthropic import AsyncAnthropic, AsyncAnthropicBedrock, AsyncAnthropicVertex
1619
except ImportError as _import_error:
@@ -39,7 +42,8 @@ def client(self) -> AsyncAnthropicClient:
3942
return self._client
4043

4144
def model_profile(self, model_name: str) -> ModelProfile | None:
42-
return anthropic_model_profile(model_name)
45+
profile = anthropic_model_profile(model_name)
46+
return ModelProfile(json_schema_transformer=AnthropicJsonSchemaTransformer).update(profile)
4347

4448
@overload
4549
def __init__(self, *, anthropic_client: AsyncAnthropicClient | None = None) -> None: ...
@@ -83,3 +87,55 @@ def __init__(
8387
else:
8488
http_client = cached_async_http_client(provider='anthropic')
8589
self._client = AsyncAnthropic(api_key=api_key, base_url=base_url, http_client=http_client)
90+
91+
92+
@dataclass(init=False)
93+
class AnthropicJsonSchemaTransformer(JsonSchemaTransformer):
94+
"""Transforms schemas to the subset supported by Anthropic structured outputs.
95+
96+
Transformation is applied when:
97+
- `NativeOutput` is used as the `output_type` of the Agent
98+
- `strict=True` is set on the `Tool`
99+
100+
The behavior of this transformer differs from the OpenAI one in that it sets `Tool.strict=False` by default when not explicitly set to True.
101+
102+
Example:
103+
```python
104+
from pydantic_ai import Agent
105+
106+
agent = Agent('anthropic:claude-sonnet-4-5')
107+
108+
@agent.tool_plain # -> defaults to strict=False
109+
def my_tool(x: str) -> dict[str, int]:
110+
...
111+
```
112+
113+
Anthropic's SDK `transform_schema()` automatically:
114+
- Adds `additionalProperties: false` to all objects (required by API)
115+
- Removes unsupported constraints (minLength, pattern, etc.)
116+
- Moves removed constraints to description field
117+
- Removes title and $schema fields
118+
"""
119+
120+
def walk(self) -> JsonSchema:
121+
schema = super().walk()
122+
123+
# The caller (pydantic_ai.models._customize_tool_def or _customize_output_object) coalesces
124+
# - output_object.strict = self.is_strict_compatible
125+
# - tool_def.strict = self.is_strict_compatible
126+
# the reason we don't default to `strict=True` is that the transformation could be lossy
127+
# so in order to change the behavior (default to True), we need to come up with logic that will check for lossiness
128+
# https://github.com/pydantic/pydantic-ai/issues/3541
129+
self.is_strict_compatible = self.strict is True # not compatible when strict is False/None
130+
131+
if self.strict is True:
132+
from anthropic import transform_schema
133+
134+
return transform_schema(schema)
135+
else:
136+
return schema
137+
138+
def transform(self, schema: JsonSchema) -> JsonSchema:
139+
schema.pop('title', None)
140+
schema.pop('$schema', None)
141+
return schema

tests/profiles/test_anthropic.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
from inline_snapshot import snapshot
2323
from pydantic import BaseModel, Field
2424

25+
from pydantic_ai.providers.anthropic import AnthropicJsonSchemaTransformer
26+
2527
from ..conftest import try_import
2628

2729
with try_import() as imports_successful:
28-
from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer, anthropic_model_profile
30+
from pydantic_ai.profiles.anthropic import anthropic_model_profile
2931

3032
pytestmark = [
3133
pytest.mark.skipif(not imports_successful(), reason='anthropic not installed'),

tests/providers/test_bedrock.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,16 @@ def test_bedrock_provider_model_profile(env: TestEnv, mocker: MockerFixture):
6060
anthropic_model_profile_mock.assert_called_with('claude-3-5-sonnet-20240620')
6161
assert isinstance(anthropic_profile, BedrockModelProfile)
6262
assert anthropic_profile.bedrock_supports_tool_choice is True
63+
# Bedrock does not support native structured output, even for models that support it via direct Anthropic API
64+
assert anthropic_profile.supports_json_schema_output is False
65+
assert anthropic_profile.json_schema_transformer is None
6366

6467
anthropic_profile = provider.model_profile('anthropic.claude-instant-v1')
6568
anthropic_model_profile_mock.assert_called_with('claude-instant')
6669
assert isinstance(anthropic_profile, BedrockModelProfile)
6770
assert anthropic_profile.bedrock_supports_tool_choice is True
71+
assert anthropic_profile.supports_json_schema_output is False
72+
assert anthropic_profile.json_schema_transformer is None
6873

6974
mistral_profile = provider.model_profile('mistral.mistral-large-2407-v1:0')
7075
mistral_model_profile_mock.assert_called_with('mistral-large-2407')

tests/providers/test_openrouter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pydantic_ai.agent import Agent
1010
from pydantic_ai.exceptions import UserError
1111
from pydantic_ai.profiles.amazon import amazon_model_profile
12-
from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer, anthropic_model_profile
12+
from pydantic_ai.profiles.anthropic import anthropic_model_profile
1313
from pydantic_ai.profiles.cohere import cohere_model_profile
1414
from pydantic_ai.profiles.deepseek import deepseek_model_profile
1515
from pydantic_ai.profiles.google import google_model_profile
@@ -127,7 +127,7 @@ def test_openrouter_provider_model_profile(mocker: MockerFixture):
127127
anthropic_profile = provider.model_profile('anthropic/claude-3.5-sonnet')
128128
anthropic_model_profile_mock.assert_called_with('claude-3.5-sonnet')
129129
assert anthropic_profile is not None
130-
assert anthropic_profile.json_schema_transformer == AnthropicJsonSchemaTransformer
130+
assert anthropic_profile.json_schema_transformer == OpenAIJsonSchemaTransformer
131131

132132
mistral_profile = provider.model_profile('mistralai/mistral-large-2407')
133133
mistral_model_profile_mock.assert_called_with('mistral-large-2407')

tests/providers/test_vercel.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic_ai._json_schema import InlineDefsJsonSchemaTransformer
88
from pydantic_ai.exceptions import UserError
99
from pydantic_ai.profiles.amazon import amazon_model_profile
10-
from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer, anthropic_model_profile
10+
from pydantic_ai.profiles.anthropic import anthropic_model_profile
1111
from pydantic_ai.profiles.cohere import cohere_model_profile
1212
from pydantic_ai.profiles.deepseek import deepseek_model_profile
1313
from pydantic_ai.profiles.google import GoogleJsonSchemaTransformer, google_model_profile
@@ -82,7 +82,7 @@ def test_vercel_provider_model_profile(mocker: MockerFixture):
8282
profile = provider.model_profile('anthropic/claude-sonnet-4-5')
8383
anthropic_mock.assert_called_with('claude-sonnet-4-5')
8484
assert profile is not None
85-
assert profile.json_schema_transformer == AnthropicJsonSchemaTransformer
85+
assert profile.json_schema_transformer == OpenAIJsonSchemaTransformer
8686

8787
# Test bedrock provider
8888
profile = provider.model_profile('bedrock/anthropic.claude-sonnet-4-5')

0 commit comments

Comments
 (0)