feat(phase1): add image storage utilities

- Create backend/services/image_storage.py with 4 core functions:
  - sanitize_filename(): remove unsafe chars, limit to 255 chars, convert to lowercase
  - get_unique_filename(): handle collisions with UUID suffix (format: {name}_{uuid8}_{variant}.jpg)
  - ensure_image_directories(): create /images/ root and category subdirs on startup
  - save_image(): save bytes to /images/{category}/{filename}, returns relative path
- Create comprehensive test suite (22 tests) covering all functionality
- Integrate ensure_image_directories() into FastAPI startup event
- Directory structure: /images/{category}/{filename}
- Collision handling: auto-suffix with UUID if filename exists
- All tests passing, pathlib.Path for safe operations
This commit is contained in:
2026-04-20 21:57:26 +03:00
parent 39fab336ba
commit ea49cd6e4a
4487 changed files with 1305959 additions and 4 deletions

View File

@@ -0,0 +1,4 @@
File generated from our OpenAPI spec by Stainless.
This directory can be used to store custom files to expand the SDK.
It is ignored by Stainless code generation and its content (other than this keep file) won't be touched.

View File

@@ -0,0 +1 @@
from ._files import files_from_dir as files_from_dir, async_files_from_dir as async_files_from_dir

View File

@@ -0,0 +1 @@
from ._google_auth import google_auth as google_auth

View File

@@ -0,0 +1,13 @@
from ..._exceptions import AnthropicError
INSTRUCTIONS = """
Anthropic error: missing required dependency `{library}`.
$ pip install anthropic[{extra}]
"""
class MissingDependencyError(AnthropicError):
def __init__(self, *, library: str, extra: str) -> None:
super().__init__(INSTRUCTIONS.format(library=library, extra=extra))

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing_extensions import ClassVar, override
from ._common import MissingDependencyError
from ..._utils import LazyProxy
if TYPE_CHECKING:
import google.auth # type: ignore
google_auth = google.auth
class GoogleAuthProxy(LazyProxy[Any]):
should_cache: ClassVar[bool] = True
@override
def __load__(self) -> Any:
try:
import google.auth # type: ignore
except ImportError as err:
raise MissingDependencyError(extra="vertex", library="google-auth") from err
return google.auth
if not TYPE_CHECKING:
google_auth = GoogleAuthProxy()

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import os
from pathlib import Path
import anyio
from .._types import FileTypes
def files_from_dir(directory: str | os.PathLike[str]) -> list[FileTypes]:
path = Path(directory)
files: list[FileTypes] = []
_collect_files(path, path.parent, files)
return files
def _collect_files(directory: Path, relative_to: Path, files: list[FileTypes]) -> None:
for path in directory.iterdir():
if path.is_dir():
_collect_files(path, relative_to, files)
continue
files.append((path.relative_to(relative_to).as_posix(), path.read_bytes()))
async def async_files_from_dir(directory: str | os.PathLike[str]) -> list[FileTypes]:
path = anyio.Path(directory)
files: list[FileTypes] = []
await _async_collect_files(path, path.parent, files)
return files
async def _async_collect_files(directory: anyio.Path, relative_to: anyio.Path, files: list[FileTypes]) -> None:
async for path in directory.iterdir():
if await path.is_dir():
await _async_collect_files(path, relative_to, files)
continue
files.append((path.relative_to(relative_to).as_posix(), await path.read_bytes()))

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from typing_extensions import TypeVar
from ..._types import NotGiven
from ..._models import TypeAdapter, construct_type_unchecked
from ..._utils._utils import is_given
from ...types.message import Message
from ...types.parsed_message import ParsedMessage, ParsedTextBlock, ParsedContentBlock
from ...types.beta.beta_message import BetaMessage
from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaTextBlock, ParsedBetaContentBlock
ResponseFormatT = TypeVar("ResponseFormatT", default=None)
def parse_text(text: str, output_format: ResponseFormatT | NotGiven) -> ResponseFormatT | None:
if is_given(output_format):
adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format)
return adapted_type.validate_json(text)
return None
def parse_beta_response(
*,
output_format: ResponseFormatT | NotGiven,
response: BetaMessage,
) -> ParsedBetaMessage[ResponseFormatT]:
content_list: list[ParsedBetaContentBlock[ResponseFormatT]] = []
for content in response.content:
if content.type == "text":
content_list.append(
construct_type_unchecked(
type_=ParsedBetaTextBlock[ResponseFormatT],
value={**content.to_dict(), "parsed_output": parse_text(content.text, output_format)},
)
)
else:
content_list.append(content) # type: ignore
return construct_type_unchecked(
type_=ParsedBetaMessage[ResponseFormatT],
value={
**response.to_dict(),
"content": content_list,
},
)
def parse_response(
*,
output_format: ResponseFormatT | NotGiven,
response: Message,
) -> ParsedMessage[ResponseFormatT]:
content_list: list[ParsedContentBlock[ResponseFormatT]] = []
for content in response.content:
if content.type == "text":
content_list.append(
construct_type_unchecked(
type_=ParsedTextBlock[ResponseFormatT],
value={**content.to_dict(), "parsed_output": parse_text(content.text, output_format)},
)
)
else:
content_list.append(content) # type: ignore
return construct_type_unchecked(
type_=ParsedMessage[ResponseFormatT],
value={
**response.to_dict(),
"content": content_list,
},
)

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import inspect
from typing import Any, Literal, Optional, cast
from typing_extensions import assert_never
import pydantic
from ..._utils import is_list
SupportedTypes = Literal[
"object",
"array",
"string",
"integer",
"number",
"boolean",
"null",
]
SupportedStringFormats = {
"date-time",
"time",
"date",
"duration",
"email",
"hostname",
"uri",
"ipv4",
"ipv6",
"uuid",
}
def get_transformed_string(
schema: dict[str, Any],
) -> dict[str, Any]:
"""Transforms a JSON schema of type string to ensure it conforms to the API's expectations.
Specifically, it ensures that if the schema is of type "string" and does not already
specify a "format", it sets the format to "text".
Args:
schema: The original JSON schema.
Returns:
The transformed JSON schema.
"""
if schema.get("type") == "string" and "format" not in schema:
schema["format"] = "text"
return schema
def transform_schema(
json_schema: type[pydantic.BaseModel] | dict[str, Any],
) -> dict[str, Any]:
"""
Transforms a JSON schema to ensure it conforms to the API's expectations.
Args:
json_schema (Dict[str, Any]): The original JSON schema.
Returns:
The transformed JSON schema.
Examples:
>>> transform_schema(
... {
... "type": "integer",
... "minimum": 1,
... "maximum": 10,
... "description": "A number",
... }
... )
{'type': 'integer', 'description': 'A number\n\n{minimum: 1, maximum: 10}'}
"""
if inspect.isclass(json_schema) and issubclass(json_schema, pydantic.BaseModel): # pyright: ignore[reportUnnecessaryIsInstance]
json_schema = json_schema.model_json_schema()
strict_schema: dict[str, Any] = {}
json_schema = {**json_schema}
ref = json_schema.pop("$ref", None)
if ref is not None:
strict_schema["$ref"] = ref
return strict_schema
defs = json_schema.pop("$defs", None)
if defs is not None:
strict_defs: dict[str, Any] = {}
strict_schema["$defs"] = strict_defs
for name, schema in defs.items():
strict_defs[name] = transform_schema(schema)
type_: Optional[SupportedTypes] = json_schema.pop("type", None)
any_of = json_schema.pop("anyOf", None)
one_of = json_schema.pop("oneOf", None)
all_of = json_schema.pop("allOf", None)
if is_list(any_of):
strict_schema["anyOf"] = [transform_schema(cast("dict[str, Any]", variant)) for variant in any_of]
elif is_list(one_of):
strict_schema["anyOf"] = [transform_schema(cast("dict[str, Any]", variant)) for variant in one_of]
elif is_list(all_of):
strict_schema["allOf"] = [transform_schema(cast("dict[str, Any]", variant)) for variant in all_of]
else:
if type_ is None:
raise ValueError("Schema must have a 'type', 'anyOf', 'oneOf', or 'allOf' field.")
strict_schema["type"] = type_
enum = json_schema.pop("enum", None)
if is_list(enum):
strict_schema["enum"] = enum
description = json_schema.pop("description", None)
if description is not None:
strict_schema["description"] = description
title = json_schema.pop("title", None)
if title is not None:
strict_schema["title"] = title
if type_ == "object":
strict_schema["properties"] = {
key: transform_schema(prop_schema) for key, prop_schema in json_schema.pop("properties", {}).items()
}
json_schema.pop("additionalProperties", None)
strict_schema["additionalProperties"] = False
required = json_schema.pop("required", None)
if required is not None:
strict_schema["required"] = required
elif type_ == "string":
format = json_schema.pop("format", None)
if format and format in SupportedStringFormats:
strict_schema["format"] = format
elif format:
# add it back so its treated as an extra property and appended to the description
json_schema["format"] = format
elif type_ == "array":
items = json_schema.pop("items", None)
if items is not None:
strict_schema["items"] = transform_schema(items)
min_items = json_schema.pop("minItems", None)
if min_items is not None and min_items == 0 or min_items == 1:
strict_schema["minItems"] = min_items
elif min_items is not None:
# add it back so its treated as an extra property and appended to the description
json_schema["minItems"] = min_items
elif type_ == "boolean" or type_ == "integer" or type_ == "number" or type_ == "null" or type_ is None:
pass
else:
assert_never(type_)
# if there are any propes leftover then they aren't supported, so we add them to the description
# so that the model *might* follow them.
if json_schema:
description = strict_schema.get("description")
strict_schema["description"] = (
(description + "\n\n" if description is not None else "")
+ "{"
+ ", ".join(f"{key}: {value}" for key, value in json_schema.items())
+ "}"
)
return strict_schema

View File

@@ -0,0 +1,75 @@
"""Tracking for SDK helper usage via the x-stainless-helper header."""
from __future__ import annotations
from typing import Any, Dict, cast
_HELPER_ATTR = "_stainless_helper"
def tag_helper(obj: Any, name: str) -> None:
"""Mark an object as created by a named SDK helper."""
try:
object.__setattr__(obj, _HELPER_ATTR, name)
except (AttributeError, TypeError):
pass
def get_helper_tag(obj: object) -> str | None:
"""Get the helper name from an object, if any."""
return getattr(obj, _HELPER_ATTR, None) # type: ignore[return-value]
def collect_helpers(
tools: Any = None,
messages: Any = None,
) -> list[str]:
"""Collect deduplicated helper names from tools and messages."""
helpers: set[str] = set()
if tools:
for tool in tools:
tag = get_helper_tag(tool)
if tag is not None:
helpers.add(tag)
if messages:
for message in messages:
tag = get_helper_tag(message)
if tag is not None:
helpers.add(tag)
# Check content blocks within messages
if isinstance(message, dict):
blocks: Any = cast(Dict[str, Any], message).get("content")
else:
blocks = getattr(message, "content", None)
if isinstance(blocks, list):
for block in cast(list[object], blocks):
tag = get_helper_tag(block)
if tag is not None:
helpers.add(tag)
return list(helpers)
def stainless_helper_header(
tools: Any = None,
messages: Any = None,
) -> dict[str, str]:
"""Build x-stainless-helper header dict from tools and messages.
Returns an empty dict if no helpers are found.
"""
helpers = collect_helpers(tools, messages)
if not helpers:
return {}
return {"x-stainless-helper": ", ".join(helpers)}
def stainless_helper_header_from_file(file: object) -> dict[str, str]:
"""Build x-stainless-helper header dict from a file object."""
tag = get_helper_tag(file)
if tag is None:
return {}
return {"x-stainless-helper": tag}

View File

@@ -0,0 +1 @@
from ._client import AnthropicAWS as AnthropicAWS, AsyncAnthropicAWS as AsyncAnthropicAWS

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from ..._utils import lru_cache
if TYPE_CHECKING:
import boto3
@lru_cache(maxsize=512)
def _get_session(
*,
aws_access_key: str | None,
aws_secret_key: str | None,
aws_session_token: str | None,
region: str | None,
profile: str | None,
) -> boto3.Session:
import boto3
return boto3.Session(
profile_name=profile,
region_name=region,
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key,
aws_session_token=aws_session_token,
)
def get_auth_headers(
*,
method: str,
url: str,
headers: httpx.Headers,
aws_access_key: str | None,
aws_secret_key: str | None,
aws_session_token: str | None,
region: str | None,
profile: str | None,
data: str | None,
service_name: str,
) -> dict[str, str]:
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
session = _get_session(
profile=profile,
region=region,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
aws_session_token=aws_session_token,
)
# The connection header may be stripped by a proxy somewhere, so the receiver
# of this message may not see this header, so we remove it from the set of headers
# that are signed.
new_headers = {k: v for k, v in dict(headers).items() if k.lower() != "connection"}
request = AWSRequest(method=method.upper(), url=url, headers=new_headers, data=data)
credentials = session.get_credentials()
if not credentials:
raise RuntimeError("Could not resolve AWS credentials from session")
signer = SigV4Auth(credentials, service_name, session.region_name)
signer.add_auth(request)
prepped = request.prepare()
return {key: value for key, value in dict(prepped.headers).items() if value is not None}

View File

