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:
4
venv/lib/python3.12/site-packages/anthropic/lib/.keep
Normal file
4
venv/lib/python3.12/site-packages/anthropic/lib/.keep
Normal 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.
|
||||
@@ -0,0 +1 @@
|
||||
from ._files import files_from_dir as files_from_dir, async_files_from_dir as async_files_from_dir
|
||||
@@ -0,0 +1 @@
|
||||
from ._google_auth import google_auth as google_auth
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
42
venv/lib/python3.12/site-packages/anthropic/lib/_files.py
Normal file
42
venv/lib/python3.12/site-packages/anthropic/lib/_files.py
Normal 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()))
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
from ._client import AnthropicAWS as AnthropicAWS, AsyncAnthropicAWS as AsyncAnthropicAWS
|
||||
72
venv/lib/python3.12/site-packages/anthropic/lib/aws/_auth.py
Normal file
72
venv/lib/python3.12/site-packages/anthropic/lib/aws/_auth.py
Normal 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}
|
||||
401
venv/lib/python3.12/site-packages/anthropic/lib/aws/_client.py
Normal file
401
venv/lib/python3.12/site-packages/anthropic/lib/aws/_client.py
Normal 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
from ._client import AnthropicBedrock as AnthropicBedrock, AsyncAnthropicBedrock as AsyncAnthropicBedrock
|
||||
from ._mantle import (
|
||||
AnthropicBedrockMantle as AnthropicBedrockMantle,
|
||||
AsyncAnthropicBedrockMantle as AsyncAnthropicBedrockMantle,
|
||||
)
|
||||
@@ -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}
|
||||
102
venv/lib/python3.12/site-packages/anthropic/lib/bedrock/_beta.py
Normal file
102
venv/lib/python3.12/site-packages/anthropic/lib/bedrock/_beta.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
127
venv/lib/python3.12/site-packages/anthropic/lib/foundry.md
Normal file
127
venv/lib/python3.12/site-packages/anthropic/lib/foundry.md
Normal 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())
|
||||
```
|
||||
443
venv/lib/python3.12/site-packages/anthropic/lib/foundry.py
Normal file
443
venv/lib/python3.12/site-packages/anthropic/lib/foundry.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
442
venv/lib/python3.12/site-packages/anthropic/lib/tools/mcp.py
Normal file
442
venv/lib/python3.12/site-packages/anthropic/lib/tools/mcp.py
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
from ._client import AnthropicVertex as AnthropicVertex, AsyncAnthropicVertex as AsyncAnthropicVertex
|
||||
@@ -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())
|
||||
102
venv/lib/python3.12/site-packages/anthropic/lib/vertex/_beta.py
Normal file
102
venv/lib/python3.12/site-packages/anthropic/lib/vertex/_beta.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user