|
1 | 1 | from __future__ import annotations as _annotations |
2 | 2 |
|
3 | 3 | import os |
| 4 | +from dataclasses import dataclass |
4 | 5 | from typing import TypeAlias, overload |
5 | 6 |
|
6 | 7 | import httpx |
|
11 | 12 | from pydantic_ai.profiles.anthropic import anthropic_model_profile |
12 | 13 | from pydantic_ai.providers import Provider |
13 | 14 |
|
| 15 | +from .._json_schema import JsonSchema, JsonSchemaTransformer |
| 16 | + |
14 | 17 | try: |
15 | 18 | from anthropic import AsyncAnthropic, AsyncAnthropicBedrock, AsyncAnthropicVertex |
16 | 19 | except ImportError as _import_error: |
@@ -39,7 +42,8 @@ def client(self) -> AsyncAnthropicClient: |
39 | 42 | return self._client |
40 | 43 |
|
41 | 44 | 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) |
43 | 47 |
|
44 | 48 | @overload |
45 | 49 | def __init__(self, *, anthropic_client: AsyncAnthropicClient | None = None) -> None: ... |
@@ -83,3 +87,55 @@ def __init__( |
83 | 87 | else: |
84 | 88 | http_client = cached_async_http_client(provider='anthropic') |
85 | 89 | 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 |
0 commit comments