@@ -0,0 +1,401 @@
from __future__ import annotations
from typing import Any, Mapping
from typing_extensions import Self, override
import httpx
from ..._types import NOT_GIVEN, Omit, Headers, Timeout, NotGiven
from ..._client import Anthropic, AsyncAnthropic
from ._credentials import (
resolve_region,
resolve_api_key,
resolve_base_url,
resolve_auth_mode,
resolve_workspace_id,
validate_credentials,
)
from ..._exceptions import AnthropicError
from ..._base_client import DEFAULT_MAX_RETRIES
class AnthropicAWS(Anthropic):
aws_access_key: str | None
aws_secret_key: str | None
aws_region: str | None
aws_profile: str | None
aws_session_token: str | None
workspace_id: str | None
_use_sigv4: bool
_skip_auth: bool
def __init__(
self,
*,
api_key: str | None = None,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
aws_session_token: str | None = None,
workspace_id: str | None = None,
skip_auth: bool = False,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.Client | None = None,
_strict_response_validation: bool = False,
# Passed through to parent but not used for AWS auth
auth_token: str | None = None,
) -> None:
self._skip_auth = skip_auth
validate_credentials(aws_access_key=aws_access_key, aws_secret_key=aws_secret_key)
if skip_auth:
self._use_sigv4 = False
resolved_api_key = None
else:
self._use_sigv4 = resolve_auth_mode(
api_key=api_key,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
aws_profile=aws_profile,
)
resolved_api_key = resolve_api_key(api_key=api_key, use_sigv4=self._use_sigv4)
resolved_region = resolve_region(aws_region)
if self._use_sigv4 and resolved_region is None:
raise AnthropicError(
"No AWS region was provided. Set the `aws_region` argument or the `AWS_REGION`/`AWS_DEFAULT_REGION` environment variable."
)
self.aws_access_key = aws_access_key
self.aws_secret_key = aws_secret_key
self.aws_region = resolved_region
self.aws_profile = aws_profile
self.aws_session_token = aws_session_token
if skip_auth:
self.workspace_id = workspace_id
else:
resolved_workspace_id = resolve_workspace_id(workspace_id)
if resolved_workspace_id is None:
raise AnthropicError(
"No workspace ID found. Set the `workspace_id` argument or the `ANTHROPIC_AWS_WORKSPACE_ID` environment variable."
)
self.workspace_id = resolved_workspace_id
if not skip_auth:
resolved_base_url = resolve_base_url(
str(base_url) if base_url is not None else None,
region=resolved_region,
)
if resolved_base_url is None:
raise AnthropicError(
"No AWS region was provided and no base_url was given. "
"Set the `aws_region` argument, the `AWS_REGION`/`AWS_DEFAULT_REGION` environment variable, "
"or provide a `base_url` directly."
)
base_url = resolved_base_url
super().__init__(
api_key=resolved_api_key,
auth_token=auth_token,
base_url=base_url, # type: ignore[arg-type]
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
@property
@override
def default_headers(self) -> dict[str, str | Omit]:
headers = {**super().default_headers}
if self.workspace_id is not None:
headers["anthropic-workspace-id"] = self.workspace_id
return headers
@property
@override
def _api_key_auth(self) -> dict[str, str]:
if self._use_sigv4 or self._skip_auth:
return {}
return super()._api_key_auth
@override
def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None:
if self._use_sigv4 or self._skip_auth:
return
super()._validate_headers(headers, custom_headers)
@override
def _prepare_request(self, request: httpx.Request) -> None:
if not self._use_sigv4:
return
from ._auth import get_auth_headers
data = request.read().decode()
headers = get_auth_headers(
method=request.method,
url=str(request.url),
headers=request.headers,
aws_access_key=self.aws_access_key,
aws_secret_key=self.aws_secret_key,
aws_session_token=self.aws_session_token,
region=self.aws_region,
profile=self.aws_profile,
data=data,
service_name="aws-external-anthropic",
)
request.headers.update(headers)
@override
def copy(
self,
*,
api_key: str | None = None,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
aws_session_token: str | None = None,
workspace_id: str | None = None,
skip_auth: bool | None = None,
auth_token: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.Client | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
# If region is changing and no explicit base_url, let __init__ derive it
resolved_base_url = base_url or (None if aws_region else self.base_url)
return super().copy(
api_key=api_key or self.api_key,
auth_token=auth_token,
base_url=resolved_base_url,
timeout=timeout,
http_client=http_client,
max_retries=max_retries,
default_headers=default_headers,
set_default_headers=set_default_headers,
default_query=default_query,
set_default_query=set_default_query,
_extra_kwargs={
"aws_access_key": aws_access_key or self.aws_access_key,
"aws_secret_key": aws_secret_key or self.aws_secret_key,
"aws_region": aws_region or self.aws_region,
"aws_profile": aws_profile or self.aws_profile,
"aws_session_token": aws_session_token or self.aws_session_token,
"workspace_id": workspace_id or self.workspace_id,
"skip_auth": skip_auth if skip_auth is not None else self._skip_auth,
**_extra_kwargs,
},
)
with_options = copy
class AsyncAnthropicAWS(AsyncAnthropic):
aws_access_key: str | None
aws_secret_key: str | None
aws_region: str | None
aws_profile: str | None
aws_session_token: str | None
workspace_id: str | None
_use_sigv4: bool
_skip_auth: bool
def __init__(
self,
*,
api_key: str | None = None,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
aws_session_token: str | None = None,
workspace_id: str | None = None,
skip_auth: bool = False,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.AsyncClient | None = None,
_strict_response_validation: bool = False,
# Accepted for compatibility with AsyncAnthropic.copy() but not used
auth_token: str | None = None,
) -> None:
self._skip_auth = skip_auth
validate_credentials(aws_access_key=aws_access_key, aws_secret_key=aws_secret_key)
if skip_auth:
self._use_sigv4 = False
resolved_api_key = None
else:
self._use_sigv4 = resolve_auth_mode(
api_key=api_key,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
aws_profile=aws_profile,
)
resolved_api_key = resolve_api_key(api_key=api_key, use_sigv4=self._use_sigv4)
resolved_region = resolve_region(aws_region)
if self._use_sigv4 and resolved_region is None:
raise AnthropicError(
"No AWS region was provided. Set the `aws_region` argument or the `AWS_REGION`/`AWS_DEFAULT_REGION` environment variable."
)
self.aws_access_key = aws_access_key
self.aws_secret_key = aws_secret_key
self.aws_region = resolved_region
self.aws_profile = aws_profile
self.aws_session_token = aws_session_token
if skip_auth:
self.workspace_id = workspace_id
else:
resolved_workspace_id = resolve_workspace_id(workspace_id)
if resolved_workspace_id is None:
raise AnthropicError(
"No workspace ID found. Set the `workspace_id` argument or the `ANTHROPIC_AWS_WORKSPACE_ID` environment variable."
)
self.workspace_id = resolved_workspace_id
if not skip_auth:
resolved_base_url = resolve_base_url(
str(base_url) if base_url is not None else None,
region=resolved_region,
)
if resolved_base_url is None:
raise AnthropicError(
"No AWS region was provided and no base_url was given. "
"Set the `aws_region` argument, the `AWS_REGION`/`AWS_DEFAULT_REGION` environment variable, "
"or provide a `base_url` directly."
)
base_url = resolved_base_url
super().__init__(
api_key=resolved_api_key,
auth_token=auth_token,
base_url=base_url, # type: ignore[arg-type]
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
@property
@override
def default_headers(self) -> dict[str, str | Omit]:
headers = {**super().default_headers}
if self.workspace_id is not None:
headers["anthropic-workspace-id"] = self.workspace_id
return headers
@property
@override
def _api_key_auth(self) -> dict[str, str]:
if self._use_sigv4 or self._skip_auth:
return {}
return super()._api_key_auth
@override
def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None:
if self._use_sigv4 or self._skip_auth:
return
super()._validate_headers(headers, custom_headers)
@override
async def _prepare_request(self, request: httpx.Request) -> None:
if not self._use_sigv4:
return
from ._auth import get_auth_headers
data = request.read().decode()
headers = get_auth_headers(
method=request.method,
url=str(request.url),
headers=request.headers,
aws_access_key=self.aws_access_key,
aws_secret_key=self.aws_secret_key,
aws_session_token=self.aws_session_token,
region=self.aws_region,
profile=self.aws_profile,
data=data,
service_name="aws-external-anthropic",
)
request.headers.update(headers)
@override
def copy(
self,
*,
api_key: str | None = None,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
aws_session_token: str | None = None,
workspace_id: str | None = None,
skip_auth: bool | None = None,
auth_token: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.AsyncClient | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
# If region is changing and no explicit base_url, let __init__ derive it
resolved_base_url = base_url or (None if aws_region else self.base_url)
return super().copy(
api_key=api_key or self.api_key,
auth_token=auth_token,
base_url=resolved_base_url,
timeout=timeout,
http_client=http_client,
max_retries=max_retries,
default_headers=default_headers,
set_default_headers=set_default_headers,
default_query=default_query,
set_default_query=set_default_query,
_extra_kwargs={
"aws_access_key": aws_access_key or self.aws_access_key,
"aws_secret_key": aws_secret_key or self.aws_secret_key,
"aws_region": aws_region or self.aws_region,
"aws_profile": aws_profile or self.aws_profile,
"aws_session_token": aws_session_token or self.aws_session_token,
"workspace_id": workspace_id or self.workspace_id,
"skip_auth": skip_auth if skip_auth is not None else self._skip_auth,
**_extra_kwargs,
},
)
with_options = copy

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import os
from typing import Sequence
def validate_credentials(
*,
aws_access_key: str | None,
aws_secret_key: str | None,
) -> None:
"""Raise if only one of aws_access_key/aws_secret_key is provided."""
if (aws_access_key is not None) != (aws_secret_key is not None):
provided = "aws_access_key" if aws_access_key is not None else "aws_secret_key"
missing = "aws_secret_key" if aws_access_key is not None else "aws_access_key"
raise ValueError(
f"`{provided}` was provided without `{missing}`. "
f"Both must be provided together, or neither (to use the default credential chain)."
)
def _read_env(*env_vars: str) -> str | None:
"""Return the first non-None value from the given env vars, or None."""
for var in env_vars:
value = os.environ.get(var)
if value is not None:
return value
return None
def resolve_auth_mode(
*,
api_key: str | None,
aws_access_key: str | None,
aws_secret_key: str | None,
aws_profile: str | None,
api_key_env_vars: Sequence[str] = ("ANTHROPIC_AWS_API_KEY",),
) -> bool:
"""Determine whether to use SigV4 auth. Returns True for SigV4, False for API key.
Auth precedence:
1. api_key constructor arg → API key mode
2. aws_access_key + aws_secret_key constructor args → SigV4
3. aws_profile constructor arg → SigV4
4. API key env var(s) → API key mode (checked in order; first match wins)
5. Default AWS credential chain → SigV4
"""
if api_key is not None:
return False
if aws_access_key is not None or aws_secret_key is not None:
return True
if aws_profile is not None:
return True
# No explicit constructor args that signal SigV4 — check env vars
if _read_env(*api_key_env_vars) is not None:
return False
# Fall back to default AWS credential chain
return True
def resolve_api_key(
*,
api_key: str | None,
use_sigv4: bool,
api_key_env_vars: Sequence[str] = ("ANTHROPIC_AWS_API_KEY",),
) -> str | None:
"""Resolve the API key. Returns None if using SigV4."""
if api_key is not None:
return api_key
if not use_sigv4:
# Must be from env var
return _read_env(*api_key_env_vars)
return None
def resolve_region(aws_region: str | None) -> str | None:
"""Resolve the AWS region from constructor arg or env var.
Does not silently default — returns None if no region is available.
"""
if aws_region is not None:
return aws_region
return os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
def resolve_workspace_id(
workspace_id: str | None,
*,
workspace_id_env_vars: Sequence[str] = ("ANTHROPIC_AWS_WORKSPACE_ID",),
) -> str | None:
"""Resolve the workspace ID from constructor arg or env var(s).
Returns None if no workspace ID is available (caller should raise).
"""
if workspace_id is not None:
return workspace_id
return _read_env(*workspace_id_env_vars)
def resolve_base_url(
base_url: str | None,
*,
region: str | None,
base_url_env_vars: Sequence[str] = ("ANTHROPIC_AWS_BASE_URL",),
url_template: str = "https://aws-external-anthropic.{region}.api.aws",
) -> str | None:
"""Resolve the base URL from constructor arg, env var, or region.
Returns None if no base URL is resolvable (caller should raise).
"""
if base_url is not None:
return base_url
env_url = _read_env(*base_url_env_vars)
if env_url is not None:
return env_url
if region is not None:
return url_template.format(region=region)
return None

View File

@@ -0,0 +1,5 @@
from ._client import AnthropicBedrock as AnthropicBedrock, AsyncAnthropicBedrock as AsyncAnthropicBedrock
from ._mantle import (
AnthropicBedrockMantle as AnthropicBedrockMantle,
AsyncAnthropicBedrockMantle as AsyncAnthropicBedrockMantle,
)

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from ..._utils import lru_cache
if TYPE_CHECKING:
import boto3
@lru_cache(maxsize=512)
def _get_session(
*,
aws_access_key: str | None,
aws_secret_key: str | None,
aws_session_token: str | None,
region: str | None,
profile: str | None,
) -> boto3.Session:
import boto3
return boto3.Session(
profile_name=profile,
region_name=region,
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key,
aws_session_token=aws_session_token,
)
def get_auth_headers(
*,
method: str,
url: str,
headers: httpx.Headers,
aws_access_key: str | None,
aws_secret_key: str | None,
aws_session_token: str | None,
region: str | None,
profile: str | None,
data: str | None,
) -> dict[str, str]:
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
session = _get_session(
profile=profile,
region=region,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
aws_session_token=aws_session_token,
)
# The connection header may be stripped by a proxy somewhere, so the receiver
# of this message may not see this header, so we remove it from the set of headers
# that are signed.
headers = headers.copy()
del headers["connection"]
request = AWSRequest(method=method.upper(), url=url, headers=headers, data=data)
credentials = session.get_credentials()
if not credentials:
raise RuntimeError("could not resolve credentials from session")
signer = SigV4Auth(credentials, "bedrock", session.region_name)
signer.add_auth(request)
prepped = request.prepare()
return {key: value for key, value in dict(prepped.headers).items() if value is not None}

View File

@@ -0,0 +1,102 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
from __future__ import annotations
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ._beta_messages import (
Messages,
AsyncMessages,
MessagesWithRawResponse,
AsyncMessagesWithRawResponse,
MessagesWithStreamingResponse,
AsyncMessagesWithStreamingResponse,
)
__all__ = ["Beta", "AsyncBeta"]
class Beta(SyncAPIResource):
@cached_property
def messages(self) -> Messages:
return Messages(self._client)
@cached_property
def with_raw_response(self) -> BetaWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return BetaWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> BetaWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return BetaWithStreamingResponse(self)
class AsyncBeta(AsyncAPIResource):
@cached_property
def messages(self) -> AsyncMessages:
return AsyncMessages(self._client)
@cached_property
def with_raw_response(self) -> AsyncBetaWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return AsyncBetaWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> AsyncBetaWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return AsyncBetaWithStreamingResponse(self)
class BetaWithRawResponse:
def __init__(self, beta: Beta) -> None:
self._beta = beta
@cached_property
def messages(self) -> MessagesWithRawResponse:
return MessagesWithRawResponse(self._beta.messages)
class AsyncBetaWithRawResponse:
def __init__(self, beta: AsyncBeta) -> None:
self._beta = beta
@cached_property
def messages(self) -> AsyncMessagesWithRawResponse:
return AsyncMessagesWithRawResponse(self._beta.messages)
class BetaWithStreamingResponse:
def __init__(self, beta: Beta) -> None:
self._beta = beta
@cached_property
def messages(self) -> MessagesWithStreamingResponse:
return MessagesWithStreamingResponse(self._beta.messages)
class AsyncBetaWithStreamingResponse:
def __init__(self, beta: AsyncBeta) -> None:
self._beta = beta
@cached_property
def messages(self) -> AsyncMessagesWithStreamingResponse:
return AsyncMessagesWithStreamingResponse(self._beta.messages)

View File

@@ -0,0 +1,93 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
from __future__ import annotations
from ... import _legacy_response
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
from ...resources.beta import Messages as FirstPartyMessagesAPI, AsyncMessages as FirstPartyAsyncMessagesAPI
__all__ = ["Messages", "AsyncMessages"]
class Messages(SyncAPIResource):
create = FirstPartyMessagesAPI.create
@cached_property
def with_raw_response(self) -> MessagesWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return MessagesWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> MessagesWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return MessagesWithStreamingResponse(self)
class AsyncMessages(AsyncAPIResource):
create = FirstPartyAsyncMessagesAPI.create
@cached_property
def with_raw_response(self) -> AsyncMessagesWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return AsyncMessagesWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> AsyncMessagesWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return AsyncMessagesWithStreamingResponse(self)
class MessagesWithRawResponse:
def __init__(self, messages: Messages) -> None:
self._messages = messages
self.create = _legacy_response.to_raw_response_wrapper(
messages.create,
)
class AsyncMessagesWithRawResponse:
def __init__(self, messages: AsyncMessages) -> None:
self._messages = messages
self.create = _legacy_response.async_to_raw_response_wrapper(
messages.create,
)
class MessagesWithStreamingResponse:
def __init__(self, messages: Messages) -> None:
self._messages = messages
self.create = to_streamed_response_wrapper(
messages.create,
)
class AsyncMessagesWithStreamingResponse:
def __init__(self, messages: AsyncMessages) -> None:
self._messages = messages
self.create = async_to_streamed_response_wrapper(
messages.create,
)

View File

@@ -0,0 +1,458 @@
from __future__ import annotations
import os
import logging
import urllib.parse
from typing import Any, Union, Mapping, TypeVar
from typing_extensions import Self, override
import httpx
from ... import _exceptions
from ._beta import Beta, AsyncBeta
from ..._types import NOT_GIVEN, Timeout, NotGiven
from ..._utils import is_dict, is_given
from ..._compat import model_copy
from ..._version import __version__
from ..._streaming import Stream, AsyncStream
from ..._exceptions import AnthropicError, APIStatusError
from ..._base_client import (
DEFAULT_MAX_RETRIES,
BaseClient,
SyncAPIClient,
AsyncAPIClient,
FinalRequestOptions,
)
from ._stream_decoder import AWSEventStreamDecoder
from ...resources.messages import Messages, AsyncMessages
from ...resources.completions import Completions, AsyncCompletions
log: logging.Logger = logging.getLogger(__name__)
DEFAULT_VERSION = "bedrock-2023-05-31"
_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient])
_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]])
def _prepare_options(input_options: FinalRequestOptions) -> FinalRequestOptions:
options = model_copy(input_options, deep=True)
if is_dict(options.json_data):
options.json_data.setdefault("anthropic_version", DEFAULT_VERSION)
if is_given(options.headers):
betas = options.headers.get("anthropic-beta")
if betas:
options.json_data.setdefault("anthropic_beta", betas.split(","))
if options.url in {"/v1/complete", "/v1/messages", "/v1/messages?beta=true"} and options.method == "post":
if not is_dict(options.json_data):
raise RuntimeError("Expected dictionary json_data for post /completions endpoint")
model = options.json_data.pop("model", None)
model = urllib.parse.quote(str(model), safe=":")
stream = options.json_data.pop("stream", False)
if stream:
options.url = f"/model/{model}/invoke-with-response-stream"
else:
options.url = f"/model/{model}/invoke"
if options.url.startswith("/v1/messages/batches"):
raise AnthropicError("The Batch API is not supported in Bedrock yet")
if options.url == "/v1/messages/count_tokens":
raise AnthropicError("Token counting is not supported in Bedrock yet")
return options
def _infer_region() -> str:
"""
Infer the AWS region from the environment variables or
from the boto3 session if available.
"""
aws_region = os.environ.get("AWS_REGION")
if aws_region is None:
try:
import boto3
session = boto3.Session()
if session.region_name:
aws_region = session.region_name
except ImportError:
pass
if aws_region is None:
log.warning("No AWS region specified, defaulting to us-east-1")
aws_region = "us-east-1" # fall back to legacy behavior
return aws_region
class BaseBedrockClient(BaseClient[_HttpxClientT, _DefaultStreamT]):
@override
def _make_status_error(
self,
err_msg: str,
*,
body: object,
response: httpx.Response,
) -> APIStatusError:
if response.status_code == 400:
return _exceptions.BadRequestError(err_msg, response=response, body=body)
if response.status_code == 401:
return _exceptions.AuthenticationError(err_msg, response=response, body=body)
if response.status_code == 403:
return _exceptions.PermissionDeniedError(err_msg, response=response, body=body)
if response.status_code == 404:
return _exceptions.NotFoundError(err_msg, response=response, body=body)
if response.status_code == 409:
return _exceptions.ConflictError(err_msg, response=response, body=body)
if response.status_code == 422:
return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body)
if response.status_code == 429:
return _exceptions.RateLimitError(err_msg, response=response, body=body)
if response.status_code == 503:
return _exceptions.ServiceUnavailableError(err_msg, response=response, body=body)
if response.status_code >= 500:
return _exceptions.InternalServerError(err_msg, response=response, body=body)
return APIStatusError(err_msg, response=response, body=body)
class AnthropicBedrock(BaseBedrockClient[httpx.Client, Stream[Any]], SyncAPIClient):
messages: Messages
completions: Completions
beta: Beta
def __init__(
self,
aws_secret_key: str | None = None,
aws_access_key: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
aws_session_token: str | None = None,
api_key: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
# Configure a custom httpx client. See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
http_client: httpx.Client | None = None,
# Enable or disable schema validation for data returned by the API.
# When enabled an error APIResponseValidationError is raised
# if the API responds with invalid data for the expected schema.
#
# This parameter may be removed or changed in the future.
# If you rely on this feature, please open a GitHub issue
# outlining your use-case to help us decide if it should be
# part of our public interface in the future.
_strict_response_validation: bool = False,
) -> None:
if api_key is None:
api_key = os.environ.get("AWS_BEARER_TOKEN_BEDROCK")
has_aws_credentials = (
aws_access_key is not None
or aws_secret_key is not None
or aws_session_token is not None
or aws_profile is not None
)
if api_key is not None and has_aws_credentials:
raise ValueError(
"Cannot specify both `api_key` and AWS credentials (`aws_access_key`, `aws_secret_key`, `aws_session_token`, `aws_profile`)"
)
self.api_key: str | None = api_key
self.aws_secret_key = aws_secret_key
self.aws_access_key = aws_access_key
self.aws_region = _infer_region() if aws_region is None else aws_region
self.aws_profile = aws_profile
self.aws_session_token = aws_session_token
if base_url is None:
base_url = os.environ.get("ANTHROPIC_BEDROCK_BASE_URL")
if base_url is None:
base_url = f"https://bedrock-runtime.{self.aws_region}.amazonaws.com"
super().__init__(
version=__version__,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
custom_headers=default_headers,
custom_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
self.beta = Beta(self)
self.messages = Messages(self)
self.completions = Completions(self)
@override
def _make_sse_decoder(self) -> AWSEventStreamDecoder:
return AWSEventStreamDecoder()
@override
def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
return _prepare_options(options)
@override
def _prepare_request(self, request: httpx.Request) -> None:
if self.api_key is not None:
request.headers["Authorization"] = f"Bearer {self.api_key}"
return
from ._auth import get_auth_headers
data = request.read().decode()
headers = get_auth_headers(
method=request.method,
url=str(request.url),
headers=request.headers,
aws_access_key=self.aws_access_key,
aws_secret_key=self.aws_secret_key,
aws_session_token=self.aws_session_token,
region=self.aws_region or "us-east-1",
profile=self.aws_profile,
data=data,
)
request.headers.update(headers)
def copy(
self,
*,
aws_secret_key: str | None = None,
aws_access_key: str | None = None,
aws_region: str | None = None,
aws_session_token: str | None = None,
api_key: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.Client | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
"""
Create a new client instance re-using the same options given to the current client with optional overriding.
"""
if default_headers is not None and set_default_headers is not None:
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
if default_query is not None and set_default_query is not None:
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
headers = self._custom_headers
if default_headers is not None:
headers = {**headers, **default_headers}
elif set_default_headers is not None:
headers = set_default_headers
params = self._custom_query
if default_query is not None:
params = {**params, **default_query}
elif set_default_query is not None:
params = set_default_query
return self.__class__(
aws_secret_key=aws_secret_key or self.aws_secret_key,
aws_access_key=aws_access_key or self.aws_access_key,
aws_region=aws_region or self.aws_region,
aws_session_token=aws_session_token or self.aws_session_token,
api_key=api_key or self.api_key,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
**_extra_kwargs,
)
# Alias for `copy` for nicer inline usage, e.g.
# client.with_options(timeout=10).foo.create(...)
with_options = copy
class AsyncAnthropicBedrock(BaseBedrockClient[httpx.AsyncClient, AsyncStream[Any]], AsyncAPIClient):
messages: AsyncMessages
completions: AsyncCompletions
beta: AsyncBeta
def __init__(
self,
aws_secret_key: str | None = None,
aws_access_key: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
aws_session_token: str | None = None,
api_key: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
# Configure a custom httpx client. See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
http_client: httpx.AsyncClient | None = None,
# Enable or disable schema validation for data returned by the API.
# When enabled an error APIResponseValidationError is raised
# if the API responds with invalid data for the expected schema.
#
# This parameter may be removed or changed in the future.
# If you rely on this feature, please open a GitHub issue
# outlining your use-case to help us decide if it should be
# part of our public interface in the future.
_strict_response_validation: bool = False,
) -> None:
if api_key is None:
api_key = os.environ.get("AWS_BEARER_TOKEN_BEDROCK")
has_aws_credentials = (
aws_access_key is not None
or aws_secret_key is not None
or aws_session_token is not None
or aws_profile is not None
)
if api_key is not None and has_aws_credentials:
raise ValueError(
"Cannot specify both `api_key` and AWS credentials (`aws_access_key`, `aws_secret_key`, `aws_session_token`, `aws_profile`)"
)
self.api_key: str | None = api_key
self.aws_secret_key = aws_secret_key
self.aws_access_key = aws_access_key
self.aws_region = _infer_region() if aws_region is None else aws_region
self.aws_profile = aws_profile
self.aws_session_token = aws_session_token
if base_url is None:
base_url = os.environ.get("ANTHROPIC_BEDROCK_BASE_URL")
if base_url is None:
base_url = f"https://bedrock-runtime.{self.aws_region}.amazonaws.com"
super().__init__(
version=__version__,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
custom_headers=default_headers,
custom_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
self.messages = AsyncMessages(self)
self.completions = AsyncCompletions(self)
self.beta = AsyncBeta(self)
@override
def _make_sse_decoder(self) -> AWSEventStreamDecoder:
return AWSEventStreamDecoder()
@override
async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
return _prepare_options(options)
@override
async def _prepare_request(self, request: httpx.Request) -> None:
if self.api_key is not None:
request.headers["Authorization"] = f"Bearer {self.api_key}"
return
from ._auth import get_auth_headers
data = request.read().decode()
headers = get_auth_headers(
method=request.method,
url=str(request.url),
headers=request.headers,
aws_access_key=self.aws_access_key,
aws_secret_key=self.aws_secret_key,
aws_session_token=self.aws_session_token,
region=self.aws_region or "us-east-1",
profile=self.aws_profile,
data=data,
)
request.headers.update(headers)
def copy(
self,
*,
aws_secret_key: str | None = None,
aws_access_key: str | None = None,
aws_region: str | None = None,
aws_session_token: str | None = None,
api_key: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.AsyncClient | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
"""
Create a new client instance re-using the same options given to the current client with optional overriding.
"""
if default_headers is not None and set_default_headers is not None:
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
if default_query is not None and set_default_query is not None:
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
headers = self._custom_headers
if default_headers is not None:
headers = {**headers, **default_headers}
elif set_default_headers is not None:
headers = set_default_headers
params = self._custom_query
if default_query is not None:
params = {**params, **default_query}
elif set_default_query is not None:
params = set_default_query
return self.__class__(
aws_secret_key=aws_secret_key or self.aws_secret_key,
aws_access_key=aws_access_key or self.aws_access_key,
aws_region=aws_region or self.aws_region,
aws_session_token=aws_session_token or self.aws_session_token,
api_key=api_key or self.api_key,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
**_extra_kwargs,
)
# Alias for `copy` for nicer inline usage, e.g.
# client.with_options(timeout=10).foo.create(...)
with_options = copy

View File

@@ -0,0 +1,512 @@
from __future__ import annotations
import os
from typing import Any, Union, Mapping, TypeVar
from typing_extensions import Self, override
import httpx
from ... import _exceptions
from ..._qs import Querystring
from ..._types import NOT_GIVEN, Omit, Timeout, NotGiven
from ..._utils import is_given
from ..._compat import cached_property
from ..._version import __version__
from ..aws._auth import get_auth_headers
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._streaming import Stream, AsyncStream
from ..._exceptions import AnthropicError, APIStatusError
from ..._base_client import (
DEFAULT_MAX_RETRIES,
BaseClient,
SyncAPIClient,
AsyncAPIClient,
)
from ..aws._credentials import (
resolve_region,
resolve_api_key,
resolve_auth_mode,
validate_credentials,
)
from ...resources.messages import Messages, AsyncMessages
from ...resources.beta.messages import Messages as BetaMessages, AsyncMessages as AsyncBetaMessages
DEFAULT_SERVICE_NAME = "bedrock-mantle"
_MANTLE_API_KEY_ENV_VARS = ("AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_AWS_API_KEY")
_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient])
_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]])
# --- Beta resources (messages-only) ---
class MantleBeta(SyncAPIResource):
@cached_property
def messages(self) -> BetaMessages:
return BetaMessages(self._client)
class AsyncMantleBeta(AsyncAPIResource):
@cached_property
def messages(self) -> AsyncBetaMessages:
return AsyncBetaMessages(self._client)
# --- Base ---
class BaseMantleClient(BaseClient[_HttpxClientT, _DefaultStreamT]):
@override
def _make_status_error(
self,
err_msg: str,
*,
body: object,
response: httpx.Response,
) -> APIStatusError:
if response.status_code == 400:
return _exceptions.BadRequestError(err_msg, response=response, body=body)
if response.status_code == 401:
return _exceptions.AuthenticationError(err_msg, response=response, body=body)
if response.status_code == 403:
return _exceptions.PermissionDeniedError(err_msg, response=response, body=body)
if response.status_code == 404:
return _exceptions.NotFoundError(err_msg, response=response, body=body)
if response.status_code == 409:
return _exceptions.ConflictError(err_msg, response=response, body=body)
if response.status_code == 413:
return _exceptions.RequestTooLargeError(err_msg, response=response, body=body)
if response.status_code == 422:
return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body)
if response.status_code == 429:
return _exceptions.RateLimitError(err_msg, response=response, body=body)
if response.status_code == 529:
return _exceptions.OverloadedError(err_msg, response=response, body=body)
if response.status_code >= 500:
return _exceptions.InternalServerError(err_msg, response=response, body=body)
return APIStatusError(err_msg, response=response, body=body)
# --- Shared init logic ---
def _resolve_mantle_config(
*,
api_key: str | None,
aws_access_key: str | None,
aws_secret_key: str | None,
aws_region: str | None,
aws_profile: str | None,
skip_auth: bool,
base_url: str | httpx.URL | None,
default_headers: Mapping[str, str] | None,
) -> tuple[str | None, str | httpx.URL, bool, dict[str, str]]:
"""Resolve and validate all Mantle client configuration.
Returns (resolved_api_key, resolved_base_url, use_sigv4, merged_headers).
"""
if skip_auth:
use_sigv4 = False
resolved_api_key = None
else:
validate_credentials(aws_access_key=aws_access_key, aws_secret_key=aws_secret_key)
use_sigv4 = resolve_auth_mode(
api_key=api_key,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
aws_profile=aws_profile,
api_key_env_vars=_MANTLE_API_KEY_ENV_VARS,
)
resolved_api_key = resolve_api_key(
api_key=api_key,
use_sigv4=use_sigv4,
api_key_env_vars=_MANTLE_API_KEY_ENV_VARS,
)
resolved_region = resolve_region(aws_region)
if base_url is None:
base_url = os.environ.get("ANTHROPIC_BEDROCK_MANTLE_BASE_URL")
if base_url is None:
if resolved_region is None:
raise AnthropicError(
"No AWS region or base URL found. Set `aws_region` in the constructor, "
"the `AWS_REGION` / `AWS_DEFAULT_REGION` environment variable, or provide "
"a `base_url` / `ANTHROPIC_BEDROCK_MANTLE_BASE_URL` environment variable."
)
base_url = f"https://bedrock-mantle.{resolved_region}.api.aws/anthropic"
merged_headers: dict[str, str] = {}
if default_headers:
merged_headers.update(default_headers)
return resolved_api_key, base_url, use_sigv4, merged_headers
# --- Sync client ---
class AnthropicBedrockMantle(BaseMantleClient[httpx.Client, Stream[Any]], SyncAPIClient):
messages: Messages
beta: MantleBeta
aws_region: str | None
aws_access_key: str | None
aws_secret_key: str | None
aws_session_token: str | None
aws_profile: str | None
skip_auth: bool
_use_sigv4: bool
def __init__(
self,
*,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_session_token: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
api_key: str | None = None,
skip_auth: bool = False,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.Client | None = None,
_strict_response_validation: bool = False,
) -> None:
resolved_api_key, resolved_base_url, use_sigv4, merged_headers = _resolve_mantle_config(
api_key=api_key,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
aws_region=aws_region,
aws_profile=aws_profile,
skip_auth=skip_auth,
base_url=base_url,
default_headers=default_headers,
)
resolved_region = resolve_region(aws_region)
super().__init__(
version=__version__,
base_url=resolved_base_url,
timeout=timeout,
max_retries=max_retries,
custom_headers=merged_headers,
custom_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
self.api_key = resolved_api_key
self.aws_region = resolved_region
self.aws_access_key = aws_access_key
self.aws_secret_key = aws_secret_key
self.aws_session_token = aws_session_token
self.aws_profile = aws_profile
self.skip_auth = skip_auth
self._use_sigv4 = use_sigv4
self.messages = Messages(self)
self.beta = MantleBeta(self)
@property
@override
def qs(self) -> Querystring:
return Querystring(array_format="comma")
@property
@override
def auth_headers(self) -> dict[str, str]:
if self.skip_auth or self._use_sigv4:
return {}
api_key = self.api_key
if api_key is None:
return {}
return {"Authorization": f"Bearer {api_key}"}
@property
@override
def default_headers(self) -> dict[str, str | Omit]:
return {
**super().default_headers,
"X-Stainless-Async": "false",
"anthropic-version": "2023-06-01",
**self._custom_headers,
}
@override
def _validate_headers(self, headers: Any, custom_headers: Any) -> None:
pass
@override
def _prepare_request(self, request: httpx.Request) -> None:
if self.skip_auth or not self._use_sigv4:
return
data = request.read().decode()
headers = get_auth_headers(
method=request.method,
url=str(request.url),
headers=request.headers,
aws_access_key=self.aws_access_key,
aws_secret_key=self.aws_secret_key,
aws_session_token=self.aws_session_token,
region=self.aws_region,
profile=self.aws_profile,
data=data,
service_name=DEFAULT_SERVICE_NAME,
)
request.headers.update(headers)
def copy(
self,
*,
api_key: str | None = None,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_session_token: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
skip_auth: bool | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.Client | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
if default_headers is not None and set_default_headers is not None:
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
if default_query is not None and set_default_query is not None:
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
headers = self._custom_headers
if default_headers is not None:
headers = {**headers, **default_headers}
elif set_default_headers is not None:
headers = set_default_headers
params = self._custom_query
if default_query is not None:
params = {**params, **default_query}
elif set_default_query is not None:
params = set_default_query
return self.__class__(
api_key=api_key or self.api_key,
aws_access_key=aws_access_key or self.aws_access_key,
aws_secret_key=aws_secret_key or self.aws_secret_key,
aws_session_token=aws_session_token or self.aws_session_token,
aws_region=aws_region or self.aws_region,
aws_profile=aws_profile or self.aws_profile,
skip_auth=skip_auth if skip_auth is not None else self.skip_auth,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
**_extra_kwargs,
)
with_options = copy
# --- Async client ---
class AsyncAnthropicBedrockMantle(BaseMantleClient[httpx.AsyncClient, AsyncStream[Any]], AsyncAPIClient):
messages: AsyncMessages
beta: AsyncMantleBeta
aws_region: str | None
aws_access_key: str | None
aws_secret_key: str | None
aws_session_token: str | None
aws_profile: str | None
skip_auth: bool
_use_sigv4: bool
def __init__(
self,
*,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_session_token: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
api_key: str | None = None,
skip_auth: bool = False,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.AsyncClient | None = None,
_strict_response_validation: bool = False,
) -> None:
resolved_api_key, resolved_base_url, use_sigv4, merged_headers = _resolve_mantle_config(
api_key=api_key,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
aws_region=aws_region,
aws_profile=aws_profile,
skip_auth=skip_auth,
base_url=base_url,
default_headers=default_headers,
)
resolved_region = resolve_region(aws_region)
super().__init__(
version=__version__,
base_url=resolved_base_url,
timeout=timeout,
max_retries=max_retries,
custom_headers=merged_headers,
custom_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
self.api_key = resolved_api_key
self.aws_region = resolved_region
self.aws_access_key = aws_access_key
self.aws_secret_key = aws_secret_key
self.aws_session_token = aws_session_token
self.aws_profile = aws_profile
self.skip_auth = skip_auth
self._use_sigv4 = use_sigv4
self.messages = AsyncMessages(self)
self.beta = AsyncMantleBeta(self)
@property
@override
def qs(self) -> Querystring:
return Querystring(array_format="comma")
@property
@override
def auth_headers(self) -> dict[str, str]:
if self.skip_auth or self._use_sigv4:
return {}
api_key = self.api_key
if api_key is None:
return {}
return {"Authorization": f"Bearer {api_key}"}
@property
@override
def default_headers(self) -> dict[str, str | Omit]:
return {
**super().default_headers,
"X-Stainless-Async": "async:asyncio",
"anthropic-version": "2023-06-01",
**self._custom_headers,
}
@override
def _validate_headers(self, headers: Any, custom_headers: Any) -> None:
pass
@override
async def _prepare_request(self, request: httpx.Request) -> None:
if self.skip_auth or not self._use_sigv4:
return
data = request.read().decode()
headers = get_auth_headers(
method=request.method,
url=str(request.url),
headers=request.headers,
aws_access_key=self.aws_access_key,
aws_secret_key=self.aws_secret_key,
aws_session_token=self.aws_session_token,
region=self.aws_region,
profile=self.aws_profile,
data=data,
service_name=DEFAULT_SERVICE_NAME,
)
request.headers.update(headers)
def copy(
self,
*,
api_key: str | None = None,
aws_access_key: str | None = None,
aws_secret_key: str | None = None,
aws_session_token: str | None = None,
aws_region: str | None = None,
aws_profile: str | None = None,
skip_auth: bool | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.AsyncClient | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
if default_headers is not None and set_default_headers is not None:
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
if default_query is not None and set_default_query is not None:
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
headers = self._custom_headers
if default_headers is not None:
headers = {**headers, **default_headers}
elif set_default_headers is not None:
headers = set_default_headers
params = self._custom_query
if default_query is not None:
params = {**params, **default_query}
elif set_default_query is not None:
params = set_default_query
return self.__class__(
api_key=api_key or self.api_key,
aws_access_key=aws_access_key or self.aws_access_key,
aws_secret_key=aws_secret_key or self.aws_secret_key,
aws_session_token=aws_session_token or self.aws_session_token,
aws_region=aws_region or self.aws_region,
aws_profile=aws_profile or self.aws_profile,
skip_auth=skip_auth if skip_auth is not None else self.skip_auth,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
**_extra_kwargs,
)
with_options = copy

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import TypeVar
import httpx
from ..._client import Anthropic, AsyncAnthropic
from ..._streaming import Stream, AsyncStream
from ._stream_decoder import AWSEventStreamDecoder
_T = TypeVar("_T")
class BedrockStream(Stream[_T]):
def __init__(
self,
*,
cast_to: type[_T],
response: httpx.Response,
client: Anthropic,
) -> None:
super().__init__(cast_to=cast_to, response=response, client=client)
self._decoder = AWSEventStreamDecoder()
class AsyncBedrockStream(AsyncStream[_T]):
def __init__(
self,
*,
cast_to: type[_T],
response: httpx.Response,
client: AsyncAnthropic,
) -> None:
super().__init__(cast_to=cast_to, response=response, client=client)
self._decoder = AWSEventStreamDecoder()

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator, AsyncIterator
from ..._utils import lru_cache
from ..._streaming import ServerSentEvent
if TYPE_CHECKING:
from botocore.model import Shape
from botocore.eventstream import EventStreamMessage
@lru_cache(maxsize=None)
def get_response_stream_shape() -> Shape:
from botocore.model import ServiceModel
from botocore.loaders import Loader
loader = Loader()
bedrock_service_dict = loader.load_service_model("bedrock-runtime", "service-2")
bedrock_service_model = ServiceModel(bedrock_service_dict)
return bedrock_service_model.shape_for("ResponseStream")
class AWSEventStreamDecoder:
def __init__(self) -> None:
from botocore.parsers import EventStreamJSONParser
self.parser = EventStreamJSONParser()
def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]:
"""Given an iterator that yields lines, iterate over it & yield every event encountered"""
from botocore.eventstream import EventStreamBuffer
event_stream_buffer = EventStreamBuffer()
for chunk in iterator:
event_stream_buffer.add_data(chunk)
for event in event_stream_buffer:
message = self._parse_message_from_event(event)
if message:
yield ServerSentEvent(data=message, event="completion")
async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]:
"""Given an async iterator that yields lines, iterate over it & yield every event encountered"""
from botocore.eventstream import EventStreamBuffer
event_stream_buffer = EventStreamBuffer()
async for chunk in iterator:
event_stream_buffer.add_data(chunk)
for event in event_stream_buffer:
message = self._parse_message_from_event(event)
if message:
yield ServerSentEvent(data=message, event="completion")
def _parse_message_from_event(self, event: EventStreamMessage) -> str | None:
response_dict = event.to_response_dict()
parsed_response = self.parser.parse(response_dict, get_response_stream_shape())
if response_dict["status_code"] != 200:
raise ValueError(f"Bad response code, expected 200: {response_dict}")
chunk = parsed_response.get("chunk")
if not chunk:
return None
return chunk.get("bytes").decode() # type: ignore[no-any-return]

View File

@@ -0,0 +1,127 @@
# Anthropic Foundry
To use this library with Foundry, use the `AnthropicFoundry` class instead of the `Anthropic` class.
## Installation
```bash
pip install anthropic
```
## Usage
### Basic Usage with API Key
```python
from anthropic import AnthropicFoundry
client = AnthropicFoundry(
api_key="...", # defaults to ANTHROPIC_FOUNDRY_API_KEY environment variable
resource="my-resource", # your Foundry resource
)
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(message.content[0].text)
```
### Using Azure AD Token Provider
For enhanced security, you can use Azure AD (Microsoft Entra) authentication instead of an API key:
```python
from anthropic import AnthropicFoundry
from azure.identity import DefaultAzureCredential
from azure.identity import get_bearer_token_provider
credential = DefaultAzureCredential()
token_provider = get_bearer_token_provider(
credential,
"https://ai.azure.com/.default"
)
client = AnthropicFoundry(
azure_ad_token_provider=token_provider,
resource="my-resource",
)
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(message.content[0].text)
```
## Examples
### Streaming Messages
```python
from anthropic import AnthropicFoundry
client = AnthropicFoundry(
api_key="...",
resource="my-resource",
)
with client.messages.stream(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": "Write a haiku about programming"}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
```
### Async Usage
```python
from anthropic import AsyncAnthropicFoundry
async def main():
client = AsyncAnthropicFoundry(
api_key="...",
resource="my-resource",
)
message = await client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(message.content[0].text)
import asyncio
asyncio.run(main())
```
### Async Streaming
```python
from anthropic import AsyncAnthropicFoundry
async def main():
client = AsyncAnthropicFoundry(
api_key="...",
resource="my-resource",
)
async with client.messages.stream(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": "Write a haiku about programming"}],
) as stream:
async for text in stream.text_stream:
print(text, end="", flush=True)
import asyncio
asyncio.run(main())
```

View File

@@ -0,0 +1,443 @@
from __future__ import annotations
import os
import inspect
from typing import Any, Union, Mapping, TypeVar, Callable, Awaitable, cast, overload
from functools import cached_property
from typing_extensions import Self, override
import httpx
from .._types import NOT_GIVEN, Omit, Timeout, NotGiven
from .._utils import is_given
from .._client import Anthropic, AsyncAnthropic
from .._compat import model_copy
from .._models import FinalRequestOptions
from .._streaming import Stream, AsyncStream
from .._exceptions import AnthropicError
from .._base_client import DEFAULT_MAX_RETRIES, BaseClient
from ..resources.beta import Beta, AsyncBeta
from ..resources.messages import Messages, AsyncMessages
from ..resources.beta.messages import Messages as BetaMessages, AsyncMessages as AsyncBetaMessages
AzureADTokenProvider = Callable[[], str]
AsyncAzureADTokenProvider = Callable[[], "str | Awaitable[str]"]
_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient])
_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]])
class MutuallyExclusiveAuthError(AnthropicError):
def __init__(self) -> None:
super().__init__(
"The `api_key` and `azure_ad_token_provider` arguments are mutually exclusive; Only one can be passed at a time"
)
class BaseFoundryClient(BaseClient[_HttpxClientT, _DefaultStreamT]): ...
class MessagesFoundry(Messages):
@cached_property
@override
def batches(self) -> None: # type: ignore[override]
"""Batches endpoint is not supported for Anthropic Foundry client."""
return None
class BetaFoundryMessages(BetaMessages):
@cached_property
@override
def batches(self) -> None: # type: ignore[override]
"""Batches endpoint is not supported for Anthropic Foundry client."""
return None
class BetaFoundry(Beta):
@cached_property
@override
def messages(self) -> BetaMessages: # type: ignore[override]
"""Return beta messages resource instance with excluded unsupported endpoints."""
return BetaFoundryMessages(self._client)
class AsyncMessagesFoundry(AsyncMessages):
@cached_property
@override
def batches(self) -> None: # type: ignore[override]
"""Batches endpoint is not supported for Anthropic Foundry client."""
return None
class AsyncBetaFoundryMessages(AsyncBetaMessages):
@cached_property
@override
def batches(self) -> None: # type: ignore[override]
"""Batches endpoint is not supported for Anthropic Foundry client."""
return None
class AsyncBetaFoundry(AsyncBeta):
@cached_property
@override
def messages(self) -> AsyncBetaMessages: # type: ignore[override]
"""Return beta messages resource instance with excluded unsupported endpoints."""
return AsyncBetaFoundryMessages(self._client)
# ==============================================================================
class AnthropicFoundry(BaseFoundryClient[httpx.Client, Stream[Any]], Anthropic):
@overload
def __init__(
self,
*,
resource: str | None = None,
api_key: str | None = None,
azure_ad_token_provider: AzureADTokenProvider | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.Client | None = None,
_strict_response_validation: bool = False,
) -> None: ...
@overload
def __init__(
self,
*,
base_url: str,
api_key: str | None = None,
azure_ad_token_provider: AzureADTokenProvider | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.Client | None = None,
_strict_response_validation: bool = False,
) -> None: ...
def __init__(
self,
*,
resource: str | None = None,
api_key: str | None = None,
azure_ad_token_provider: AzureADTokenProvider | None = None,
base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.Client | None = None,
_strict_response_validation: bool = False,
) -> None:
"""Construct a new synchronous Anthropic Foundry client instance.
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `api_key` from `ANTHROPIC_FOUNDRY_API_KEY`
- `resource` from `ANTHROPIC_FOUNDRY_RESOURCE`
- `base_url` from `ANTHROPIC_FOUNDRY_BASE_URL`
Args:
resource: Your Foundry resource name, e.g. `example-resource` for `https://example-resource.services.ai.azure.com/anthropic/`
azure_ad_token_provider: A function that returns an Azure Active Directory token, will be invoked on every request.
"""
api_key = api_key if api_key is not None else os.environ.get("ANTHROPIC_FOUNDRY_API_KEY")
resource = resource if resource is not None else os.environ.get("ANTHROPIC_FOUNDRY_RESOURCE")
base_url = base_url if base_url is not None else os.environ.get("ANTHROPIC_FOUNDRY_BASE_URL")
if api_key is None and azure_ad_token_provider is None:
raise AnthropicError(
"Missing credentials. Please pass one of `api_key`, `azure_ad_token_provider`, or the `ANTHROPIC_FOUNDRY_API_KEY` environment variable."
)
if base_url is None:
if resource is None:
raise ValueError(
"Must provide one of the `base_url` or `resource` arguments, or the `ANTHROPIC_FOUNDRY_RESOURCE` environment variable"
)
base_url = f"https://{resource}.services.ai.azure.com/anthropic/"
elif resource is not None:
raise ValueError("base_url and resource are mutually exclusive")
super().__init__(
api_key=api_key,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
self._azure_ad_token_provider = azure_ad_token_provider
@cached_property
@override
def models(self) -> None: # type: ignore[override]
"""Models endpoint is not supported for Anthropic Foundry client."""
return None
@cached_property
@override
def messages(self) -> MessagesFoundry: # type: ignore[override]
"""Return messages resource instance with excluded unsupported endpoints."""
return MessagesFoundry(client=self)
@cached_property
@override
def beta(self) -> Beta: # type: ignore[override]
"""Return beta resource instance with excluded unsupported endpoints."""
return BetaFoundry(self)
@override
def copy(
self,
*,
api_key: str | None = None,
azure_ad_token_provider: AzureADTokenProvider | None = None,
auth_token: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.Client | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
"""
Create a new client instance re-using the same options given to the current client with optional overriding.
"""
return super().copy(
api_key=api_key,
auth_token=auth_token,
base_url=base_url,
timeout=timeout,
http_client=http_client,
max_retries=max_retries,
default_headers=default_headers,
set_default_headers=set_default_headers,
default_query=default_query,
set_default_query=set_default_query,
_extra_kwargs={
"azure_ad_token_provider": azure_ad_token_provider or self._azure_ad_token_provider,
**_extra_kwargs,
},
)
with_options = copy
def _get_azure_ad_token(self) -> str | None:
provider = self._azure_ad_token_provider
if provider is not None:
token = provider()
if not token or not isinstance(token, str): # pyright: ignore[reportUnnecessaryIsInstance]
raise ValueError(
f"Expected `azure_ad_token_provider` argument to return a string but it returned {token}",
)
return token
return None
@override
def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
headers: dict[str, str | Omit] = {**options.headers} if is_given(options.headers) else {}
options = model_copy(options)
options.headers = headers
azure_ad_token = self._get_azure_ad_token()
if azure_ad_token is not None:
if headers.get("Authorization") is None:
headers["Authorization"] = f"Bearer {azure_ad_token}"
elif self.api_key is not None:
if headers.get("api-key") is None:
assert self.api_key is not None
headers["api-key"] = self.api_key
else:
# should never be hit
raise ValueError("Unable to handle auth")
return options
class AsyncAnthropicFoundry(BaseFoundryClient[httpx.AsyncClient, AsyncStream[Any]], AsyncAnthropic):
@overload
def __init__(
self,
*,
resource: str | None = None,
api_key: str | None = None,
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.AsyncClient | None = None,
_strict_response_validation: bool = False,
) -> None: ...
@overload
def __init__(
self,
*,
base_url: str,
api_key: str | None = None,
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.AsyncClient | None = None,
_strict_response_validation: bool = False,
) -> None: ...
def __init__(
self,
*,
resource: str | None = None,
api_key: str | None = None,
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
http_client: httpx.AsyncClient | None = None,
_strict_response_validation: bool = False,
) -> None:
"""Construct a new asynchronous Anthropic Foundry client instance.
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `api_key` from `ANTHROPIC_FOUNDRY_API_KEY`
- `resource` from `ANTHROPIC_FOUNDRY_RESOURCE`
- `base_url` from `ANTHROPIC_FOUNDRY_BASE_URL`
Args:
resource: Your Foundry resource name, e.g. `example-resource` for `https://example-resource.services.ai.azure.com/anthropic/`
azure_ad_token_provider: A function that returns an Azure Active Directory token, will be invoked on every request.
"""
api_key = api_key if api_key is not None else os.environ.get("ANTHROPIC_FOUNDRY_API_KEY")
resource = resource if resource is not None else os.environ.get("ANTHROPIC_FOUNDRY_RESOURCE")
base_url = base_url if base_url is not None else os.environ.get("ANTHROPIC_FOUNDRY_BASE_URL")
if api_key is None and azure_ad_token_provider is None:
raise AnthropicError(
"Missing credentials. Please pass one of `api_key`, `azure_ad_token_provider`, or the `ANTHROPIC_FOUNDRY_API_KEY` environment variable."
)
if base_url is None:
if resource is None:
raise ValueError(
"Must provide one of the `base_url` or `resource` arguments, or the `ANTHROPIC_FOUNDRY_RESOURCE` environment variable"
)
base_url = f"https://{resource}.services.ai.azure.com/anthropic/"
elif resource is not None:
raise ValueError("base_url and resource are mutually exclusive")
super().__init__(
api_key=api_key,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
self._azure_ad_token_provider = azure_ad_token_provider
@cached_property
@override
def models(self) -> None: # type: ignore[override]
"""Models endpoint is not supported for Azure Anthropic client."""
return None
@cached_property
@override
def messages(self) -> AsyncMessagesFoundry: # type: ignore[override]
"""Return messages resource instance with excluded unsupported endpoints."""
return AsyncMessagesFoundry(client=self)
@cached_property
@override
def beta(self) -> AsyncBetaFoundry: # type: ignore[override]
"""Return beta resource instance with excluded unsupported endpoints."""
return AsyncBetaFoundry(client=self)
@override
def copy(
self,
*,
api_key: str | None = None,
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
auth_token: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.AsyncClient | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
"""
Create a new client instance re-using the same options given to the current client with optional overriding.
"""
return super().copy(
api_key=api_key,
auth_token=auth_token,
base_url=base_url,
timeout=timeout,
http_client=http_client,
max_retries=max_retries,
default_headers=default_headers,
set_default_headers=set_default_headers,
default_query=default_query,
set_default_query=set_default_query,
_extra_kwargs={
"azure_ad_token_provider": azure_ad_token_provider or self._azure_ad_token_provider,
**_extra_kwargs,
},
)
with_options = copy
async def _get_azure_ad_token(self) -> str | None:
provider = self._azure_ad_token_provider
if provider is not None:
token = provider()
if inspect.isawaitable(token):
token = await token
if not token or not isinstance(cast(Any, token), str):
raise ValueError(
f"Expected `azure_ad_token_provider` argument to return a string but it returned {token}",
)
return str(token)
return None
@override
async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
headers: dict[str, str | Omit] = {**options.headers} if is_given(options.headers) else {}
options = model_copy(options)
options.headers = headers
azure_ad_token = await self._get_azure_ad_token()
if azure_ad_token is not None:
if headers.get("Authorization") is None:
headers["Authorization"] = f"Bearer {azure_ad_token}"
elif self.api_key is not None:
assert self.api_key is not None
if headers.get("api-key") is None:
headers["api-key"] = self.api_key
else:
# should never be hit
raise ValueError("Unable to handle auth")
return options

View File

@@ -0,0 +1,39 @@
from typing_extensions import TypeAlias
from ._types import (
TextEvent as TextEvent,
InputJsonEvent as InputJsonEvent,
MessageStopEvent as MessageStopEvent,
MessageStreamEvent as MessageStreamEvent,
ContentBlockStopEvent as ContentBlockStopEvent,
ParsedMessageStopEvent as ParsedMessageStopEvent,
ParsedMessageStreamEvent as ParsedMessageStreamEvent,
ParsedContentBlockStopEvent as ParsedContentBlockStopEvent,
)
from ._messages import (
MessageStream as MessageStream,
AsyncMessageStream as AsyncMessageStream,
MessageStreamManager as MessageStreamManager,
AsyncMessageStreamManager as AsyncMessageStreamManager,
)
from ._beta_types import (
BetaInputJsonEvent as BetaInputJsonEvent,
ParsedBetaTextEvent as ParsedBetaTextEvent,
ParsedBetaMessageStopEvent as ParsedBetaMessageStopEvent,
ParsedBetaMessageStreamEvent as ParsedBetaMessageStreamEvent,
ParsedBetaContentBlockStopEvent as ParsedBetaContentBlockStopEvent,
)
# For backwards compatibility
BetaTextEvent: TypeAlias = ParsedBetaTextEvent
BetaMessageStopEvent: TypeAlias = ParsedBetaMessageStopEvent[object]
BetaMessageStreamEvent: TypeAlias = ParsedBetaMessageStreamEvent
BetaContentBlockStopEvent: TypeAlias = ParsedBetaContentBlockStopEvent[object]
from ._beta_messages import (
BetaMessageStream as BetaMessageStream,
BetaAsyncMessageStream as BetaAsyncMessageStream,
BetaMessageStreamManager as BetaMessageStreamManager,
BetaAsyncMessageStreamManager as BetaAsyncMessageStreamManager,
)

View File

@@ -0,0 +1,555 @@
from __future__ import annotations
import builtins
from types import TracebackType
from typing import TYPE_CHECKING, Any, Type, Generic, Callable, cast
from typing_extensions import Self, Iterator, Awaitable, AsyncIterator, assert_never
import httpx
from pydantic import BaseModel
from anthropic.types.beta.beta_tool_use_block import BetaToolUseBlock
from anthropic.types.beta.beta_mcp_tool_use_block import BetaMCPToolUseBlock
from anthropic.types.beta.beta_server_tool_use_block import BetaServerToolUseBlock
from ..._types import NOT_GIVEN, NotGiven
from ..._utils import consume_sync_iterator, consume_async_iterator
from ..._models import build, construct_type, construct_type_unchecked
from ._beta_types import (
BetaCitationEvent,
BetaThinkingEvent,
BetaInputJsonEvent,
BetaSignatureEvent,
BetaCompactionEvent,
ParsedBetaTextEvent,
ParsedBetaMessageStopEvent,
ParsedBetaMessageStreamEvent,
ParsedBetaContentBlockStopEvent,
)
from ..._streaming import Stream, AsyncStream
from ...types.beta import BetaRawMessageStreamEvent
from ..._utils._utils import is_given
from .._parse._response import ResponseFormatT, parse_text
from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaContentBlock
class BetaMessageStream(Generic[ResponseFormatT]):
text_stream: Iterator[str]
"""Iterator over just the text deltas in the stream.
```py
for text in stream.text_stream:
print(text, end="", flush=True)
print()
```
"""
def __init__(
self,
raw_stream: Stream[BetaRawMessageStreamEvent],
output_format: ResponseFormatT | NotGiven,
) -> None:
self._raw_stream = raw_stream
self.text_stream = self.__stream_text__()
self._iterator = self.__stream__()
self.__final_message_snapshot: ParsedBetaMessage[ResponseFormatT] | None = None
self.__output_format = output_format
@property
def response(self) -> httpx.Response:
return self._raw_stream.response
@property
def request_id(self) -> str | None:
return self.response.headers.get("request-id") # type: ignore[no-any-return]
def __next__(self) -> ParsedBetaMessageStreamEvent[ResponseFormatT]:
return self._iterator.__next__()
def __iter__(self) -> Iterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]:
for item in self._iterator:
yield item
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
def close(self) -> None:
"""
Close the response and release the connection.
Automatically called if the response body is read to completion.
"""
self._raw_stream.close()
def get_final_message(self) -> ParsedBetaMessage[ResponseFormatT]:
"""Waits until the stream has been read to completion and returns
the accumulated `Message` object.
"""
self.until_done()
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
def get_final_text(self) -> str:
"""Returns all `text` content blocks concatenated together.
> [!NOTE]
> Currently the API will only respond with a single content block.
Will raise an error if no `text` content blocks were returned.
"""
message = self.get_final_message()
text_blocks: list[str] = []
for block in message.content:
if block.type == "text":
text_blocks.append(block.text)
if not text_blocks:
raise RuntimeError(
f".get_final_text() can only be called when the API returns a `text` content block.\nThe API returned {','.join([b.type for b in message.content])} content block type(s) that you can access by calling get_final_message().content"
)
return "".join(text_blocks)
def until_done(self) -> None:
"""Blocks until the stream has been consumed"""
consume_sync_iterator(self)
# properties
@property
def current_message_snapshot(self) -> ParsedBetaMessage[ResponseFormatT]:
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
def __stream__(self) -> Iterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]:
for sse_event in self._raw_stream:
self.__final_message_snapshot = accumulate_event(
event=sse_event,
current_snapshot=self.__final_message_snapshot,
request_headers=self.response.request.headers,
output_format=self.__output_format,
)
events_to_fire = build_events(event=sse_event, message_snapshot=self.current_message_snapshot)
for event in events_to_fire:
yield event
def __stream_text__(self) -> Iterator[str]:
for chunk in self:
if chunk.type == "content_block_delta" and chunk.delta.type == "text_delta":
yield chunk.delta.text
class BetaMessageStreamManager(Generic[ResponseFormatT]):
"""Wrapper over MessageStream that is returned by `.stream()`.
```py
with client.beta.messages.stream(...) as stream:
for chunk in stream:
...
```
"""
def __init__(
self,
api_request: Callable[[], Stream[BetaRawMessageStreamEvent]],
*,
output_format: ResponseFormatT | NotGiven,
) -> None:
self.__stream: BetaMessageStream[ResponseFormatT] | None = None
self.__api_request = api_request
self.__output_format = output_format
def __enter__(self) -> BetaMessageStream[ResponseFormatT]:
raw_stream = self.__api_request()
self.__stream = BetaMessageStream(raw_stream, output_format=self.__output_format)
return self.__stream
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if self.__stream is not None:
self.__stream.close()
class BetaAsyncMessageStream(Generic[ResponseFormatT]):
text_stream: AsyncIterator[str]
"""Async iterator over just the text deltas in the stream.
```py
async for text in stream.text_stream:
print(text, end="", flush=True)
print()
```
"""
def __init__(
self,
raw_stream: AsyncStream[BetaRawMessageStreamEvent],
output_format: ResponseFormatT | NotGiven,
) -> None:
self._raw_stream = raw_stream
self.text_stream = self.__stream_text__()
self._iterator = self.__stream__()
self.__final_message_snapshot: ParsedBetaMessage[ResponseFormatT] | None = None
self.__output_format = output_format
@property
def response(self) -> httpx.Response:
return self._raw_stream.response
@property
def request_id(self) -> str | None:
return self.response.headers.get("request-id") # type: ignore[no-any-return]
async def __anext__(self) -> ParsedBetaMessageStreamEvent[ResponseFormatT]:
return await self._iterator.__anext__()
async def __aiter__(self) -> AsyncIterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]:
async for item in self._iterator:
yield item
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.close()
async def close(self) -> None:
"""
Close the response and release the connection.
Automatically called if the response body is read to completion.
"""
await self._raw_stream.close()
async def get_final_message(self) -> ParsedBetaMessage[ResponseFormatT]:
"""Waits until the stream has been read to completion and returns
the accumulated `Message` object.
"""
await self.until_done()
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
async def get_final_text(self) -> str:
"""Returns all `text` content blocks concatenated together.
> [!NOTE]
> Currently the API will only respond with a single content block.
Will raise an error if no `text` content blocks were returned.
"""
message = await self.get_final_message()
text_blocks: list[str] = []
for block in message.content:
if block.type == "text":
text_blocks.append(block.text)
if not text_blocks:
raise RuntimeError(
f".get_final_text() can only be called when the API returns a `text` content block.\nThe API returned {','.join([b.type for b in message.content])} content block type(s) that you can access by calling get_final_message().content"
)
return "".join(text_blocks)
async def until_done(self) -> None:
"""Waits until the stream has been consumed"""
await consume_async_iterator(self)
# properties
@property
def current_message_snapshot(self) -> ParsedBetaMessage[ResponseFormatT]:
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
async def __stream__(self) -> AsyncIterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]:
async for sse_event in self._raw_stream:
self.__final_message_snapshot = accumulate_event(
event=sse_event,
current_snapshot=self.__final_message_snapshot,
request_headers=self.response.request.headers,
output_format=self.__output_format,
)
events_to_fire = build_events(event=sse_event, message_snapshot=self.current_message_snapshot)
for event in events_to_fire:
yield event
async def __stream_text__(self) -> AsyncIterator[str]:
async for chunk in self:
if chunk.type == "content_block_delta" and chunk.delta.type == "text_delta":
yield chunk.delta.text
class BetaAsyncMessageStreamManager(Generic[ResponseFormatT]):
"""Wrapper over BetaAsyncMessageStream that is returned by `.stream()`
so that an async context manager can be used without `await`ing the
original client call.
```py
async with client.beta.messages.stream(...) as stream:
async for chunk in stream:
...
```
"""
def __init__(
self,
api_request: Awaitable[AsyncStream[BetaRawMessageStreamEvent]],
*,
output_format: ResponseFormatT | NotGiven = NOT_GIVEN,
) -> None:
self.__stream: BetaAsyncMessageStream[ResponseFormatT] | None = None
self.__api_request = api_request
self.__output_format = output_format
async def __aenter__(self) -> BetaAsyncMessageStream[ResponseFormatT]:
raw_stream = await self.__api_request
self.__stream = BetaAsyncMessageStream(raw_stream, output_format=self.__output_format)
return self.__stream
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if self.__stream is not None:
await self.__stream.close()
def build_events(
*,
event: BetaRawMessageStreamEvent,
message_snapshot: ParsedBetaMessage[ResponseFormatT],
) -> list[ParsedBetaMessageStreamEvent[ResponseFormatT]]:
events_to_fire: list[ParsedBetaMessageStreamEvent[ResponseFormatT]] = []
if event.type == "message_start":
events_to_fire.append(event)
elif event.type == "message_delta":
events_to_fire.append(event)
elif event.type == "message_stop":
events_to_fire.append(
build(ParsedBetaMessageStopEvent[ResponseFormatT], type="message_stop", message=message_snapshot)
)
elif event.type == "content_block_start":
events_to_fire.append(event)
elif event.type == "content_block_delta":
events_to_fire.append(event)
content_block = message_snapshot.content[event.index]
if event.delta.type == "text_delta":
if content_block.type == "text":
events_to_fire.append(
build(
ParsedBetaTextEvent,
type="text",
text=event.delta.text,
snapshot=content_block.text,
)
)
elif event.delta.type == "input_json_delta":
if content_block.type == "tool_use" or content_block.type == "mcp_tool_use":
events_to_fire.append(
build(
BetaInputJsonEvent,
type="input_json",
partial_json=event.delta.partial_json,
snapshot=content_block.input,
)
)
elif event.delta.type == "citations_delta":
if content_block.type == "text":
events_to_fire.append(
build(
BetaCitationEvent,
type="citation",
citation=event.delta.citation,
snapshot=content_block.citations or [],
)
)
elif event.delta.type == "thinking_delta":
if content_block.type == "thinking":
events_to_fire.append(
build(
BetaThinkingEvent,
type="thinking",
thinking=event.delta.thinking,
snapshot=content_block.thinking,
)
)
elif event.delta.type == "signature_delta":
if content_block.type == "thinking":
events_to_fire.append(
build(
BetaSignatureEvent,
type="signature",
signature=content_block.signature,
)
)
pass
elif event.delta.type == "compaction_delta":
if content_block.type == "compaction":
events_to_fire.append(
build(
BetaCompactionEvent,
type="compaction",
content=content_block.content,
)
)
else:
# we only want exhaustive checking for linters, not at runtime
if TYPE_CHECKING: # type: ignore[unreachable]
assert_never(event.delta)
elif event.type == "content_block_stop":
content_block = message_snapshot.content[event.index]
event_to_fire = build(
ParsedBetaContentBlockStopEvent,
type="content_block_stop",
index=event.index,
content_block=content_block,
)
events_to_fire.append(event_to_fire)
else:
# we only want exhaustive checking for linters, not at runtime
if TYPE_CHECKING: # type: ignore[unreachable]
assert_never(event)
return events_to_fire
JSON_BUF_PROPERTY = "__json_buf"
TRACKS_TOOL_INPUT = (
BetaToolUseBlock,
BetaServerToolUseBlock,
BetaMCPToolUseBlock,
)
def accumulate_event(
*,
event: BetaRawMessageStreamEvent,
current_snapshot: ParsedBetaMessage[ResponseFormatT] | None,
request_headers: httpx.Headers,
output_format: ResponseFormatT | NotGiven = NOT_GIVEN,
) -> ParsedBetaMessage[ResponseFormatT]:
if not isinstance(cast(Any, event), BaseModel):
event = cast( # pyright: ignore[reportUnnecessaryCast]
BetaRawMessageStreamEvent,
construct_type_unchecked(
type_=cast(Type[BetaRawMessageStreamEvent], BetaRawMessageStreamEvent),
value=event,
),
)
if not isinstance(cast(Any, event), BaseModel):
raise TypeError(
f"Unexpected event runtime type, after deserialising twice - {event} - {builtins.type(event)}"
)
if current_snapshot is None:
if event.type == "message_start":
return cast(
ParsedBetaMessage[ResponseFormatT], ParsedBetaMessage.construct(**cast(Any, event.message.to_dict()))
)
raise RuntimeError(f'Unexpected event order, got {event.type} before "message_start"')
if event.type == "content_block_start":
# TODO: check index
current_snapshot.content.append(
cast(
Any, # Pydantic does not support generic unions at runtime
construct_type(type_=ParsedBetaContentBlock, value=event.content_block.to_dict()),
),
)
elif event.type == "content_block_delta":
content = current_snapshot.content[event.index]
if event.delta.type == "text_delta":
if content.type == "text":
content.text += event.delta.text
elif event.delta.type == "input_json_delta":
if isinstance(content, TRACKS_TOOL_INPUT):
from jiter import from_json
# we need to keep track of the raw JSON string as well so that we can
# re-parse it for each delta, for now we just store it as an untyped
# property on the snapshot
json_buf = cast(bytes, getattr(content, JSON_BUF_PROPERTY, b""))
json_buf += bytes(event.delta.partial_json, "utf-8")
if json_buf:
try:
anthropic_beta = request_headers.get("anthropic-beta", "") if request_headers else ""
if "fine-grained-tool-streaming-2025-05-14" in anthropic_beta:
content.input = from_json(json_buf, partial_mode="trailing-strings")
else:
content.input = from_json(json_buf, partial_mode=True)
except ValueError as e:
raise ValueError(
f"Unable to parse tool parameter JSON from model. Please retry your request or adjust your prompt. Error: {e}. JSON: {json_buf.decode('utf-8')}"
) from e
setattr(content, JSON_BUF_PROPERTY, json_buf)
elif event.delta.type == "citations_delta":
if content.type == "text":
if not content.citations:
content.citations = [event.delta.citation]
else:
content.citations.append(event.delta.citation)
elif event.delta.type == "thinking_delta":
if content.type == "thinking":
content.thinking += event.delta.thinking
elif event.delta.type == "signature_delta":
if content.type == "thinking":
content.signature = event.delta.signature
elif event.delta.type == "compaction_delta":
if content.type == "compaction":
content.content = event.delta.content
else:
# we only want exhaustive checking for linters, not at runtime
if TYPE_CHECKING: # type: ignore[unreachable]
assert_never(event.delta)
elif event.type == "content_block_stop":
content_block = current_snapshot.content[event.index]
if content_block.type == "text" and is_given(output_format):
content_block.parsed_output = parse_text(content_block.text, output_format)
elif event.type == "message_delta":
current_snapshot.container = event.delta.container
current_snapshot.stop_reason = event.delta.stop_reason
current_snapshot.stop_sequence = event.delta.stop_sequence
current_snapshot.usage.output_tokens = event.usage.output_tokens
current_snapshot.context_management = event.context_management
# Update other usage fields if they exist in the event
if event.usage.input_tokens is not None:
current_snapshot.usage.input_tokens = event.usage.input_tokens
if event.usage.cache_creation_input_tokens is not None:
current_snapshot.usage.cache_creation_input_tokens = event.usage.cache_creation_input_tokens
if event.usage.cache_read_input_tokens is not None:
current_snapshot.usage.cache_read_input_tokens = event.usage.cache_read_input_tokens
if event.usage.server_tool_use is not None:
current_snapshot.usage.server_tool_use = event.usage.server_tool_use
if event.usage.iterations is not None:
current_snapshot.usage.iterations = event.usage.iterations
return current_snapshot

View File

@@ -0,0 +1,116 @@
from typing import TYPE_CHECKING, Any, Dict, Union, Generic, cast
from typing_extensions import List, Literal, Annotated
import jiter
from ..._models import BaseModel, GenericModel
from ...types.beta import (
BetaRawMessageStopEvent,
BetaRawMessageDeltaEvent,
BetaRawMessageStartEvent,
BetaRawContentBlockStopEvent,
BetaRawContentBlockDeltaEvent,
BetaRawContentBlockStartEvent,
)
from .._parse._response import ResponseFormatT
from ..._utils._transform import PropertyInfo
from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaContentBlock
from ...types.beta.beta_citations_delta import Citation
class ParsedBetaTextEvent(BaseModel):
type: Literal["text"]
text: str
"""The text delta"""
snapshot: str
"""The entire accumulated text"""
def parsed_snapshot(self) -> Dict[str, Any]:
return cast(Dict[str, Any], jiter.from_json(self.snapshot.encode("utf-8"), partial_mode="trailing-strings"))
class BetaCitationEvent(BaseModel):
type: Literal["citation"]
citation: Citation
"""The new citation"""
snapshot: List[Citation]
"""All of the accumulated citations"""
class BetaThinkingEvent(BaseModel):
type: Literal["thinking"]
thinking: str
"""The thinking delta"""
snapshot: str
"""The accumulated thinking so far"""
class BetaSignatureEvent(BaseModel):
type: Literal["signature"]
signature: str
"""The signature of the thinking block"""
class BetaInputJsonEvent(BaseModel):
type: Literal["input_json"]
partial_json: str
"""A partial JSON string delta
e.g. `'"San Francisco,'`
"""
snapshot: object
"""The currently accumulated parsed object.
e.g. `{'location': 'San Francisco, CA'}`
"""
class BetaCompactionEvent(BaseModel):
type: Literal["compaction"]
content: Union[str, None]
"""The compaction content"""
class ParsedBetaMessageStopEvent(BetaRawMessageStopEvent, GenericModel, Generic[ResponseFormatT]):
type: Literal["message_stop"]
message: ParsedBetaMessage[ResponseFormatT]
class ParsedBetaContentBlockStopEvent(BetaRawContentBlockStopEvent, GenericModel, Generic[ResponseFormatT]):
type: Literal["content_block_stop"]
if TYPE_CHECKING:
content_block: ParsedBetaContentBlock[ResponseFormatT]
else:
content_block: ParsedBetaContentBlock
ParsedBetaMessageStreamEvent = Annotated[
Union[
ParsedBetaTextEvent,
BetaCitationEvent,
BetaThinkingEvent,
BetaSignatureEvent,
BetaInputJsonEvent,
BetaCompactionEvent,
BetaRawMessageStartEvent,
BetaRawMessageDeltaEvent,
ParsedBetaMessageStopEvent[ResponseFormatT],
BetaRawContentBlockStartEvent,
BetaRawContentBlockDeltaEvent,
ParsedBetaContentBlockStopEvent[ResponseFormatT],
],
PropertyInfo(discriminator="type"),
]

View File

@@ -0,0 +1,518 @@
from __future__ import annotations
from types import TracebackType
from typing import TYPE_CHECKING, Any, Type, Generic, Callable, cast
from typing_extensions import Self, Iterator, Awaitable, AsyncIterator, assert_never
import httpx
from pydantic import BaseModel
from anthropic.types.tool_use_block import ToolUseBlock
from anthropic.types.server_tool_use_block import ServerToolUseBlock
from ._types import (
TextEvent,
CitationEvent,
ThinkingEvent,
InputJsonEvent,
SignatureEvent,
ParsedMessageStopEvent,
ParsedMessageStreamEvent,
ParsedContentBlockStopEvent,
)
from ...types import RawMessageStreamEvent
from ..._types import NOT_GIVEN, NotGiven
from ..._utils import consume_sync_iterator, consume_async_iterator
from ..._models import build, construct_type, construct_type_unchecked
from ..._streaming import Stream, AsyncStream
from ..._utils._utils import is_given
from .._parse._response import ResponseFormatT, parse_text
from ...types.parsed_message import ParsedMessage, ParsedContentBlock
class MessageStream(Generic[ResponseFormatT]):
text_stream: Iterator[str]
"""Iterator over just the text deltas in the stream.
```py
for text in stream.text_stream:
print(text, end="", flush=True)
print()
```
"""
def __init__(
self,
raw_stream: Stream[RawMessageStreamEvent],
output_format: ResponseFormatT | NotGiven,
) -> None:
self._raw_stream = raw_stream
self.text_stream = self.__stream_text__()
self._iterator = self.__stream__()
self.__final_message_snapshot: ParsedMessage[ResponseFormatT] | None = None
self.__output_format = output_format
@property
def response(self) -> httpx.Response:
return self._raw_stream.response
@property
def request_id(self) -> str | None:
return self.response.headers.get("request-id") # type: ignore[no-any-return]
def __next__(self) -> ParsedMessageStreamEvent[ResponseFormatT]:
return self._iterator.__next__()
def __iter__(self) -> Iterator[ParsedMessageStreamEvent[ResponseFormatT]]:
for item in self._iterator:
yield item
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
def close(self) -> None:
"""
Close the response and release the connection.
Automatically called if the response body is read to completion.
"""
self._raw_stream.close()
def get_final_message(self) -> ParsedMessage[ResponseFormatT]:
"""Waits until the stream has been read to completion and returns
the accumulated `Message` object.
"""
self.until_done()
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
def get_final_text(self) -> str:
"""Returns all `text` content blocks concatenated together.
> [!NOTE]
> Currently the API will only respond with a single content block.
Will raise an error if no `text` content blocks were returned.
"""
message = self.get_final_message()
text_blocks: list[str] = []
for block in message.content:
if block.type == "text":
text_blocks.append(block.text)
if not text_blocks:
raise RuntimeError(
f".get_final_text() can only be called when the API returns a `text` content block.\nThe API returned {','.join([b.type for b in message.content])} content block type(s) that you can access by calling get_final_message().content"
)
return "".join(text_blocks)
def until_done(self) -> None:
"""Blocks until the stream has been consumed"""
consume_sync_iterator(self)
# properties
@property
def current_message_snapshot(self) -> ParsedMessage[ResponseFormatT]:
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
def __stream__(self) -> Iterator[ParsedMessageStreamEvent[ResponseFormatT]]:
for sse_event in self._raw_stream:
self.__final_message_snapshot = accumulate_event(
event=sse_event,
current_snapshot=self.__final_message_snapshot,
output_format=self.__output_format,
)
events_to_fire = build_events(event=sse_event, message_snapshot=self.current_message_snapshot)
for event in events_to_fire:
yield event
def __stream_text__(self) -> Iterator[str]:
for chunk in self:
if chunk.type == "content_block_delta" and chunk.delta.type == "text_delta":
yield chunk.delta.text
class MessageStreamManager(Generic[ResponseFormatT]):
"""Wrapper over MessageStream that is returned by `.stream()`.
```py
with client.messages.stream(...) as stream:
for chunk in stream:
...
```
"""
def __init__(
self,
api_request: Callable[[], Stream[RawMessageStreamEvent]],
*,
output_format: ResponseFormatT | NotGiven,
) -> None:
self.__stream: MessageStream[ResponseFormatT] | None = None
self.__api_request = api_request
self.__output_format = output_format
def __enter__(self) -> MessageStream[ResponseFormatT]:
raw_stream = self.__api_request()
self.__stream = MessageStream(raw_stream, output_format=self.__output_format)
return self.__stream
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if self.__stream is not None:
self.__stream.close()
class AsyncMessageStream(Generic[ResponseFormatT]):
text_stream: AsyncIterator[str]
"""Async iterator over just the text deltas in the stream.
```py
async for text in stream.text_stream:
print(text, end="", flush=True)
print()
```
"""
def __init__(
self,
raw_stream: AsyncStream[RawMessageStreamEvent],
output_format: ResponseFormatT | NotGiven,
) -> None:
self._raw_stream = raw_stream
self.text_stream = self.__stream_text__()
self._iterator = self.__stream__()
self.__final_message_snapshot: ParsedMessage[ResponseFormatT] | None = None
self.__output_format = output_format
@property
def response(self) -> httpx.Response:
return self._raw_stream.response
@property
def request_id(self) -> str | None:
return self.response.headers.get("request-id") # type: ignore[no-any-return]
async def __anext__(self) -> ParsedMessageStreamEvent[ResponseFormatT]:
return await self._iterator.__anext__()
async def __aiter__(self) -> AsyncIterator[ParsedMessageStreamEvent[ResponseFormatT]]:
async for item in self._iterator:
yield item
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.close()
async def close(self) -> None:
"""
Close the response and release the connection.
Automatically called if the response body is read to completion.
"""
await self._raw_stream.close()
async def get_final_message(self) -> ParsedMessage[ResponseFormatT]:
"""Waits until the stream has been read to completion and returns
the accumulated `Message` object.
"""
await self.until_done()
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
async def get_final_text(self) -> str:
"""Returns all `text` content blocks concatenated together.
> [!NOTE]
> Currently the API will only respond with a single content block.
Will raise an error if no `text` content blocks were returned.
"""
message = await self.get_final_message()
text_blocks: list[str] = []
for block in message.content:
if block.type == "text":
text_blocks.append(block.text)
if not text_blocks:
raise RuntimeError(
f".get_final_text() can only be called when the API returns a `text` content block.\nThe API returned {','.join([b.type for b in message.content])} content block type(s) that you can access by calling get_final_message().content"
)
return "".join(text_blocks)
async def until_done(self) -> None:
"""Waits until the stream has been consumed"""
await consume_async_iterator(self)
# properties
@property
def current_message_snapshot(self) -> ParsedMessage[ResponseFormatT]:
assert self.__final_message_snapshot is not None
return self.__final_message_snapshot
async def __stream__(self) -> AsyncIterator[ParsedMessageStreamEvent[ResponseFormatT]]:
async for sse_event in self._raw_stream:
self.__final_message_snapshot = accumulate_event(
event=sse_event,
current_snapshot=self.__final_message_snapshot,
output_format=self.__output_format,
)
events_to_fire = build_events(event=sse_event, message_snapshot=self.current_message_snapshot)
for event in events_to_fire:
yield event
async def __stream_text__(self) -> AsyncIterator[str]:
async for chunk in self:
if chunk.type == "content_block_delta" and chunk.delta.type == "text_delta":
yield chunk.delta.text
class AsyncMessageStreamManager(Generic[ResponseFormatT]):
"""Wrapper over AsyncMessageStream that is returned by `.stream()`
so that an async context manager can be used without `await`ing the
original client call.
```py
async with client.messages.stream(...) as stream:
async for chunk in stream:
...
```
"""
def __init__(
self,
api_request: Awaitable[AsyncStream[RawMessageStreamEvent]],
*,
output_format: ResponseFormatT | NotGiven = NOT_GIVEN,
) -> None:
self.__stream: AsyncMessageStream[ResponseFormatT] | None = None
self.__api_request = api_request
self.__output_format = output_format
async def __aenter__(self) -> AsyncMessageStream[ResponseFormatT]:
raw_stream = await self.__api_request
self.__stream = AsyncMessageStream(raw_stream, output_format=self.__output_format)
return self.__stream
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if self.__stream is not None:
await self.__stream.close()
def build_events(
*,
event: RawMessageStreamEvent,
message_snapshot: ParsedMessage[ResponseFormatT],
) -> list[ParsedMessageStreamEvent[ResponseFormatT]]:
events_to_fire: list[ParsedMessageStreamEvent[ResponseFormatT]] = []
if event.type == "message_start":
events_to_fire.append(event)
elif event.type == "message_delta":
events_to_fire.append(event)
elif event.type == "message_stop":
events_to_fire.append(
build(ParsedMessageStopEvent[ResponseFormatT], type="message_stop", message=message_snapshot)
)
elif event.type == "content_block_start":
events_to_fire.append(event)
elif event.type == "content_block_delta":
events_to_fire.append(event)
content_block = message_snapshot.content[event.index]
if event.delta.type == "text_delta":
if content_block.type == "text":
events_to_fire.append(
build(
TextEvent,
type="text",
text=event.delta.text,
snapshot=content_block.text,
)
)
elif event.delta.type == "input_json_delta":
if content_block.type == "tool_use":
events_to_fire.append(
build(
InputJsonEvent,
type="input_json",
partial_json=event.delta.partial_json,
snapshot=content_block.input,
)
)
elif event.delta.type == "citations_delta":
if content_block.type == "text":
events_to_fire.append(
build(
CitationEvent,
type="citation",
citation=event.delta.citation,
snapshot=content_block.citations or [],
)
)
elif event.delta.type == "thinking_delta":
if content_block.type == "thinking":
events_to_fire.append(
build(
ThinkingEvent,
type="thinking",
thinking=event.delta.thinking,
snapshot=content_block.thinking,
)
)
elif event.delta.type == "signature_delta":
if content_block.type == "thinking":
events_to_fire.append(
build(
SignatureEvent,
type="signature",
signature=content_block.signature,
)
)
pass
else:
# we only want exhaustive checking for linters, not at runtime
if TYPE_CHECKING: # type: ignore[unreachable]
assert_never(event.delta)
elif event.type == "content_block_stop":
content_block = message_snapshot.content[event.index]
event_to_fire = build(
ParsedContentBlockStopEvent,
type="content_block_stop",
index=event.index,
content_block=content_block,
)
events_to_fire.append(event_to_fire)
else:
# we only want exhaustive checking for linters, not at runtime
if TYPE_CHECKING: # type: ignore[unreachable]
assert_never(event)
return events_to_fire
JSON_BUF_PROPERTY = "__json_buf"
TRACKS_TOOL_INPUT = (
ToolUseBlock,
ServerToolUseBlock,
)
def accumulate_event(
*,
event: RawMessageStreamEvent,
current_snapshot: ParsedMessage[ResponseFormatT] | None,
output_format: ResponseFormatT | NotGiven = NOT_GIVEN,
) -> ParsedMessage[ResponseFormatT]:
if not isinstance(cast(Any, event), BaseModel):
event = cast( # pyright: ignore[reportUnnecessaryCast]
RawMessageStreamEvent,
construct_type_unchecked(
type_=cast(Type[RawMessageStreamEvent], RawMessageStreamEvent),
value=event,
),
)
if not isinstance(cast(Any, event), BaseModel):
raise TypeError(f"Unexpected event runtime type, after deserialising twice - {event} - {type(event)}")
if current_snapshot is None:
if event.type == "message_start":
return cast(ParsedMessage[ResponseFormatT], ParsedMessage.construct(**cast(Any, event.message.to_dict())))
raise RuntimeError(f'Unexpected event order, got {event.type} before "message_start"')
if event.type == "content_block_start":
# TODO: check index
current_snapshot.content.append(
cast(
Any, # Pydantic does not support generic unions at runtime
construct_type(type_=ParsedContentBlock, value=event.content_block.model_dump()),
),
)
elif event.type == "content_block_delta":
content = current_snapshot.content[event.index]
if event.delta.type == "text_delta":
if content.type == "text":
content.text += event.delta.text
elif event.delta.type == "input_json_delta":
if isinstance(content, TRACKS_TOOL_INPUT):
from jiter import from_json
# we need to keep track of the raw JSON string as well so that we can
# re-parse it for each delta, for now we just store it as an untyped
# property on the snapshot
json_buf = cast(bytes, getattr(content, JSON_BUF_PROPERTY, b""))
json_buf += bytes(event.delta.partial_json, "utf-8")
if json_buf:
content.input = from_json(json_buf, partial_mode=True)
setattr(content, JSON_BUF_PROPERTY, json_buf)
elif event.delta.type == "citations_delta":
if content.type == "text":
if not content.citations:
content.citations = [event.delta.citation]
else:
content.citations.append(event.delta.citation)
elif event.delta.type == "thinking_delta":
if content.type == "thinking":
content.thinking += event.delta.thinking
elif event.delta.type == "signature_delta":
if content.type == "thinking":
content.signature = event.delta.signature
else:
# we only want exhaustive checking for linters, not at runtime
if TYPE_CHECKING: # type: ignore[unreachable]
assert_never(event.delta)
elif event.type == "content_block_stop":
content_block = current_snapshot.content[event.index]
if content_block.type == "text" and is_given(output_format):
content_block.parsed_output = parse_text(content_block.text, output_format)
elif event.type == "message_delta":
current_snapshot.stop_reason = event.delta.stop_reason
current_snapshot.stop_sequence = event.delta.stop_sequence
current_snapshot.usage.output_tokens = event.usage.output_tokens
# Update other usage fields if they exist in the event
if event.usage.input_tokens is not None:
current_snapshot.usage.input_tokens = event.usage.input_tokens
if event.usage.cache_creation_input_tokens is not None:
current_snapshot.usage.cache_creation_input_tokens = event.usage.cache_creation_input_tokens
if event.usage.cache_read_input_tokens is not None:
current_snapshot.usage.cache_read_input_tokens = event.usage.cache_read_input_tokens
if event.usage.server_tool_use is not None:
current_snapshot.usage.server_tool_use = event.usage.server_tool_use
return current_snapshot

View File

@@ -0,0 +1,140 @@
from typing import TYPE_CHECKING, Any, Dict, Union, Generic, cast
from typing_extensions import List, Literal, Annotated
import jiter
from ...types import (
Message,
ContentBlock,
MessageDeltaEvent as RawMessageDeltaEvent,
MessageStartEvent as RawMessageStartEvent,
RawMessageStopEvent,
ContentBlockDeltaEvent as RawContentBlockDeltaEvent,
ContentBlockStartEvent as RawContentBlockStartEvent,
RawContentBlockStopEvent,
)
from ..._models import BaseModel, GenericModel
from .._parse._response import ResponseFormatT
from ..._utils._transform import PropertyInfo
from ...types.parsed_message import ParsedMessage, ParsedContentBlock
from ...types.citations_delta import Citation
class TextEvent(BaseModel):
type: Literal["text"]
text: str
"""The text delta"""
snapshot: str
"""The entire accumulated text"""
def parsed_snapshot(self) -> Dict[str, Any]:
return cast(Dict[str, Any], jiter.from_json(self.snapshot.encode("utf-8"), partial_mode="trailing-strings"))
class CitationEvent(BaseModel):
type: Literal["citation"]
citation: Citation
"""The new citation"""
snapshot: List[Citation]
"""All of the accumulated citations"""
class ThinkingEvent(BaseModel):
type: Literal["thinking"]
thinking: str
"""The thinking delta"""
snapshot: str
"""The accumulated thinking so far"""
class SignatureEvent(BaseModel):
type: Literal["signature"]
signature: str
"""The signature of the thinking block"""
class InputJsonEvent(BaseModel):
type: Literal["input_json"]
partial_json: str
"""A partial JSON string delta
e.g. `'"San Francisco,'`
"""
snapshot: object
"""The currently accumulated parsed object.
e.g. `{'location': 'San Francisco, CA'}`
"""
class MessageStopEvent(RawMessageStopEvent):
type: Literal["message_stop"]
message: Message
class ContentBlockStopEvent(RawContentBlockStopEvent):
type: Literal["content_block_stop"]
content_block: ContentBlock
MessageStreamEvent = Annotated[
Union[
TextEvent,
CitationEvent,
ThinkingEvent,
SignatureEvent,
InputJsonEvent,
RawMessageStartEvent,
RawMessageDeltaEvent,
MessageStopEvent,
RawContentBlockStartEvent,
RawContentBlockDeltaEvent,
ContentBlockStopEvent,
],
PropertyInfo(discriminator="type"),
]
class ParsedMessageStopEvent(RawMessageStopEvent, GenericModel, Generic[ResponseFormatT]):
type: Literal["message_stop"]
message: ParsedMessage[ResponseFormatT]
class ParsedContentBlockStopEvent(RawContentBlockStopEvent, GenericModel, Generic[ResponseFormatT]):
type: Literal["content_block_stop"]
if TYPE_CHECKING:
content_block: ParsedContentBlock[ResponseFormatT]
else:
content_block: ParsedContentBlock
ParsedMessageStreamEvent = Annotated[
Union[
TextEvent,
CitationEvent,
ThinkingEvent,
SignatureEvent,
InputJsonEvent,
RawMessageStartEvent,
RawMessageDeltaEvent,
ParsedMessageStopEvent[ResponseFormatT],
RawContentBlockStartEvent,
RawContentBlockDeltaEvent,
ParsedContentBlockStopEvent[ResponseFormatT],
],
PropertyInfo(discriminator="type"),
]

View File

@@ -0,0 +1,29 @@
from ._beta_runner import BetaToolRunner, BetaAsyncToolRunner, BetaStreamingToolRunner, BetaAsyncStreamingToolRunner
from ._beta_functions import (
ToolError,
BetaFunctionTool,
BetaAsyncFunctionTool,
BetaBuiltinFunctionTool,
BetaFunctionToolResultType,
BetaAsyncBuiltinFunctionTool,
beta_tool,
beta_async_tool,
)
from ._beta_builtin_memory_tool import BetaAbstractMemoryTool, BetaAsyncAbstractMemoryTool
__all__ = [
"beta_tool",
"beta_async_tool",
"BetaFunctionTool",
"BetaAsyncFunctionTool",
"BetaBuiltinFunctionTool",
"BetaAsyncBuiltinFunctionTool",
"BetaToolRunner",
"BetaAsyncStreamingToolRunner",
"BetaStreamingToolRunner",
"BetaAsyncToolRunner",
"BetaFunctionToolResultType",
"BetaAbstractMemoryTool",
"BetaAsyncAbstractMemoryTool",
"ToolError",
]

View File

@@ -0,0 +1,875 @@
from __future__ import annotations
import os
import uuid
import shutil
from abc import abstractmethod
from typing import TYPE_CHECKING, Any, List, cast
from pathlib import Path
from typing_extensions import override, assert_never
from anyio import Path as AsyncPath
from anyio.to_thread import run_sync
from anthropic.types.beta import (
BetaMemoryTool20250818ViewCommand,
BetaMemoryTool20250818CreateCommand,
BetaMemoryTool20250818DeleteCommand,
BetaMemoryTool20250818InsertCommand,
BetaMemoryTool20250818RenameCommand,
BetaMemoryTool20250818StrReplaceCommand,
)
from ..._models import construct_type_unchecked
from ...types.beta import (
BetaMemoryTool20250818Param,
BetaMemoryTool20250818Command,
BetaCacheControlEphemeralParam,
BetaMemoryTool20250818ViewCommand,
BetaMemoryTool20250818CreateCommand,
BetaMemoryTool20250818DeleteCommand,
BetaMemoryTool20250818InsertCommand,
BetaMemoryTool20250818RenameCommand,
BetaMemoryTool20250818StrReplaceCommand,
)
from ._beta_functions import (
ToolError,
BetaBuiltinFunctionTool,
BetaFunctionToolResultType,
BetaAsyncBuiltinFunctionTool,
)
MAX_LINES = 999999
LINE_NUMBER_WIDTH = len(str(MAX_LINES))
# Owner read/write only. Avoids 0o666 which, in environments with a permissive
# umask (e.g. Docker where umask is often 0o000), would make memory files
# world-readable or even world-writable.
_FILE_CREATE_MODE = 0o600
# The default mkdir mode is 0o777, but we want to be more restrictive for memory
# directories to avoid them being world-accessible in environments with permissive umasks
# (eg Docker)
_DIR_CREATE_MODE = 0o700
class BetaAbstractMemoryTool(BetaBuiltinFunctionTool):
"""Abstract base class for memory tool implementations.
This class provides the interface for implementing a custom memory backend for Claude.
Subclass this to create your own memory storage solution (e.g., database, cloud storage, encrypted files, etc.).
Example usage:
```py
class MyMemoryTool(BetaAbstractMemoryTool):
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
...
return "view result"
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
...
return "created successfully"
# ... implement other abstract methods
client = Anthropic()
memory_tool = MyMemoryTool()
message = client.beta.messages.run_tools(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "Remember that I like coffee"}],
tools=[memory_tool],
).until_done()
```
"""
def __init__(self, *, cache_control: BetaCacheControlEphemeralParam | None = None) -> None:
super().__init__()
self._cache_control = cache_control
@override
def to_dict(self) -> BetaMemoryTool20250818Param:
param: BetaMemoryTool20250818Param = {"type": "memory_20250818", "name": "memory"}
if self._cache_control is not None:
param["cache_control"] = self._cache_control
return param
@override
def call(self, input: object) -> BetaFunctionToolResultType:
command = cast(
BetaMemoryTool20250818Command,
construct_type_unchecked(value=input, type_=cast(Any, BetaMemoryTool20250818Command)),
)
return self.execute(command)
def execute(self, command: BetaMemoryTool20250818Command) -> BetaFunctionToolResultType:
"""Execute a memory command and return the result.
This method dispatches to the appropriate handler method based on the
command type (view, create, str_replace, insert, delete, rename).
You typically don't need to override this method.
"""
if command.command == "view":
return self.view(command)
elif command.command == "create":
return self.create(command)
elif command.command == "str_replace":
return self.str_replace(command)
elif command.command == "insert":
return self.insert(command)
elif command.command == "delete":
return self.delete(command)
elif command.command == "rename":
return self.rename(command)
elif TYPE_CHECKING: # type: ignore[unreachable]
assert_never(command)
else:
raise NotImplementedError(f"Unknown command: {command.command}")
@abstractmethod
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
"""View the contents of a memory path."""
pass
@abstractmethod
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
"""Create a new memory file with the specified content."""
pass
@abstractmethod
def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> BetaFunctionToolResultType:
"""Replace text in a memory file."""
pass
@abstractmethod
def insert(self, command: BetaMemoryTool20250818InsertCommand) -> BetaFunctionToolResultType:
"""Insert text at a specific line number in a memory file."""
pass
@abstractmethod
def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> BetaFunctionToolResultType:
"""Delete a memory file or directory."""
pass
@abstractmethod
def rename(self, command: BetaMemoryTool20250818RenameCommand) -> BetaFunctionToolResultType:
"""Rename or move a memory file or directory."""
pass
def clear_all_memory(self) -> BetaFunctionToolResultType:
"""Clear all memory data."""
raise NotImplementedError("clear_all_memory not implemented")
class BetaAsyncAbstractMemoryTool(BetaAsyncBuiltinFunctionTool):
"""Abstract base class for memory tool implementations.
This class provides the interface for implementing a custom memory backend for Claude.
Subclass this to create your own memory storage solution (e.g., database, cloud storage, encrypted files, etc.).
Example usage:
```py
class MyMemoryTool(BetaAbstractMemoryTool):
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
...
return "view result"
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
...
return "created successfully"
# ... implement other abstract methods
client = Anthropic()
memory_tool = MyMemoryTool()
message = client.beta.messages.run_tools(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "Remember that I like coffee"}],
tools=[memory_tool],
).until_done()
```
"""
def __init__(self, *, cache_control: BetaCacheControlEphemeralParam | None = None) -> None:
super().__init__()
self._cache_control = cache_control
@override
def to_dict(self) -> BetaMemoryTool20250818Param:
param: BetaMemoryTool20250818Param = {"type": "memory_20250818", "name": "memory"}
if self._cache_control is not None:
param["cache_control"] = self._cache_control
return param
@override
async def call(self, input: object) -> BetaFunctionToolResultType:
command = cast(
BetaMemoryTool20250818Command,
construct_type_unchecked(value=input, type_=cast(Any, BetaMemoryTool20250818Command)),
)
return await self.execute(command)
async def execute(self, command: BetaMemoryTool20250818Command) -> BetaFunctionToolResultType:
"""Execute a memory command and return the result.
This method dispatches to the appropriate handler method based on the
command type (view, create, str_replace, insert, delete, rename).
You typically don't need to override this method.
"""
if command.command == "view":
return await self.view(command)
elif command.command == "create":
return await self.create(command)
elif command.command == "str_replace":
return await self.str_replace(command)
elif command.command == "insert":
return await self.insert(command)
elif command.command == "delete":
return await self.delete(command)
elif command.command == "rename":
return await self.rename(command)
elif TYPE_CHECKING: # type: ignore[unreachable]
assert_never(command)
else:
raise NotImplementedError(f"Unknown command: {command.command}")
@abstractmethod
async def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
"""View the contents of a memory path."""
pass
@abstractmethod
async def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
"""Create a new memory file with the specified content."""
pass
@abstractmethod
async def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> BetaFunctionToolResultType:
"""Replace text in a memory file."""
pass
@abstractmethod
async def insert(self, command: BetaMemoryTool20250818InsertCommand) -> BetaFunctionToolResultType:
"""Insert text at a specific line number in a memory file."""
pass
@abstractmethod
async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> BetaFunctionToolResultType:
"""Delete a memory file or directory."""
pass
@abstractmethod
async def rename(self, command: BetaMemoryTool20250818RenameCommand) -> BetaFunctionToolResultType:
"""Rename or move a memory file or directory."""
pass
async def clear_all_memory(self) -> BetaFunctionToolResultType:
"""Clear all memory data."""
raise NotImplementedError("clear_all_memory not implemented")
def _atomic_write_file(target_path: Path, content: str) -> None:
dir_path = target_path.parent
temp_path = dir_path / f".tmp-{os.getpid()}-{uuid.uuid4()}"
data = content.encode("utf-8")
try:
fd = os.open(temp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
offset = 0
while offset < len(data):
written = os.write(fd, data[offset:])
if written == 0:
raise OSError("os.write returned 0")
offset += written
os.fsync(fd)
finally:
os.close(fd)
os.replace(temp_path, target_path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def _validate_no_symlink_escape(target_path: Path, memory_root: Path) -> None:
resolved_root = memory_root.resolve()
current = target_path
while True:
try:
resolved = current.resolve()
if resolved != resolved_root and not str(resolved).startswith(str(resolved_root) + os.sep):
raise ToolError("Path would escape /memories directory via symlink")
return
except (FileNotFoundError, OSError):
parent = current.parent
if parent == current or current == memory_root:
return
current = parent
def _read_file_content(full_path: Path, memory_path: str) -> str:
try:
return full_path.read_text(encoding="utf-8")
except FileNotFoundError as err:
raise ToolError(
f"The file {memory_path} no longer exists (may have been deleted or renamed concurrently)."
) from err
def _format_file_size(bytes_size: int) -> str:
if bytes_size == 0:
return "0B"
k = 1024
sizes = ["B", "K", "M", "G"]
i = int(bytes_size.bit_length() - 1) // 10
i = min(i, len(sizes) - 1)
size = bytes_size / (k**i)
if size == int(size):
return f"{int(size)}{sizes[i]}"
else:
return f"{size:.1f}{sizes[i]}"
class BetaLocalFilesystemMemoryTool(BetaAbstractMemoryTool):
"""File-based memory storage implementation for Claude conversations"""
def __init__(self, base_path: str = "./memory"):
super().__init__()
self.base_path = Path(base_path)
self.memory_root = self.base_path / "memories"
self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
def _validate_path(self, path: str) -> Path:
"""Validate and resolve memory paths"""
if not path.startswith("/memories"):
raise ToolError(f"Path must start with /memories, got: {path}")
relative_path = path[len("/memories") :].lstrip("/")
full_path = self.memory_root / relative_path if relative_path else self.memory_root
resolved_path = full_path.resolve()
resolved_root = self.memory_root.resolve()
if resolved_path != resolved_root and not str(resolved_path).startswith(str(resolved_root) + os.sep):
raise ToolError(f"Path {path} would escape /memories directory")
_validate_no_symlink_escape(resolved_path, self.memory_root)
return resolved_path
@override
def view(self, command: BetaMemoryTool20250818ViewCommand) -> str:
full_path = self._validate_path(command.path)
if not full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if full_path.is_dir():
items: List[tuple[str, str]] = []
def collect_items(dir_path: Path, relative_path: str, depth: int) -> None:
if depth > 2:
return
try:
dir_contents = sorted(dir_path.iterdir(), key=lambda x: x.name)
except Exception:
return
for item in dir_contents:
if item.name.startswith("."):
continue
item_relative_path = f"{relative_path}/{item.name}" if relative_path else item.name
try:
stat = item.stat()
except Exception:
continue
if item.is_dir():
items.append((_format_file_size(stat.st_size), f"{item_relative_path}/"))
if depth < 2:
collect_items(item, item_relative_path, depth + 1)
elif item.is_file():
items.append((_format_file_size(stat.st_size), item_relative_path))
collect_items(full_path, "", 1)
header = f"Here're the files and directories up to 2 levels deep in {command.path}, excluding hidden items:"
dir_stat = full_path.stat()
dir_size = _format_file_size(dir_stat.st_size)
lines = [f"{dir_size}\t{command.path}"]
lines.extend([f"{size}\t{command.path}/{path}" for size, path in items])
return f"{header}\n" + "\n".join(lines)
elif full_path.is_file():
content = _read_file_content(full_path, command.path)
lines = content.split("\n")
if len(lines) > MAX_LINES:
raise ToolError(f"File {command.path} exceeds maximum line limit of 999,999 lines.")
display_lines = lines
start_num = 1
if command.view_range and len(command.view_range) == 2:
start_line = max(1, command.view_range[0]) - 1
end_line = len(lines) if command.view_range[1] == -1 else command.view_range[1]
display_lines = lines[start_line:end_line]
start_num = start_line + 1
numbered_lines = [
f"{str(i + start_num).rjust(LINE_NUMBER_WIDTH)}\t{line}" for i, line in enumerate(display_lines)
]
return f"Here's the content of {command.path} with line numbers:\n" + "\n".join(numbered_lines)
else:
raise ToolError(f"Unsupported file type for {command.path}")
@override
def create(self, command: BetaMemoryTool20250818CreateCommand) -> str:
full_path = self._validate_path(command.path)
full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
fd = os.open(full_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
os.write(fd, command.file_text.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
except FileExistsError as err:
raise ToolError(f"File {command.path} already exists") from err
return f"File created successfully at: {command.path}"
@override
def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> str:
full_path = self._validate_path(command.path)
if not full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = _read_file_content(full_path, command.path)
count = content.count(command.old_str)
if count == 0:
raise ToolError(
f"No replacement was performed, old_str `{command.old_str}` did not appear verbatim in {command.path}."
)
elif count > 1:
matching_lines: List[int] = []
start = 0
while True:
pos = content.find(command.old_str, start)
if pos == -1:
break
matching_lines.append(content[:pos].count("\n") + 1)
start = pos + 1
raise ToolError(
f"No replacement was performed. Multiple occurrences of old_str `{command.old_str}` in lines: {', '.join(map(str, matching_lines))}. Please ensure it is unique"
)
pos = content.find(command.old_str)
changed_line_index = content[:pos].count("\n")
new_content = content.replace(command.old_str, command.new_str)
_atomic_write_file(full_path, new_content)
new_lines = new_content.split("\n")
context_start = max(0, changed_line_index - 2)
context_end = min(len(new_lines), changed_line_index + 3)
snippet = [
f"{str(line_num).rjust(LINE_NUMBER_WIDTH)}\t{new_lines[line_num - 1]}"
for line_num in range(context_start + 1, context_end + 1)
]
return (
f"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n"
+ "\n".join(snippet)
)
@override
def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str:
full_path = self._validate_path(command.path)
if not full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = _read_file_content(full_path, command.path)
lines = content.splitlines()
if command.insert_line < 0 or command.insert_line > len(lines):
raise ToolError(
f"Invalid `insert_line` parameter: {command.insert_line}. "
f"It should be within the range [0, {len(lines)}]."
)
lines.insert(command.insert_line, command.insert_text.rstrip("\n"))
new_content = "\n".join(lines)
if not new_content.endswith("\n"):
new_content += "\n"
_atomic_write_file(full_path, content=new_content)
return f"The file {command.path} has been edited."
@override
def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str:
full_path = self._validate_path(command.path)
if command.path == "/memories":
raise ToolError("Cannot delete the /memories directory itself")
try:
if full_path.is_file():
full_path.unlink()
elif full_path.is_dir():
shutil.rmtree(full_path)
else:
raise ToolError(f"The path {command.path} does not exist")
except FileNotFoundError as err:
raise ToolError(f"The path {command.path} does not exist") from err
return f"Successfully deleted {command.path}"
@override
def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str:
old_full_path = self._validate_path(command.old_path)
new_full_path = self._validate_path(command.new_path)
if new_full_path.exists():
raise ToolError(f"The destination {command.new_path} already exists")
new_full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
old_full_path.rename(new_full_path)
except FileNotFoundError as err:
raise ToolError(f"The path {command.old_path} does not exist") from err
return f"Successfully renamed {command.old_path} to {command.new_path}"
@override
def clear_all_memory(self) -> str:
"""Override the base implementation to provide file system clearing."""
if self.memory_root.exists():
shutil.rmtree(self.memory_root)
self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
return "All memory cleared"
async def _async_atomic_write_file(target_path: AsyncPath, content: str) -> None:
temp_path = target_path.parent / f".tmp-{os.getpid()}-{uuid.uuid4()}"
sync_target_path = Path(str(target_path))
sync_temp_path = Path(str(temp_path))
data = content.encode("utf-8")
try:
def write_replace_and_sync() -> None:
fd = os.open(sync_temp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
offset = 0
while offset < len(data):
written = os.write(fd, data[offset:])
if written == 0:
raise OSError("os.write returned 0")
offset += written
os.fsync(fd)
finally:
os.close(fd)
os.replace(sync_temp_path, sync_target_path)
await run_sync(write_replace_and_sync)
except Exception:
await temp_path.unlink(missing_ok=True)
raise
async def _async_validate_no_symlink_escape(target_path: AsyncPath, memory_root: AsyncPath) -> None:
sync_target = Path(str(target_path))
sync_root = Path(str(memory_root))
await run_sync(_validate_no_symlink_escape, sync_target, sync_root)
async def _async_read_file_content(full_path: AsyncPath, memory_path: str) -> str:
try:
return await full_path.read_text(encoding="utf-8")
except FileNotFoundError as err:
raise ToolError(
f"The file {memory_path} no longer exists (may have been deleted or renamed concurrently)."
) from err
class BetaAsyncLocalFilesystemMemoryTool(BetaAsyncAbstractMemoryTool):
"""Async file-based memory storage implementation for Claude conversations"""
def __init__(self, base_path: str = "./memory"):
super().__init__()
self.base_path = AsyncPath(base_path)
self.memory_root = self.base_path / "memories"
# Note: Directory creation is deferred to async methods since __init__ can't be async
async def _ensure_memory_root(self) -> None:
"""Ensure the memory root directory exists"""
await self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
async def _validate_path(self, path: str) -> AsyncPath:
"""Validate and resolve memory paths"""
if not path.startswith("/memories"):
raise ToolError(f"Path must start with /memories, got: {path}")
relative_path = path[len("/memories") :].lstrip("/")
full_path = self.memory_root / relative_path if relative_path else self.memory_root
sync_memory_root = Path(str(self.memory_root))
sync_full_path = Path(str(full_path))
resolved_path = sync_full_path.resolve()
resolved_root = sync_memory_root.resolve()
if resolved_path != resolved_root and not str(resolved_path).startswith(str(resolved_root) + os.sep):
raise ToolError(f"Path {path} would escape /memories directory")
await _async_validate_no_symlink_escape(full_path, self.memory_root)
return AsyncPath(resolved_path)
@override
async def view(self, command: BetaMemoryTool20250818ViewCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if not await full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if await full_path.is_dir():
items: List[tuple[str, str]] = []
async def collect_items(dir_path: AsyncPath, relative_path: str, depth: int) -> None:
if depth > 2:
return
try:
dir_items = [item async for item in dir_path.iterdir()]
dir_contents = sorted(dir_items, key=lambda x: x.name)
except Exception:
return
for item in dir_contents:
if item.name.startswith("."):
continue
item_relative_path = f"{relative_path}/{item.name}" if relative_path else item.name
try:
sync_item = Path(str(item))
stat = await run_sync(sync_item.stat)
except Exception:
continue
if await item.is_dir():
items.append((_format_file_size(stat.st_size), f"{item_relative_path}/"))
if depth < 2:
await collect_items(item, item_relative_path, depth + 1)
elif await item.is_file():
items.append((_format_file_size(stat.st_size), item_relative_path))
await collect_items(full_path, "", 1)
header = f"Here're the files and directories up to 2 levels deep in {command.path}, excluding hidden items:"
sync_full_path = Path(str(full_path))
dir_stat = await run_sync(sync_full_path.stat)
dir_size = _format_file_size(dir_stat.st_size)
lines = [f"{dir_size}\t{command.path}"]
lines.extend([f"{size}\t{command.path}/{path}" for size, path in items])
return f"{header}\n" + "\n".join(lines)
elif await full_path.is_file():
content = await _async_read_file_content(full_path, command.path)
lines = content.split("\n")
if len(lines) > MAX_LINES:
raise ToolError(f"File {command.path} exceeds maximum line limit of 999,999 lines.")
display_lines = lines
start_num = 1
if command.view_range and len(command.view_range) == 2:
start_line = max(1, command.view_range[0]) - 1
end_line = len(lines) if command.view_range[1] == -1 else command.view_range[1]
display_lines = lines[start_line:end_line]
start_num = start_line + 1
numbered_lines = [
f"{str(i + start_num).rjust(LINE_NUMBER_WIDTH)}\t{line}" for i, line in enumerate(display_lines)
]
return f"Here's the content of {command.path} with line numbers:\n" + "\n".join(numbered_lines)
else:
raise ToolError(f"Unsupported file type for {command.path}")
@override
async def create(self, command: BetaMemoryTool20250818CreateCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
await full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
sync_full_path = Path(str(full_path))
def create_exclusive() -> None:
fd = os.open(sync_full_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
try:
os.write(fd, command.file_text.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
await run_sync(create_exclusive)
except FileExistsError as err:
raise ToolError(f"File {command.path} already exists") from err
return f"File created successfully at: {command.path}"
@override
async def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if not await full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not await full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = await _async_read_file_content(full_path, command.path)
count = content.count(command.old_str)
if count == 0:
raise ToolError(
f"No replacement was performed, old_str `{command.old_str}` did not appear verbatim in {command.path}."
)
elif count > 1:
matching_lines: List[int] = []
start = 0
while True:
pos = content.find(command.old_str, start)
if pos == -1:
break
matching_lines.append(content[:pos].count("\n") + 1)
start = pos + 1
raise ToolError(
f"No replacement was performed. Multiple occurrences of old_str `{command.old_str}` in lines: {', '.join(map(str, matching_lines))}. Please ensure it is unique"
)
pos = content.find(command.old_str)
changed_line_index = content[:pos].count("\n")
new_content = content.replace(command.old_str, command.new_str)
await _async_atomic_write_file(full_path, new_content)
new_lines = new_content.split("\n")
context_start = max(0, changed_line_index - 2)
context_end = min(len(new_lines), changed_line_index + 3)
snippet = [
f"{str(line_num).rjust(LINE_NUMBER_WIDTH)}\t{new_lines[line_num - 1]}"
for line_num in range(context_start + 1, context_end + 1)
]
return (
f"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n"
+ "\n".join(snippet)
)
@override
async def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if not await full_path.exists():
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
if not await full_path.is_file():
raise ToolError(f"The path {command.path} is not a file.")
content = await _async_read_file_content(full_path, command.path)
lines = content.splitlines()
if command.insert_line < 0 or command.insert_line > len(lines):
raise ToolError(
f"Invalid `insert_line` parameter: {command.insert_line}. "
f"It should be within the range [0, {len(lines)}]."
)
lines.insert(command.insert_line, command.insert_text.rstrip("\n"))
new_content = "\n".join(lines)
if not new_content.endswith("\n"):
new_content += "\n"
await _async_atomic_write_file(full_path, content=new_content)
return f"The file {command.path} has been edited."
@override
async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)
if command.path == "/memories":
raise ToolError("Cannot delete the /memories directory itself")
try:
if await full_path.is_file():
await full_path.unlink()
elif await full_path.is_dir():
await run_sync(shutil.rmtree, str(full_path))
else:
raise ToolError(f"The path {command.path} does not exist")
except FileNotFoundError as err:
raise ToolError(f"The path {command.path} does not exist") from err
return f"Successfully deleted {command.path}"
@override
async def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str:
await self._ensure_memory_root()
old_full_path = await self._validate_path(command.old_path)
new_full_path = await self._validate_path(command.new_path)
if await new_full_path.exists():
raise ToolError(f"The destination {command.new_path} already exists")
await new_full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
try:
await old_full_path.rename(new_full_path)
except FileNotFoundError as err:
raise ToolError(f"The path {command.old_path} does not exist") from err
return f"Successfully renamed {command.old_path} to {command.new_path}"
@override
async def clear_all_memory(self) -> str:
"""Override the base implementation to provide file system clearing."""
if await self.memory_root.exists():
await run_sync(shutil.rmtree, str(self.memory_root))
await self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
return "All memory cleared"

View File

@@ -0,0 +1,55 @@
from typing import TypedDict
from typing_extensions import Required
DEFAULT_SUMMARY_PROMPT = """You have been working on the task described above but have not yet completed it. Write a continuation summary that will allow you (or another instance of yourself) to resume work efficiently in a future context window where the conversation history will be replaced with this summary. Your summary should be structured, concise, and actionable. Include:
1. Task Overview
The user's core request and success criteria
Any clarifications or constraints they specified
2. Current State
What has been completed so far
Files created, modified, or analyzed (with paths if relevant)
Key outputs or artifacts produced
3. Important Discoveries
Technical constraints or requirements uncovered
Decisions made and their rationale
Errors encountered and how they were resolved
What approaches were tried that didn't work (and why)
4. Next Steps
Specific actions needed to complete the task
Any blockers or open questions to resolve
Priority order if multiple steps remain
5. Context to Preserve
User preferences or style requirements
Domain-specific details that aren't obvious
Any promises made to the user
Be concise but complete—err on the side of including information that would prevent duplicate work or repeated mistakes. Write in a way that enables immediate resumption of the task.
Wrap your summary in <summary></summary> tags."""
DEFAULT_THRESHOLD = 100_000
class CompactionControl(TypedDict, total=False):
"""Client-side compaction control configuration.
.. deprecated::
Use server-side compaction instead by passing
``edits=[{"type": "compact_20260112"}]`` in the params passed to ``tool_runner()``.
See https://platform.claude.com/docs/en/build-with-claude/compaction
"""
context_token_threshold: int
"""The context token threshold at which to trigger compaction.
When the cumulative token count (input + output) across all messages exceeds this threshold,
the message history will be automatically summarized and compressed. Defaults to 100,000 tokens.
"""
model: str
"""
The model to use for generating the compaction summary.
If not specified, defaults to the same model used for the tool runner.
"""
summary_prompt: str
"""The prompt used to instruct the model on how to generate the summary."""
enabled: Required[bool]

View File

@@ -0,0 +1,429 @@
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any, Union, Generic, TypeVar, Callable, Iterable, Coroutine, cast, overload
from inspect import iscoroutinefunction
from typing_extensions import Literal, TypeAlias, override
import pydantic
import docstring_parser
from pydantic import BaseModel
from ... import _compat
from ..._utils import is_dict
from ..._compat import cached_property
from ..._models import TypeAdapter
from ...types.beta import BetaToolParam, BetaToolUnionParam, BetaCacheControlEphemeralParam
from ..._utils._utils import CallableT
from ...types.tool_param import InputSchema
from ...types.beta.beta_tool_result_block_param import Content as BetaContent
log = logging.getLogger(__name__)
BetaFunctionToolResultType: TypeAlias = Union[str, Iterable[BetaContent]]
class ToolError(Exception):
"""Error that can be raised from a tool to return structured content with ``is_error: True``.
When the tool runner catches this error, it will use the :attr:`content`
property as the tool result instead of ``repr(exc)``.
Example::
raise ToolError(
[
{"type": "text", "text": "Error details here"},
{"type": "image", "source": {"type": "base64", "data": "...", "media_type": "image/png"}},
]
)
"""
content: BetaFunctionToolResultType
def __init__(self, content: BetaFunctionToolResultType) -> None:
if isinstance(content, str):
message = content
else:
parts: list[str] = []
for block in content:
text = block.get("text")
if text is not None:
parts.append(str(text))
else:
parts.append(f"[{block.get('type', 'unknown')}]")
message = " ".join(parts) if parts else "Tool error"
super().__init__(message)
self.content = content
Function = Callable[..., BetaFunctionToolResultType]
FunctionT = TypeVar("FunctionT", bound=Function)
AsyncFunction = Callable[..., Coroutine[Any, Any, BetaFunctionToolResultType]]
AsyncFunctionT = TypeVar("AsyncFunctionT", bound=AsyncFunction)
class BetaBuiltinFunctionTool(ABC):
@abstractmethod
def to_dict(self) -> BetaToolUnionParam: ...
@abstractmethod
def call(self, input: object) -> BetaFunctionToolResultType: ...
@property
def name(self) -> str:
raw = self.to_dict()
if "mcp_server_name" in raw:
return raw["mcp_server_name"]
return raw["name"]
class BetaAsyncBuiltinFunctionTool(ABC):
@abstractmethod
def to_dict(self) -> BetaToolUnionParam: ...
@abstractmethod
async def call(self, input: object) -> BetaFunctionToolResultType: ...
@property
def name(self) -> str:
raw = self.to_dict()
if "mcp_server_name" in raw:
return raw["mcp_server_name"]
return raw["name"]
class BaseFunctionTool(Generic[CallableT]):
func: CallableT
"""The function this tool is wrapping"""
name: str
"""The name of the tool that will be sent to the API"""
description: str
input_schema: InputSchema
def __init__(
self,
func: CallableT,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> None:
if _compat.PYDANTIC_V1:
raise RuntimeError("Tool functions are only supported with Pydantic v2")
self.func = func
self._func_with_validate = pydantic.validate_call(func)
self.name = name or func.__name__
self._defer_loading = defer_loading
self._cache_control = cache_control
self._allowed_callers = allowed_callers
self._eager_input_streaming = eager_input_streaming
self._input_examples = input_examples
self._strict = strict
self.description = description or self._get_description_from_docstring()
if input_schema is not None:
if isinstance(input_schema, type):
self.input_schema: InputSchema = input_schema.model_json_schema()
else:
self.input_schema = input_schema
else:
self.input_schema = self._create_schema_from_function()
@property
def __call__(self) -> CallableT:
return self.func
def to_dict(self) -> BetaToolParam:
defn: BetaToolParam = {
"name": self.name,
"description": self.description,
"input_schema": self.input_schema,
}
if self._defer_loading is not None:
defn["defer_loading"] = self._defer_loading
if self._cache_control is not None:
defn["cache_control"] = self._cache_control
if self._allowed_callers is not None:
defn["allowed_callers"] = self._allowed_callers
if self._eager_input_streaming is not None:
defn["eager_input_streaming"] = self._eager_input_streaming
if self._input_examples is not None:
defn["input_examples"] = self._input_examples
if self._strict is not None:
defn["strict"] = self._strict
return defn
@cached_property
def _parsed_docstring(self) -> docstring_parser.Docstring:
return docstring_parser.parse(self.func.__doc__ or "")
def _get_description_from_docstring(self) -> str:
"""Extract description from parsed docstring."""
if self._parsed_docstring.short_description:
description = self._parsed_docstring.short_description
if self._parsed_docstring.long_description:
description += f"\n\n{self._parsed_docstring.long_description}"
return description
return ""
def _create_schema_from_function(self) -> InputSchema:
"""Create JSON schema from function signature using pydantic."""
from pydantic_core import CoreSchema
from pydantic.json_schema import JsonSchemaValue, GenerateJsonSchema
from pydantic_core.core_schema import ArgumentsParameter
class CustomGenerateJsonSchema(GenerateJsonSchema):
def __init__(self, *, func: Callable[..., Any], parsed_docstring: Any) -> None:
super().__init__()
self._func = func
self._parsed_docstring = parsed_docstring
def __call__(self, *_args: Any, **_kwds: Any) -> "CustomGenerateJsonSchema": # noqa: ARG002
return self
@override
def kw_arguments_schema(
self,
arguments: "list[ArgumentsParameter]",
var_kwargs_schema: CoreSchema | None,
) -> JsonSchemaValue:
schema = super().kw_arguments_schema(arguments, var_kwargs_schema)
if schema.get("type") != "object":
return schema
properties = schema.get("properties")
if not properties or not is_dict(properties):
return schema
# Add parameter descriptions from docstring
for param in self._parsed_docstring.params:
prop_schema = properties.get(param.arg_name)
if not prop_schema or not is_dict(prop_schema):
continue
if param.description and "description" not in prop_schema:
prop_schema["description"] = param.description
return schema
schema_generator = CustomGenerateJsonSchema(func=self.func, parsed_docstring=self._parsed_docstring)
return self._adapter.json_schema(schema_generator=schema_generator) # type: ignore
@cached_property
def _adapter(self) -> TypeAdapter[Any]:
return TypeAdapter(self._func_with_validate)
class BetaFunctionTool(BaseFunctionTool[FunctionT]):
def call(self, input: object) -> BetaFunctionToolResultType:
if iscoroutinefunction(self.func):
raise RuntimeError("Cannot call a coroutine function synchronously. Use `@async_tool` instead.")
if not is_dict(input):
raise TypeError(f"Input must be a dictionary, got {type(input).__name__}")
try:
return self._func_with_validate(**cast(Any, input))
except pydantic.ValidationError as e:
raise ValueError(f"Invalid arguments for function {self.name}") from e
class BetaAsyncFunctionTool(BaseFunctionTool[AsyncFunctionT]):
async def call(self, input: object) -> BetaFunctionToolResultType:
if not iscoroutinefunction(self.func):
raise RuntimeError("Cannot call a synchronous function asynchronously. Use `@tool` instead.")
if not is_dict(input):
raise TypeError(f"Input must be a dictionary, got {type(input).__name__}")
try:
return await self._func_with_validate(**cast(Any, input))
except pydantic.ValidationError as e:
raise ValueError(f"Invalid arguments for function {self.name}") from e
@overload
def beta_tool(func: FunctionT) -> BetaFunctionTool[FunctionT]: ...
@overload
def beta_tool(
func: FunctionT,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaFunctionTool[FunctionT]: ...
@overload
def beta_tool(
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> Callable[[FunctionT], BetaFunctionTool[FunctionT]]: ...
def beta_tool(
func: FunctionT | None = None,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaFunctionTool[FunctionT] | Callable[[FunctionT], BetaFunctionTool[FunctionT]]:
"""Create a FunctionTool from a function with automatic schema inference.
Can be used as a decorator with or without parentheses:
@function_tool
def my_func(x: int) -> str: ...
@function_tool()
def my_func(x: int) -> str: ...
@function_tool(name="custom_name")
def my_func(x: int) -> str: ...
"""
if _compat.PYDANTIC_V1:
raise RuntimeError("Tool functions are only supported with Pydantic v2")
def _make(fn: FunctionT) -> BetaFunctionTool[FunctionT]:
return BetaFunctionTool(
fn,
name=name,
description=description,
input_schema=input_schema,
defer_loading=defer_loading,
cache_control=cache_control,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
if func is not None:
return _make(func)
return _make
@overload
def beta_async_tool(func: AsyncFunctionT) -> BetaAsyncFunctionTool[AsyncFunctionT]: ...
@overload
def beta_async_tool(
func: AsyncFunctionT,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaAsyncFunctionTool[AsyncFunctionT]: ... # noqa: E501
@overload
def beta_async_tool(
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> Callable[[AsyncFunctionT], BetaAsyncFunctionTool[AsyncFunctionT]]: ...
def beta_async_tool(
func: AsyncFunctionT | None = None,
*,
name: str | None = None,
description: str | None = None,
input_schema: InputSchema | type[BaseModel] | None = None,
defer_loading: bool | None = None,
cache_control: BetaCacheControlEphemeralParam | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaAsyncFunctionTool[AsyncFunctionT] | Callable[[AsyncFunctionT], BetaAsyncFunctionTool[AsyncFunctionT]]:
"""Create an AsyncFunctionTool from a function with automatic schema inference.
Can be used as a decorator with or without parentheses:
@async_tool
async def my_func(x: int) -> str: ...
@async_tool()
async def my_func(x: int) -> str: ...
@async_tool(name="custom_name")
async def my_func(x: int) -> str: ...
"""
if _compat.PYDANTIC_V1:
raise RuntimeError("Tool functions are only supported with Pydantic v2")
def _make(fn: AsyncFunctionT) -> BetaAsyncFunctionTool[AsyncFunctionT]:
return BetaAsyncFunctionTool(
fn,
name=name,
description=description,
input_schema=input_schema,
defer_loading=defer_loading,
cache_control=cache_control,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
if func is not None:
return _make(func)
return _make
BetaRunnableTool = Union[BetaFunctionTool[Any], BetaBuiltinFunctionTool]
BetaAsyncRunnableTool = Union[BetaAsyncFunctionTool[Any], BetaAsyncBuiltinFunctionTool]

View File

@@ -0,0 +1,685 @@
from __future__ import annotations
import logging
import warnings
from abc import ABC, abstractmethod
from typing import (
TYPE_CHECKING,
Any,
List,
Union,
Generic,
TypeVar,
Callable,
Iterable,
Iterator,
Coroutine,
AsyncIterator,
)
from contextlib import contextmanager, asynccontextmanager
from typing_extensions import TypedDict, override
import httpx
from ..._types import Body, Query, Headers, NotGiven
from ..._utils import consume_sync_iterator, consume_async_iterator
from ...types.beta import BetaMessage, BetaMessageParam
from ._beta_functions import (
ToolError,
BetaFunctionTool,
BetaRunnableTool,
BetaAsyncFunctionTool,
BetaAsyncRunnableTool,
BetaBuiltinFunctionTool,
BetaAsyncBuiltinFunctionTool,
)
from .._stainless_helpers import stainless_helper_header
from ._beta_compaction_control import DEFAULT_THRESHOLD, DEFAULT_SUMMARY_PROMPT, CompactionControl
from ..streaming._beta_messages import BetaMessageStream, BetaAsyncMessageStream
from ...types.beta.parsed_beta_message import ResponseFormatT, ParsedBetaMessage, ParsedBetaContentBlock
from ...types.beta.message_create_params import ParseMessageCreateParamsBase
from ...types.beta.beta_tool_result_block_param import BetaToolResultBlockParam
if TYPE_CHECKING:
from ..._client import Anthropic, AsyncAnthropic
AnyFunctionToolT = TypeVar(
"AnyFunctionToolT",
bound=Union[
BetaFunctionTool[Any], BetaAsyncFunctionTool[Any], BetaBuiltinFunctionTool, BetaAsyncBuiltinFunctionTool
],
)
RunnerItemT = TypeVar("RunnerItemT")
log = logging.getLogger(__name__)
class RequestOptions(TypedDict, total=False):
extra_headers: Headers | None
extra_query: Query | None
extra_body: Body | None
timeout: float | httpx.Timeout | None | NotGiven
class BaseToolRunner(Generic[AnyFunctionToolT, ResponseFormatT]):
def __init__(
self,
*,
params: ParseMessageCreateParamsBase[ResponseFormatT],
options: RequestOptions,
tools: Iterable[AnyFunctionToolT],
max_iterations: int | None = None,
compaction_control: CompactionControl | None = None,
) -> None:
self._tools_by_name = {tool.name: tool for tool in tools}
self._params: ParseMessageCreateParamsBase[ResponseFormatT] = {
**params,
"messages": [message for message in params["messages"]],
}
helper_header = stainless_helper_header(
tools=self._tools_by_name.values(),
messages=params.get("messages"),
)
if helper_header:
merged_headers = {**helper_header, **(options.get("extra_headers") or {})}
options = {**options, "extra_headers": merged_headers}
self._options = options
self._messages_modified = False
self._cached_tool_call_response: BetaMessageParam | None = None
self._max_iterations = max_iterations
self._iteration_count = 0
self._compaction_control = compaction_control
def set_messages_params(
self,
params: ParseMessageCreateParamsBase[ResponseFormatT]
| Callable[[ParseMessageCreateParamsBase[ResponseFormatT]], ParseMessageCreateParamsBase[ResponseFormatT]],
) -> None:
"""
Update the parameters for the next API call. This invalidates any cached tool responses.
Args:
params (ParsedMessageCreateParamsBase[ResponseFormatT] | Callable): Either new parameters or a function to mutate existing parameters
"""
if callable(params):
params = params(self._params)
self._params = params
def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[ResponseFormatT]) -> None:
"""Add one or more messages to the conversation history.
This invalidates the cached tool response, i.e. if tools were already called, then they will
be called again on the next loop iteration.
"""
message_params: List[BetaMessageParam] = [
{"role": message.role, "content": message.content} if isinstance(message, BetaMessage) else message
for message in messages
]
self._messages_modified = True
self.set_messages_params(lambda params: {**params, "messages": [*params["messages"], *message_params]})
self._cached_tool_call_response = None
def _should_stop(self) -> bool:
if self._max_iterations is not None and self._iteration_count >= self._max_iterations:
return True
return False
class BaseSyncToolRunner(BaseToolRunner[BetaRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC):
def __init__(
self,
*,
params: ParseMessageCreateParamsBase[ResponseFormatT],
options: RequestOptions,
tools: Iterable[BetaRunnableTool],
client: Anthropic,
max_iterations: int | None = None,
compaction_control: CompactionControl | None = None,
) -> None:
super().__init__(
params=params,
options=options,
tools=tools,
max_iterations=max_iterations,
compaction_control=compaction_control,
)
self._client = client
if compaction_control is not None and compaction_control.get("enabled"):
warnings.warn(
"The 'compaction_control' parameter is deprecated and will be removed in a future version. "
"Use server-side compaction instead by passing `edits=[{'type': 'compact_20260112'}]` in your "
"the params passed to `tool_runner()`. See https://platform.claude.com/docs/en/build-with-claude/compaction",
DeprecationWarning,
stacklevel=3,
)
self._iterator = self.__run__()
self._last_message: (
Callable[[], ParsedBetaMessage[ResponseFormatT]] | ParsedBetaMessage[ResponseFormatT] | None
) = None
def __next__(self) -> RunnerItemT:
return self._iterator.__next__()
def __iter__(self) -> Iterator[RunnerItemT]:
for item in self._iterator:
yield item
@abstractmethod
@contextmanager
def _handle_request(self) -> Iterator[RunnerItemT]:
raise NotImplementedError()
yield # type: ignore[unreachable]
def _check_and_compact(self) -> bool:
"""
Check token usage and compact messages if threshold exceeded.
Returns True if compaction was performed, False otherwise.
"""
if self._compaction_control is None or not self._compaction_control["enabled"]:
return False
message = self._get_last_message()
tokens_used = 0
if message is not None:
total_input_tokens = (
message.usage.input_tokens
+ (message.usage.cache_creation_input_tokens or 0)
+ (message.usage.cache_read_input_tokens or 0)
)
tokens_used = total_input_tokens + message.usage.output_tokens
threshold = self._compaction_control.get("context_token_threshold", DEFAULT_THRESHOLD)
if tokens_used < threshold:
return False
# Perform compaction
log.info(f"Token usage {tokens_used} has exceeded the threshold of {threshold}. Performing compaction.")
model = self._compaction_control.get("model", self._params["model"])
messages = list(self._params["messages"])
if messages[-1]["role"] == "assistant":
# Remove tool_use blocks from the last message to avoid 400 error
# (tool_use requires tool_result, which we don't have yet)
non_tool_blocks = [
block
for block in messages[-1]["content"]
if isinstance(block, dict) and block.get("type") != "tool_use"
]
if non_tool_blocks:
messages[-1]["content"] = non_tool_blocks
else:
messages.pop()
messages = [
*messages,
BetaMessageParam(
role="user",
content=self._compaction_control.get("summary_prompt", DEFAULT_SUMMARY_PROMPT),
),
]
response = self._client.beta.messages.create(
model=model,
messages=messages,
max_tokens=self._params["max_tokens"],
extra_headers={"X-Stainless-Helper": "compaction"},
)
log.info(f"Compaction complete. New token usage: {response.usage.output_tokens}")
first_content = list(response.content)[0]
if first_content.type != "text":
raise ValueError("Compaction response content is not of type 'text'")
self.set_messages_params(
lambda params: {
**params,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": first_content.text,
}
],
}
],
}
)
return True
def __run__(self) -> Iterator[RunnerItemT]:
while not self._should_stop():
with self._handle_request() as item:
yield item
message = self._get_last_message()
assert message is not None
# Update container from response for programmatic tool calling support
last_assistant_message = self._get_last_assistant_message()
if last_assistant_message is not None and last_assistant_message.container is not None:
self._params["container"] = last_assistant_message.container.id
self._iteration_count += 1
# If the compaction was performed, skip tool call generation this iteration
if not self._check_and_compact():
response = self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return
if not self._messages_modified:
self.append_messages(message, response)
self._messages_modified = False
self._cached_tool_call_response = None
def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
"""
Consumes the tool runner stream and returns the last message if it has not been consumed yet.
If it has, it simply returns the last message.
"""
consume_sync_iterator(self)
last_message = self._get_last_message()
assert last_message is not None
return last_message
def generate_tool_call_response(self) -> BetaMessageParam | None:
"""Generate a MessageParam by calling tool functions with any tool use blocks from the last message.
Note the tool call response is cached, repeated calls to this method will return the same response.
None can be returned if no tool call was applicable.
"""
if self._cached_tool_call_response is not None:
log.debug("Returning cached tool call response.")
return self._cached_tool_call_response
response = self._generate_tool_call_response()
self._cached_tool_call_response = response
return response
def _generate_tool_call_response(self) -> BetaMessageParam | None:
content = self._get_last_assistant_message_content()
if not content:
return None
tool_use_blocks = [block for block in content if block.type == "tool_use"]
if not tool_use_blocks:
return None
results: list[BetaToolResultBlockParam] = []
for tool_use in tool_use_blocks:
tool = self._tools_by_name.get(tool_use.name)
if tool is None:
warnings.warn(
f"Tool '{tool_use.name}' not found in tool runner. "
f"Available tools: {list(self._tools_by_name.keys())}. "
f"If using a raw tool definition, handle the tool call manually and use `append_messages()` to add the result. "
f"Otherwise, pass the tool using `beta_tool(func)` or a `@beta_tool` decorated function.",
UserWarning,
stacklevel=3,
)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": f"Error: Tool '{tool_use.name}' not found",
"is_error": True,
}
)
continue
try:
result = tool.call(tool_use.input)
results.append({"type": "tool_result", "tool_use_id": tool_use.id, "content": result})
except ToolError as exc:
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": exc.content,
"is_error": True,
}
)
except Exception as exc:
log.exception(f"Error occurred while calling tool: {tool.name}", exc_info=exc)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": repr(exc),
"is_error": True,
}
)
return {"role": "user", "content": results}
def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
if callable(self._last_message):
return self._last_message()
return self._last_message
def _get_last_assistant_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
last_message = self._get_last_message()
if last_message is None or last_message.role != "assistant" or not last_message.content:
return None
return last_message
def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None:
last_assistant_message = self._get_last_assistant_message()
if last_assistant_message is None:
return None
return last_assistant_message.content
class BetaToolRunner(BaseSyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]):
@override
@contextmanager
def _handle_request(self) -> Iterator[ParsedBetaMessage[ResponseFormatT]]:
message = self._client.beta.messages.parse(**self._params, **self._options)
self._last_message = message
yield message
class BetaStreamingToolRunner(BaseSyncToolRunner[BetaMessageStream[ResponseFormatT], ResponseFormatT]):
@override
@contextmanager
def _handle_request(self) -> Iterator[BetaMessageStream[ResponseFormatT]]:
with self._client.beta.messages.stream(**self._params, **self._options) as stream:
self._last_message = stream.get_final_message
yield stream
class BaseAsyncToolRunner(
BaseToolRunner[BetaAsyncRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC
):
def __init__(
self,
*,
params: ParseMessageCreateParamsBase[ResponseFormatT],
options: RequestOptions,
tools: Iterable[BetaAsyncRunnableTool],
client: AsyncAnthropic,
max_iterations: int | None = None,
compaction_control: CompactionControl | None = None,
) -> None:
super().__init__(
params=params,
options=options,
tools=tools,
max_iterations=max_iterations,
compaction_control=compaction_control,
)
self._client = client
if compaction_control is not None and compaction_control.get("enabled"):
warnings.warn(
"The 'compaction_control' parameter is deprecated and will be removed in a future version. "
"Use server-side compaction instead by passing `edits=[{'type': 'compact_20260112'}]` in your "
"the params passed to `tool_runner()`. See https://platform.claude.com/docs/en/build-with-claude/compaction",
DeprecationWarning,
stacklevel=3,
)
self._iterator = self.__run__()
self._last_message: (
Callable[[], Coroutine[None, None, ParsedBetaMessage[ResponseFormatT]]]
| ParsedBetaMessage[ResponseFormatT]
| None
) = None
async def __anext__(self) -> RunnerItemT:
return await self._iterator.__anext__()
async def __aiter__(self) -> AsyncIterator[RunnerItemT]:
async for item in self._iterator:
yield item
@abstractmethod
@asynccontextmanager
async def _handle_request(self) -> AsyncIterator[RunnerItemT]:
raise NotImplementedError()
yield # type: ignore[unreachable]
async def _check_and_compact(self) -> bool:
"""
Check token usage and compact messages if threshold exceeded.
Returns True if compaction was performed, False otherwise.
"""
if self._compaction_control is None or not self._compaction_control["enabled"]:
return False
message = await self._get_last_message()
tokens_used = 0
if message is not None:
total_input_tokens = (
message.usage.input_tokens
+ (message.usage.cache_creation_input_tokens or 0)
+ (message.usage.cache_read_input_tokens or 0)
)
tokens_used = total_input_tokens + message.usage.output_tokens
threshold = self._compaction_control.get("context_token_threshold", DEFAULT_THRESHOLD)
if tokens_used < threshold:
return False
# Perform compaction
log.info(f"Token usage {tokens_used} has exceeded the threshold of {threshold}. Performing compaction.")
model = self._compaction_control.get("model", self._params["model"])
messages = list(self._params["messages"])
if messages[-1]["role"] == "assistant":
# Remove tool_use blocks from the last message to avoid 400 error
# (tool_use requires tool_result, which we don't have yet)
non_tool_blocks = [
block
for block in messages[-1]["content"]
if isinstance(block, dict) and block.get("type") != "tool_use"
]
if non_tool_blocks:
messages[-1]["content"] = non_tool_blocks
else:
messages.pop()
messages = [
*messages,
BetaMessageParam(
role="user",
content=self._compaction_control.get("summary_prompt", DEFAULT_SUMMARY_PROMPT),
),
]
response = await self._client.beta.messages.create(
model=model,
messages=messages,
max_tokens=self._params["max_tokens"],
extra_headers={"X-Stainless-Helper": "compaction"},
)
log.info(f"Compaction complete. New token usage: {response.usage.output_tokens}")
first_content = list(response.content)[0]
if first_content.type != "text":
raise ValueError("Compaction response content is not of type 'text'")
self.set_messages_params(
lambda params: {
**params,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": first_content.text,
}
],
}
],
}
)
return True
async def __run__(self) -> AsyncIterator[RunnerItemT]:
while not self._should_stop():
async with self._handle_request() as item:
yield item
message = await self._get_last_message()
assert message is not None
# Update container from response for programmatic tool calling support
last_assistant_message = await self._get_last_assistant_message()
if last_assistant_message is not None and last_assistant_message.container is not None:
self._params["container"] = last_assistant_message.container.id
self._iteration_count += 1
# If the compaction was performed, skip tool call generation this iteration
if not await self._check_and_compact():
response = await self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return
if not self._messages_modified:
self.append_messages(message, response)
self._messages_modified = False
self._cached_tool_call_response = None
async def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
"""
Consumes the tool runner stream and returns the last message if it has not been consumed yet.
If it has, it simply returns the last message.
"""
await consume_async_iterator(self)
last_message = await self._get_last_message()
assert last_message is not None
return last_message
async def generate_tool_call_response(self) -> BetaMessageParam | None:
"""Generate a MessageParam by calling tool functions with any tool use blocks from the last message.
Note the tool call response is cached, repeated calls to this method will return the same response.
None can be returned if no tool call was applicable.
"""
if self._cached_tool_call_response is not None:
log.debug("Returning cached tool call response.")
return self._cached_tool_call_response
response = await self._generate_tool_call_response()
self._cached_tool_call_response = response
return response
async def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
if callable(self._last_message):
return await self._last_message()
return self._last_message
async def _get_last_assistant_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
last_message = await self._get_last_message()
if last_message is None or last_message.role != "assistant" or not last_message.content:
return None
return last_message
async def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None:
last_assistant_message = await self._get_last_assistant_message()
if last_assistant_message is None:
return None
return last_assistant_message.content
async def _generate_tool_call_response(self) -> BetaMessageParam | None:
content = await self._get_last_assistant_message_content()
if not content:
return None
tool_use_blocks = [block for block in content if block.type == "tool_use"]
if not tool_use_blocks:
return None
results: list[BetaToolResultBlockParam] = []
for tool_use in tool_use_blocks:
tool = self._tools_by_name.get(tool_use.name)
if tool is None:
warnings.warn(
f"Tool '{tool_use.name}' not found in tool runner. "
f"Available tools: {list(self._tools_by_name.keys())}. "
f"If using a raw tool definition, handle the tool call manually and use `append_messages()` to add the result. "
f"Otherwise, pass the tool using `beta_async_tool(func)` or a `@beta_async_tool` decorated function.",
UserWarning,
stacklevel=3,
)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": f"Error: Tool '{tool_use.name}' not found",
"is_error": True,
}
)
continue
try:
result = await tool.call(tool_use.input)
results.append({"type": "tool_result", "tool_use_id": tool_use.id, "content": result})
except ToolError as exc:
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": exc.content,
"is_error": True,
}
)
except Exception as exc:
log.exception(f"Error occurred while calling tool: {tool.name}", exc_info=exc)
results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": repr(exc),
"is_error": True,
}
)
return {"role": "user", "content": results}
class BetaAsyncToolRunner(BaseAsyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]):
@override
@asynccontextmanager
async def _handle_request(self) -> AsyncIterator[ParsedBetaMessage[ResponseFormatT]]:
message = await self._client.beta.messages.parse(**self._params, **self._options)
self._last_message = message
yield message
class BetaAsyncStreamingToolRunner(BaseAsyncToolRunner[BetaAsyncMessageStream[ResponseFormatT], ResponseFormatT]):
@override
@asynccontextmanager
async def _handle_request(self) -> AsyncIterator[BetaAsyncMessageStream[ResponseFormatT]]:
async with self._client.beta.messages.stream(**self._params, **self._options) as stream:
self._last_message = stream.get_final_message
yield stream

View File

@@ -0,0 +1,442 @@
"""Helpers for integrating MCP (Model Context Protocol) SDK types with the Anthropic SDK.
These helpers reduce boilerplate when converting between MCP types and Anthropic API types.
Usage::
from anthropic.lib.tools.mcp import mcp_tool, async_mcp_tool, mcp_message
This module requires the ``mcp`` package to be installed.
"""
# pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportMissingImports=false, reportUnknownParameterType=false
from __future__ import annotations
import json
import base64
from typing import Any, Iterable
from urllib.parse import urlparse
from typing_extensions import Literal
try:
from mcp.types import ( # type: ignore[import-not-found]
Tool,
TextContent,
ContentBlock,
ImageContent,
PromptMessage,
CallToolResult,
EmbeddedResource,
ReadResourceResult,
BlobResourceContents,
TextResourceContents,
)
from mcp.client.session import ClientSession # type: ignore[import-not-found]
except ImportError as _err:
raise ImportError(
"The `mcp` package is required to use MCP helpers. Install it with: pip install anthropic[mcp]. Requires Python 3.10 or higher."
) from _err
from ...types.beta import (
BetaBase64PDFSourceParam,
BetaPlainTextSourceParam,
BetaBase64ImageSourceParam,
BetaCacheControlEphemeralParam,
)
from ._beta_functions import (
ToolError,
BetaFunctionTool,
BetaAsyncFunctionTool,
BetaFunctionToolResultType,
beta_tool,
beta_async_tool,
)
from .._stainless_helpers import tag_helper
from ...types.beta.beta_tool_result_block_param import Content as BetaContent
__all__ = [
"mcp_tool",
"async_mcp_tool",
"mcp_content",
"mcp_message",
"mcp_resource_to_content",
"mcp_resource_to_file",
"UnsupportedMCPValueError",
]
# -----------------------------------------------------------------------
# Supported MIME types
# -----------------------------------------------------------------------
_SUPPORTED_IMAGE_TYPES = frozenset({"image/jpeg", "image/png", "image/gif", "image/webp"})
class _TaggedDict(dict): # type: ignore[type-arg]
"""A dict subclass that can carry a ``_stainless_helper`` attribute.
Behaves identically to a regular dict for serialization and isinstance checks,
but allows attaching tracking metadata that won't appear in JSON output.
"""
class _TaggedTuple(tuple): # type: ignore[type-arg]
"""A tuple subclass that can carry a ``_stainless_helper`` attribute."""
def _is_supported_image_type(mime_type: str) -> bool:
return mime_type in _SUPPORTED_IMAGE_TYPES
def _is_supported_resource_mime_type(mime_type: str | None) -> bool:
return (
mime_type is None
or mime_type.startswith("text/")
or mime_type == "application/pdf"
or _is_supported_image_type(mime_type)
)
# -----------------------------------------------------------------------
# Errors
# -----------------------------------------------------------------------
class UnsupportedMCPValueError(Exception):
"""Raised when an MCP value cannot be converted to a format supported by the Claude API."""
# -----------------------------------------------------------------------
# Content conversion
# -----------------------------------------------------------------------
def mcp_content(
content: ContentBlock,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> BetaContent:
"""Convert a single MCP content block to an Anthropic content block.
Handles text, image, and embedded resource content types.
Raises :class:`UnsupportedMCPValueError` for audio and resource_link types.
"""
if isinstance(content, TextContent):
block = _TaggedDict({"type": "text", "text": content.text})
if cache_control is not None:
block["cache_control"] = cache_control
tag_helper(block, "mcp_content")
return block # type: ignore[return-value]
if isinstance(content, ImageContent):
if not _is_supported_image_type(content.mimeType):
raise UnsupportedMCPValueError(f"Unsupported image MIME type: {content.mimeType}")
image_block = _TaggedDict(
{
"type": "image",
"source": BetaBase64ImageSourceParam(
type="base64",
data=content.data,
media_type=content.mimeType, # type: ignore[typeddict-item]
),
}
)
if cache_control is not None:
image_block["cache_control"] = cache_control
tag_helper(image_block, "mcp_content")
return image_block # type: ignore[return-value]
if isinstance(content, EmbeddedResource):
return _resource_contents_to_block(content.resource, cache_control=cache_control)
# audio, resource_link, or unknown
content_type = getattr(content, "type", type(content).__name__)
raise UnsupportedMCPValueError(f"Unsupported MCP content type: {content_type}")
def _resource_contents_to_block(
resource: TextResourceContents | BlobResourceContents,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> BetaContent:
"""Convert MCP resource contents to an Anthropic content block."""
mime_type = resource.mimeType
# Images
if mime_type is not None and _is_supported_image_type(mime_type):
if not isinstance(resource, BlobResourceContents):
raise UnsupportedMCPValueError(f"Image resource must have blob data, not text. URI: {resource.uri}")
image_block = _TaggedDict(
{
"type": "image",
"source": BetaBase64ImageSourceParam(
type="base64",
data=resource.blob,
media_type=mime_type, # type: ignore[typeddict-item]
),
}
)
if cache_control is not None:
image_block["cache_control"] = cache_control
tag_helper(image_block, "mcp_resource_to_content")
return image_block # type: ignore[return-value]
# PDFs
if mime_type == "application/pdf":
if not isinstance(resource, BlobResourceContents):
raise UnsupportedMCPValueError(f"PDF resource must have blob data, not text. URI: {resource.uri}")
pdf_block = _TaggedDict(
{
"type": "document",
"source": BetaBase64PDFSourceParam(
type="base64",
data=resource.blob,
media_type="application/pdf",
),
}
)
if cache_control is not None:
pdf_block["cache_control"] = cache_control
tag_helper(pdf_block, "mcp_resource_to_content")
return pdf_block # type: ignore[return-value]
# Text (text/*, or no MIME type)
if mime_type is None or mime_type.startswith("text/"):
if isinstance(resource, TextResourceContents):
data = resource.text
else:
data = base64.b64decode(resource.blob).decode("utf-8")
text_block = _TaggedDict(
{
"type": "document",
"source": BetaPlainTextSourceParam(
type="text",
data=data,
media_type="text/plain",
),
}
)
if cache_control is not None:
text_block["cache_control"] = cache_control
tag_helper(text_block, "mcp_resource_to_content")
return text_block # type: ignore[return-value]
raise UnsupportedMCPValueError(f'Unsupported MIME type "{mime_type}" for resource: {resource.uri}')
# -----------------------------------------------------------------------
# Message conversion
# -----------------------------------------------------------------------
def mcp_message(
message: PromptMessage,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> dict[str, Any]:
"""Convert an MCP prompt message to an Anthropic ``BetaMessageParam``."""
result = _TaggedDict(
{
"role": message.role,
"content": [mcp_content(message.content, cache_control=cache_control)],
}
)
tag_helper(result, "mcp_message")
return result
# -----------------------------------------------------------------------
# Resource conversion
# -----------------------------------------------------------------------
def mcp_resource_to_content(
result: ReadResourceResult,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
) -> BetaContent:
"""Convert MCP resource contents to an Anthropic content block.
Finds the first resource with a supported MIME type from the result's
``contents`` list.
"""
if not result.contents:
raise UnsupportedMCPValueError("Resource contents array must contain at least one item")
supported = next(
(c for c in result.contents if _is_supported_resource_mime_type(c.mimeType)),
None,
)
if supported is None:
mime_types = [c.mimeType for c in result.contents if c.mimeType is not None]
raise UnsupportedMCPValueError(
f"No supported MIME type found in resource contents. Available: {', '.join(mime_types)}"
)
return _resource_contents_to_block(supported, cache_control=cache_control)
def mcp_resource_to_file(
result: ReadResourceResult,
) -> tuple[str | None, bytes, str | None]:
"""Convert MCP resource contents to a file tuple for ``files.upload()``.
Returns a ``(filename, content_bytes, mime_type)`` tuple compatible with
the SDK's ``FileTypes``.
"""
if not result.contents:
raise UnsupportedMCPValueError("Resource contents array must contain at least one item")
resource = result.contents[0]
uri_str = str(resource.uri)
# Extract filename from URI
path = urlparse(uri_str).path
name = path.rsplit("/", 1)[-1] if path else None
# Get bytes
if isinstance(resource, BlobResourceContents):
content_bytes = base64.b64decode(resource.blob)
else:
content_bytes = resource.text.encode("utf-8")
file_tuple = _TaggedTuple((name, content_bytes, resource.mimeType))
tag_helper(file_tuple, "mcp_resource_to_file")
return file_tuple
# -----------------------------------------------------------------------
# Tool result conversion (used by tool call handlers)
# -----------------------------------------------------------------------
def _convert_tool_result(result: CallToolResult) -> BetaFunctionToolResultType:
"""Convert MCP ``CallToolResult`` to a value suitable for returning from ``call()``."""
if result.isError:
raise ToolError([mcp_content(item) for item in result.content])
# If content is empty but structuredContent is present, JSON-encode it
if not result.content and result.structuredContent is not None:
return json.dumps(result.structuredContent)
return [mcp_content(item) for item in result.content]
# -----------------------------------------------------------------------
# Public factory functions
# -----------------------------------------------------------------------
def mcp_tool(
tool: Tool,
client: ClientSession,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
defer_loading: bool | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaFunctionTool[Any]:
"""Convert an MCP tool to a sync runnable tool for ``tool_runner()``.
Example::
from anthropic.lib.tools.mcp import mcp_tool
tools_result = await mcp_client.list_tools()
runner = client.beta.messages.tool_runner(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=[mcp_tool(t, mcp_client) for t in tools_result.tools],
messages=[{"role": "user", "content": "Use the available tools"}],
)
Args:
tool: An MCP tool definition from ``client.list_tools()``.
client: The MCP ``ClientSession`` used to call the tool.
cache_control: Cache control configuration.
defer_loading: If true, tool will not be included in initial system prompt.
allowed_callers: Which callers may use this tool.
eager_input_streaming: Enable eager input streaming for this tool.
input_examples: Example inputs for the tool.
strict: When true, guarantees schema validation on tool names and inputs.
"""
import anyio.from_thread
tool_name = tool.name
def call_mcp(**kwargs: Any) -> BetaFunctionToolResultType:
result = anyio.from_thread.run(client.call_tool, tool_name, kwargs)
return _convert_tool_result(result)
result = beta_tool(
call_mcp,
name=tool_name,
description=tool.description,
input_schema=tool.inputSchema,
cache_control=cache_control,
defer_loading=defer_loading,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
tag_helper(result, "mcp_tool")
return result
def async_mcp_tool(
tool: Tool,
client: ClientSession,
*,
cache_control: BetaCacheControlEphemeralParam | None = None,
defer_loading: bool | None = None,
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
eager_input_streaming: bool | None = None,
input_examples: Iterable[dict[str, object]] | None = None,
strict: bool | None = None,
) -> BetaAsyncFunctionTool[Any]:
"""Convert an MCP tool to an async runnable tool for ``tool_runner()``.
Example::
from anthropic.lib.tools.mcp import async_mcp_tool
tools_result = await mcp_client.list_tools()
runner = await client.beta.messages.tool_runner(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=[async_mcp_tool(t, mcp_client) for t in tools_result.tools],
messages=[{"role": "user", "content": "Use the available tools"}],
)
Args:
tool: An MCP tool definition from ``client.list_tools()``.
client: The MCP ``ClientSession`` used to call the tool.
cache_control: Cache control configuration.
defer_loading: If true, tool will not be included in initial system prompt.
allowed_callers: Which callers may use this tool.
eager_input_streaming: Enable eager input streaming for this tool.
input_examples: Example inputs for the tool.
strict: When true, guarantees schema validation on tool names and inputs.
"""
tool_name = tool.name
async def call_mcp(**kwargs: Any) -> BetaFunctionToolResultType:
result = await client.call_tool(name=tool_name, arguments=kwargs)
return _convert_tool_result(result)
result = beta_async_tool(
call_mcp,
name=tool_name,
description=tool.description,
input_schema=tool.inputSchema,
cache_control=cache_control,
defer_loading=defer_loading,
allowed_callers=allowed_callers,
eager_input_streaming=eager_input_streaming,
input_examples=input_examples,
strict=strict,
)
tag_helper(result, "mcp_tool")
return result

View File

@@ -0,0 +1 @@
from ._client import AnthropicVertex as AnthropicVertex, AsyncAnthropicVertex as AsyncAnthropicVertex

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from .._extras import google_auth
if TYPE_CHECKING:
from google.auth.credentials import Credentials # type: ignore[import-untyped]
# pyright: reportMissingTypeStubs=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false
# google libraries don't provide types :/
# Note: these functions are blocking as they make HTTP requests, the async
# client runs these functions in a separate thread to ensure they do not
# cause synchronous blocking issues.
def load_auth(*, project_id: str | None) -> tuple[Credentials, str]:
try:
from google.auth.transport.requests import Request # type: ignore[import-untyped]
except ModuleNotFoundError as err:
raise RuntimeError(
f"Could not import google.auth, you need to install the SDK with `pip install anthropic[vertex]`"
) from err
credentials, loaded_project_id = google_auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
credentials = cast(Any, credentials)
credentials.refresh(Request())
if not project_id:
project_id = loaded_project_id
if not project_id:
raise ValueError("Could not resolve project_id")
return credentials, project_id
def refresh_auth(credentials: Credentials) -> None:
from google.auth.transport.requests import Request # type: ignore[import-untyped]
credentials.refresh(Request())

View File

@@ -0,0 +1,102 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
from __future__ import annotations
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ._beta_messages import (
Messages,
AsyncMessages,
MessagesWithRawResponse,
AsyncMessagesWithRawResponse,
MessagesWithStreamingResponse,
AsyncMessagesWithStreamingResponse,
)
__all__ = ["Beta", "AsyncBeta"]
class Beta(SyncAPIResource):
@cached_property
def messages(self) -> Messages:
return Messages(self._client)
@cached_property
def with_raw_response(self) -> BetaWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return BetaWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> BetaWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return BetaWithStreamingResponse(self)
class AsyncBeta(AsyncAPIResource):
@cached_property
def messages(self) -> AsyncMessages:
return AsyncMessages(self._client)
@cached_property
def with_raw_response(self) -> AsyncBetaWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return AsyncBetaWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> AsyncBetaWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return AsyncBetaWithStreamingResponse(self)
class BetaWithRawResponse:
def __init__(self, beta: Beta) -> None:
self._beta = beta
@cached_property
def messages(self) -> MessagesWithRawResponse:
return MessagesWithRawResponse(self._beta.messages)
class AsyncBetaWithRawResponse:
def __init__(self, beta: AsyncBeta) -> None:
self._beta = beta
@cached_property
def messages(self) -> AsyncMessagesWithRawResponse:
return AsyncMessagesWithRawResponse(self._beta.messages)
class BetaWithStreamingResponse:
def __init__(self, beta: Beta) -> None:
self._beta = beta
@cached_property
def messages(self) -> MessagesWithStreamingResponse:
return MessagesWithStreamingResponse(self._beta.messages)
class AsyncBetaWithStreamingResponse:
def __init__(self, beta: AsyncBeta) -> None:
self._beta = beta
@cached_property
def messages(self) -> AsyncMessagesWithStreamingResponse:
return AsyncMessagesWithStreamingResponse(self._beta.messages)

View File

@@ -0,0 +1,97 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
from __future__ import annotations
from ... import _legacy_response
from ..._compat import cached_property
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
from ...resources.beta import Messages as FirstPartyMessagesAPI, AsyncMessages as FirstPartyAsyncMessagesAPI
__all__ = ["Messages", "AsyncMessages"]
class Messages(SyncAPIResource):
create = FirstPartyMessagesAPI.create
stream = FirstPartyMessagesAPI.stream
count_tokens = FirstPartyMessagesAPI.count_tokens
@cached_property
def with_raw_response(self) -> MessagesWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return MessagesWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> MessagesWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return MessagesWithStreamingResponse(self)
class AsyncMessages(AsyncAPIResource):
create = FirstPartyAsyncMessagesAPI.create
stream = FirstPartyAsyncMessagesAPI.stream
count_tokens = FirstPartyAsyncMessagesAPI.count_tokens
@cached_property
def with_raw_response(self) -> AsyncMessagesWithRawResponse:
"""
This property can be used as a prefix for any HTTP method call to return the
the raw response object instead of the parsed content.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#accessing-raw-response-data-eg-headers
"""
return AsyncMessagesWithRawResponse(self)
@cached_property
def with_streaming_response(self) -> AsyncMessagesWithStreamingResponse:
"""
An alternative to `.with_raw_response` that doesn't eagerly read the response body.
For more information, see https://www.github.com/anthropics/anthropic-sdk-python#with_streaming_response
"""
return AsyncMessagesWithStreamingResponse(self)
class MessagesWithRawResponse:
def __init__(self, messages: Messages) -> None:
self._messages = messages
self.create = _legacy_response.to_raw_response_wrapper(
messages.create,
)
class AsyncMessagesWithRawResponse:
def __init__(self, messages: AsyncMessages) -> None:
self._messages = messages
self.create = _legacy_response.async_to_raw_response_wrapper(
messages.create,
)
class MessagesWithStreamingResponse:
def __init__(self, messages: Messages) -> None:
self._messages = messages
self.create = to_streamed_response_wrapper(
messages.create,
)
class AsyncMessagesWithStreamingResponse:
def __init__(self, messages: AsyncMessages) -> None:
self._messages = messages
self.create = async_to_streamed_response_wrapper(
messages.create,
)

View File

@@ -0,0 +1,416 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING, Any, Union, Mapping, TypeVar
from typing_extensions import Self, override
import httpx
from ... import _exceptions
from ._auth import load_auth, refresh_auth
from ._beta import Beta, AsyncBeta
from ..._types import NOT_GIVEN, NotGiven
from ..._utils import is_dict, asyncify, is_given
from ..._compat import model_copy, typed_cached_property
from ..._models import FinalRequestOptions
from ..._version import __version__
from ..._streaming import Stream, AsyncStream
from ..._exceptions import AnthropicError, APIStatusError
from ..._base_client import (
DEFAULT_MAX_RETRIES,
BaseClient,
SyncAPIClient,
AsyncAPIClient,
)
from ...resources.messages import Messages, AsyncMessages
if TYPE_CHECKING:
from google.auth.credentials import Credentials as GoogleCredentials # type: ignore
DEFAULT_VERSION = "vertex-2023-10-16"
_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient])
_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]])
class BaseVertexClient(BaseClient[_HttpxClientT, _DefaultStreamT]):
@typed_cached_property
def region(self) -> str:
raise RuntimeError("region not set")
@typed_cached_property
def project_id(self) -> str | None:
project_id = os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID")
if project_id:
return project_id
return None
@override
def _make_status_error(
self,
err_msg: str,
*,
body: object,
response: httpx.Response,
) -> APIStatusError:
if response.status_code == 400:
return _exceptions.BadRequestError(err_msg, response=response, body=body)
if response.status_code == 401:
return _exceptions.AuthenticationError(err_msg, response=response, body=body)
if response.status_code == 403:
return _exceptions.PermissionDeniedError(err_msg, response=response, body=body)
if response.status_code == 404:
return _exceptions.NotFoundError(err_msg, response=response, body=body)
if response.status_code == 409:
return _exceptions.ConflictError(err_msg, response=response, body=body)
if response.status_code == 422:
return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body)
if response.status_code == 429:
return _exceptions.RateLimitError(err_msg, response=response, body=body)
if response.status_code == 503:
return _exceptions.ServiceUnavailableError(err_msg, response=response, body=body)
if response.status_code == 504:
return _exceptions.DeadlineExceededError(err_msg, response=response, body=body)
if response.status_code >= 500:
return _exceptions.InternalServerError(err_msg, response=response, body=body)
return APIStatusError(err_msg, response=response, body=body)
class AnthropicVertex(BaseVertexClient[httpx.Client, Stream[Any]], SyncAPIClient):
messages: Messages
beta: Beta
def __init__(
self,
*,
region: str | NotGiven = NOT_GIVEN,
project_id: str | NotGiven = NOT_GIVEN,
access_token: str | None = None,
credentials: GoogleCredentials | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
# Configure a custom httpx client. See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
http_client: httpx.Client | None = None,
_strict_response_validation: bool = False,
) -> None:
if not is_given(region):
region = os.environ.get("CLOUD_ML_REGION", NOT_GIVEN)
if not is_given(region):
raise ValueError(
"No region was given. The client should be instantiated with the `region` argument or the `CLOUD_ML_REGION` environment variable should be set."
)
if base_url is None:
base_url = os.environ.get("ANTHROPIC_VERTEX_BASE_URL")
if base_url is None:
if region == "global":
base_url = "https://aiplatform.googleapis.com/v1"
elif region == "us":
base_url = "https://aiplatform.us.rep.googleapis.com/v1"
elif region == "eu":
base_url = "https://aiplatform.eu.rep.googleapis.com/v1"
else:
base_url = f"https://{region}-aiplatform.googleapis.com/v1"
super().__init__(
version=__version__,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
custom_headers=default_headers,
custom_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
if is_given(project_id):
self.project_id = project_id
self.region = region
self.access_token = access_token
self.credentials = credentials
self.messages = Messages(self)
self.beta = Beta(self)
@override
def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
return _prepare_options(options, project_id=self.project_id, region=self.region)
@override
def _prepare_request(self, request: httpx.Request) -> None:
if request.headers.get("Authorization"):
# already authenticated, nothing for us to do
return
request.headers["Authorization"] = f"Bearer {self._ensure_access_token()}"
def _ensure_access_token(self) -> str:
if self.access_token is not None:
return self.access_token
if not self.credentials:
self.credentials, project_id = load_auth(project_id=self.project_id)
if not self.project_id:
self.project_id = project_id
if self.credentials.expired or not self.credentials.token:
refresh_auth(self.credentials)
if not self.credentials.token:
raise RuntimeError("Could not resolve API token from the environment")
assert isinstance(self.credentials.token, str)
return self.credentials.token
def copy(
self,
*,
region: str | NotGiven = NOT_GIVEN,
project_id: str | NotGiven = NOT_GIVEN,
access_token: str | None = None,
credentials: GoogleCredentials | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.Client | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
"""
Create a new client instance re-using the same options given to the current client with optional overriding.
"""
if default_headers is not None and set_default_headers is not None:
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
if default_query is not None and set_default_query is not None:
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
headers = self._custom_headers
if default_headers is not None:
headers = {**headers, **default_headers}
elif set_default_headers is not None:
headers = set_default_headers
params = self._custom_query
if default_query is not None:
params = {**params, **default_query}
elif set_default_query is not None:
params = set_default_query
http_client = http_client or self._client
return self.__class__(
region=region if is_given(region) else self.region,
project_id=project_id if is_given(project_id) else self.project_id or NOT_GIVEN,
access_token=access_token or self.access_token,
credentials=credentials or self.credentials,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
**_extra_kwargs,
)
# Alias for `copy` for nicer inline usage, e.g.
# client.with_options(timeout=10).foo.create(...)
with_options = copy
class AsyncAnthropicVertex(BaseVertexClient[httpx.AsyncClient, AsyncStream[Any]], AsyncAPIClient):
messages: AsyncMessages
beta: AsyncBeta
def __init__(
self,
*,
region: str | NotGiven = NOT_GIVEN,
project_id: str | NotGiven = NOT_GIVEN,
access_token: str | None = None,
credentials: GoogleCredentials | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
# Configure a custom httpx client. See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
http_client: httpx.AsyncClient | None = None,
_strict_response_validation: bool = False,
) -> None:
if not is_given(region):
region = os.environ.get("CLOUD_ML_REGION", NOT_GIVEN)
if not is_given(region):
raise ValueError(
"No region was given. The client should be instantiated with the `region` argument or the `CLOUD_ML_REGION` environment variable should be set."
)
if base_url is None:
base_url = os.environ.get("ANTHROPIC_VERTEX_BASE_URL")
if base_url is None:
if region == "global":
base_url = "https://aiplatform.googleapis.com/v1"
else:
base_url = f"https://{region}-aiplatform.googleapis.com/v1"
super().__init__(
version=__version__,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
custom_headers=default_headers,
custom_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
if is_given(project_id):
self.project_id = project_id
self.region = region
self.access_token = access_token
self.credentials = credentials
self.messages = AsyncMessages(self)
self.beta = AsyncBeta(self)
@override
async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
return _prepare_options(options, project_id=self.project_id, region=self.region)
@override
async def _prepare_request(self, request: httpx.Request) -> None:
if request.headers.get("Authorization"):
# already authenticated, nothing for us to do
return
request.headers["Authorization"] = f"Bearer {await self._ensure_access_token()}"
async def _ensure_access_token(self) -> str:
if self.access_token is not None:
return self.access_token
if not self.credentials:
self.credentials, project_id = await asyncify(load_auth)(project_id=self.project_id)
if not self.project_id:
self.project_id = project_id
if self.credentials.expired or not self.credentials.token:
await asyncify(refresh_auth)(self.credentials)
if not self.credentials.token:
raise RuntimeError("Could not resolve API token from the environment")
assert isinstance(self.credentials.token, str)
return self.credentials.token
def copy(
self,
*,
region: str | NotGiven = NOT_GIVEN,
project_id: str | NotGiven = NOT_GIVEN,
access_token: str | None = None,
credentials: GoogleCredentials | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
http_client: httpx.AsyncClient | None = None,
max_retries: int | NotGiven = NOT_GIVEN,
default_headers: Mapping[str, str] | None = None,
set_default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
set_default_query: Mapping[str, object] | None = None,
_extra_kwargs: Mapping[str, Any] = {},
) -> Self:
"""
Create a new client instance re-using the same options given to the current client with optional overriding.
"""
if default_headers is not None and set_default_headers is not None:
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
if default_query is not None and set_default_query is not None:
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
headers = self._custom_headers
if default_headers is not None:
headers = {**headers, **default_headers}
elif set_default_headers is not None:
headers = set_default_headers
params = self._custom_query
if default_query is not None:
params = {**params, **default_query}
elif set_default_query is not None:
params = set_default_query
http_client = http_client or self._client
return self.__class__(
region=region if is_given(region) else self.region,
project_id=project_id if is_given(project_id) else self.project_id or NOT_GIVEN,
access_token=access_token or self.access_token,
credentials=credentials or self.credentials,
base_url=base_url or self.base_url,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
http_client=http_client,
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
**_extra_kwargs,
)
# Alias for `copy` for nicer inline usage, e.g.
# client.with_options(timeout=10).foo.create(...)
with_options = copy
def _prepare_options(input_options: FinalRequestOptions, *, project_id: str | None, region: str) -> FinalRequestOptions:
options = model_copy(input_options, deep=True)
if is_dict(options.json_data):
options.json_data.setdefault("anthropic_version", DEFAULT_VERSION)
if options.url in {"/v1/messages", "/v1/messages?beta=true"} and options.method == "post":
if project_id is None:
raise RuntimeError(
"No project_id was given and it could not be resolved from credentials. The client should be instantiated with the `project_id` argument or the `ANTHROPIC_VERTEX_PROJECT_ID` environment variable should be set."
)
if not is_dict(options.json_data):
raise RuntimeError("Expected json data to be a dictionary for post /v1/messages")
model = options.json_data.pop("model")
stream = options.json_data.get("stream", False)
specifier = "streamRawPredict" if stream else "rawPredict"
options.url = f"/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{specifier}"
if options.url in {"/v1/messages/count_tokens", "/v1/messages/count_tokens?beta=true"} and options.method == "post":
if project_id is None:
raise RuntimeError(
"No project_id was given and it could not be resolved from credentials. The client should be instantiated with the `project_id` argument or the `ANTHROPIC_VERTEX_PROJECT_ID` environment variable should be set."
)
options.url = f"/projects/{project_id}/locations/{region}/publishers/anthropic/models/count-tokens:rawPredict"
if options.url.startswith("/v1/messages/batches"):
raise AnthropicError("The Batch API is not supported in the Vertex client yet")
return options