From b92df2882cc4480c3c2217dfe09977ffe6cb9f38 Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:57:31 +0800 Subject: [PATCH 1/6] Rebase onto upstream main --- .github/workflows/ci.yml | 2 +- .release-please-manifest.json | 2 +- .stats.yml | 6 +- CHANGELOG.md | 49 --- CONTRIBUTING.md | 3 +- api.md | 3 - examples/messages_stream.py | 2 +- pyproject.toml | 10 +- scripts/utils/ruffen-docs.py | 2 +- src/anthropic/_client.py | 55 ++- src/anthropic/_files.py | 6 +- src/anthropic/_version.py | 2 +- src/anthropic/lib/aws/_client.py | 21 +- src/anthropic/lib/bedrock/_beta_messages.py | 6 - src/anthropic/lib/bedrock/_client.py | 22 +- src/anthropic/lib/bedrock/_mantle.py | 22 +- src/anthropic/lib/credentials/__init__.py | 2 - src/anthropic/lib/credentials/_providers.py | 36 +- src/anthropic/lib/credentials/_types.py | 29 +- src/anthropic/lib/credentials/_workload.py | 26 +- src/anthropic/lib/foundry.py | 26 +- src/anthropic/lib/google_cloud/_client.py | 30 +- src/anthropic/lib/streaming/_beta_messages.py | 56 +-- src/anthropic/lib/streaming/_beta_types.py | 5 +- src/anthropic/lib/streaming/_messages.py | 52 ++- src/anthropic/lib/streaming/_types.py | 5 +- src/anthropic/lib/tools/_skills.py | 51 +-- src/anthropic/lib/tools/agent_toolset.py | 126 ++----- src/anthropic/lib/vertex/_beta_messages.py | 4 - src/anthropic/lib/vertex/_client.py | 42 +-- src/anthropic/resources/beta/dreams.py | 13 - .../resources/beta/messages/messages.py | 50 +-- src/anthropic/resources/beta/user_profiles.py | 12 +- src/anthropic/resources/messages/messages.py | 34 +- src/anthropic/types/beta/__init__.py | 8 - src/anthropic/types/beta/beta_dream.py | 10 +- .../beta/beta_dream_memory_store_input.py | 5 +- .../beta_dream_memory_store_input_param.py | 5 +- .../types/beta/beta_output_behavior.py | 14 - .../beta/beta_output_behavior_create_new.py | 15 - .../beta_output_behavior_create_new_param.py | 15 - .../types/beta/beta_output_behavior_param.py | 13 - .../beta_output_behavior_update_existing.py | 17 - ...a_output_behavior_update_existing_param.py | 17 - src/anthropic/types/beta/beta_user_profile.py | 2 +- .../types/beta/dream_create_params.py | 8 - .../types/beta/user_profile_create_params.py | 7 +- tests/api_resources/beta/test_dreams.py | 2 - tests/lib/environments/test_poller.py | 26 -- tests/lib/environments/test_poller_method.py | 45 +-- tests/lib/environments/test_worker.py | 110 +----- .../fixtures/context_management_response.txt | 17 - .../message_delta_fields_response.txt | 17 - .../message_delta_omitted_usage_response.txt | 17 - .../fixtures/missing_usage_response.txt | 18 + .../fixtures/server_tool_use_response.txt | 56 --- .../tool_use_invalid_json_response.txt | 44 --- tests/lib/streaming/test_beta_messages.py | 202 +---------- tests/lib/streaming/test_messages.py | 284 ++------------- tests/lib/test_aws.py | 27 +- tests/lib/test_aws_auth.py | 43 +-- tests/lib/test_bedrock.py | 132 +------ tests/lib/test_bedrock_mantle.py | 162 ++++----- tests/lib/test_credentials.py | 333 +----------------- tests/lib/test_vertex.py | 122 +------ tests/lib/tools/test_agent_toolset.py | 174 --------- tests/lib/tools/test_mcp_tool.py | 20 +- tests/lib/tools/test_skills.py | 176 --------- tests/test_client.py | 119 ++----- tests/test_files.py | 19 - tests/test_middleware.py | 59 +--- uv.lock | 184 +++++++++- 72 files changed, 680 insertions(+), 2676 deletions(-) delete mode 100644 src/anthropic/types/beta/beta_output_behavior.py delete mode 100644 src/anthropic/types/beta/beta_output_behavior_create_new.py delete mode 100644 src/anthropic/types/beta/beta_output_behavior_create_new_param.py delete mode 100644 src/anthropic/types/beta/beta_output_behavior_param.py delete mode 100644 src/anthropic/types/beta/beta_output_behavior_update_existing.py delete mode 100644 src/anthropic/types/beta/beta_output_behavior_update_existing_param.py delete mode 100644 tests/lib/streaming/fixtures/context_management_response.txt delete mode 100644 tests/lib/streaming/fixtures/message_delta_fields_response.txt delete mode 100644 tests/lib/streaming/fixtures/message_delta_omitted_usage_response.txt create mode 100644 tests/lib/streaming/fixtures/missing_usage_response.txt delete mode 100644 tests/lib/streaming/fixtures/server_tool_use_response.txt delete mode 100644 tests/lib/streaming/fixtures/tool_use_invalid_json_response.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c8b3d428..9017f58b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,7 @@ jobs: detect_breaking_changes_vs_main: timeout-minutes: 10 name: detect-breaking-changes-vs-main - runs-on: 'ubuntu-latest' + runs-on: ${{ github.repository == 'stainless-sdks/anthropic-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: |- (github.event_name == 'push' && !startsWith(github.ref, 'refs/heads/release-please--')) || diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bd115b203..4bab99674 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.122.0" + ".": "0.121.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 1d0a5234a..305cd6e99 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 131 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-086fd8de69e181b730041d853827045c2df13e50b16ea2e1d4cb97b793d90caf.yml -openapi_spec_hash: 05ee4e941a73b5900f570ec1a289489f -config_hash: 3e403f0b9f4dde5482146cfe5385a7a1 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-891ba7f96c3771e1e3ba6cb37fe8cb6d8615b8a06b6c435d9df66f4aad144bb4.yml +openapi_spec_hash: ee61c0ee103fce12d7b9a73ec21762c0 +config_hash: c07d90e7ac3e3b26d1d4b41323cd0428 diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a44952f..a0db2aaf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,54 +1,5 @@ # Changelog -## 0.122.0 (2026-08-13) - -Full Changelog: [v0.121.0...v0.122.0](https://github.com/anthropics/anthropic-sdk-python/compare/v0.121.0...v0.122.0) - -### Features - -* **api:** add output_behavior to dream creation (create a new memory store or update the input store in place) ([852c4bb](https://github.com/anthropics/anthropic-sdk-python/commit/852c4bbe4a3a425a8780e89ea6c3cae54836e8bb)) - - -### Bug Fixes - -* **bedrock,aws:** run SigV4 signing off the event loop in async clients ([#334](https://github.com/anthropics/anthropic-sdk-python/issues/334)) ([2bae6c8](https://github.com/anthropics/anthropic-sdk-python/commit/2bae6c8cb86f693f4e1e3dd13bb64e03b01fe720)) -* **bedrock:** expose beta.messages.parse, stream and tool_runner ([#366](https://github.com/anthropics/anthropic-sdk-python/issues/366)) ([6eca7bb](https://github.com/anthropics/anthropic-sdk-python/commit/6eca7bb19f968856b5652d5471e3ca9fc3fe8641)) -* **client:** add models ([52e9d94](https://github.com/anthropics/anthropic-sdk-python/commit/52e9d9453a46a281846f9860e742bfc938bafebc)) -* **client:** keep token exchange bound per client across copy() ([#388](https://github.com/anthropics/anthropic-sdk-python/issues/388)) ([c13e6e3](https://github.com/anthropics/anthropic-sdk-python/commit/c13e6e30b75d73b9af51468ae0deae6e6aca1ba0)) -* **client:** read PathLike contents passed inside a file tuple ([070f953](https://github.com/anthropics/anthropic-sdk-python/commit/070f95332daea9dc3ed19fe91251d6e5285e5560)) -* **client:** treat empty ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN as unset ([#341](https://github.com/anthropics/anthropic-sdk-python/issues/341)) ([76a2e68](https://github.com/anthropics/anthropic-sdk-python/commit/76a2e68531d50c9043a5ac58919527287e56a842)) -* **streaming:** add context to malformed tool input JSON errors in the non-beta accumulator ([#339](https://github.com/anthropics/anthropic-sdk-python/issues/339)) ([a343e17](https://github.com/anthropics/anthropic-sdk-python/commit/a343e17b7bc970f656fa02a980b0baa2bf8c3b80)) -* **streaming:** apply all message_delta fields when accumulating streamed messages ([#380](https://github.com/anthropics/anthropic-sdk-python/issues/380)) ([fc1599b](https://github.com/anthropics/anthropic-sdk-python/commit/fc1599bd4c25ad5550d55f8f1c8f5c5664e19ed1)) -* **streaming:** emit input_json events for server tool use blocks ([#336](https://github.com/anthropics/anthropic-sdk-python/issues/336)) ([ccfc8e1](https://github.com/anthropics/anthropic-sdk-python/commit/ccfc8e140e8e6b824e74c8fd9bed2587e60c5b6f)) -* **streaming:** keep omitted content block fields unset in accumulated messages ([#346](https://github.com/anthropics/anthropic-sdk-python/issues/346)) ([cd40aab](https://github.com/anthropics/anthropic-sdk-python/commit/cd40aab995181aa44e9fbdb7b6ce1fa82357c18b)) -* **streaming:** run the request transform once in messages.stream() ([#347](https://github.com/anthropics/anthropic-sdk-python/issues/347)) ([81a92da](https://github.com/anthropics/anthropic-sdk-python/commit/81a92dafa2efd87c850950d7d84af727d01c2e47)) -* **streaming:** silence pydantic serializer warnings on message_stop events ([#338](https://github.com/anthropics/anthropic-sdk-python/issues/338)) ([41f9cdc](https://github.com/anthropics/anthropic-sdk-python/commit/41f9cdcac36d4c6a3d9d2c8becaa632c75a44054)) -* **tools:** reject symlink loops in tool paths and skip special skill-archive members ([#322](https://github.com/anthropics/anthropic-sdk-python/issues/322)) ([43e8669](https://github.com/anthropics/anthropic-sdk-python/commit/43e8669d066a1d18ffdbff505fc99ac45aa7492e)) -* **vertex:** expose beta.messages.parse and tool_runner ([#367](https://github.com/anthropics/anthropic-sdk-python/issues/367)) ([96723a0](https://github.com/anthropics/anthropic-sdk-python/commit/96723a000b6179f0bee51a0b5aa0129e360dc323)) - - -### Chores - -* **ci:** run breaking-change detection as a ci.yml job on every push ([6dfd16e](https://github.com/anthropics/anthropic-sdk-python/commit/6dfd16ea7a650dc65253c72264cbcb14db696c91)) -* **examples:** update messages_stream.py shebang from rye to uv ([#1519](https://github.com/anthropics/anthropic-sdk-python/issues/1519)) ([7d72364](https://github.com/anthropics/anthropic-sdk-python/commit/7d7236432934073a5c83f4eca021d35ea5bbd7c8)) -* **internal:** codegen related update ([21a0e3d](https://github.com/anthropics/anthropic-sdk-python/commit/21a0e3d46ea0c3842cbcb92de32876727595b1f4)) -* **internal:** drop remaining references to black ([13df390](https://github.com/anthropics/anthropic-sdk-python/commit/13df3907629ac68627a13944e49ba1d12c2fdc2b)) -* **internal:** remove misc things ([d6ee99e](https://github.com/anthropics/anthropic-sdk-python/commit/d6ee99e8b90f3921f74a2768748f0eea912c1dfd)) -* **tests:** use pytest monkeypatch and fakes instead of unittest.mock ([55d246f](https://github.com/anthropics/anthropic-sdk-python/commit/55d246f0e55e3f51de17d53d7974ea0ea01c5a9b)) -* **tests:** use pytest monkeypatch instead of unittest.mock ([e0c6488](https://github.com/anthropics/anthropic-sdk-python/commit/e0c64881c24291b77e2b0653994ac9e3c696ab00)) - - -### Documentation - -* **api:** clarify that user profile name is optional for resold profiles ([b8f9f61](https://github.com/anthropics/anthropic-sdk-python/commit/b8f9f6177cf7b49e75e2c0395eed4d21c5efb416)) -* fix fulfillment typos in session runner ([#1795](https://github.com/anthropics/anthropic-sdk-python/issues/1795)) ([009b035](https://github.com/anthropics/anthropic-sdk-python/commit/009b035305e0724ce108ebd796935f91711fc6e1)) -* fix incorrect docstring in AsyncAnthropicFoundry.models ([#1592](https://github.com/anthropics/anthropic-sdk-python/issues/1592)) ([f1f5e9b](https://github.com/anthropics/anthropic-sdk-python/commit/f1f5e9b9170ed04833d7c4ec0ae003978ecc19a1)) - - -### Refactors - -* use the not_given singleton instead of the legacy NOT_GIVEN alias ([eeb4d74](https://github.com/anthropics/anthropic-sdk-python/commit/eeb4d74f442678233e34af9490bae1fabaccee64)) - ## 0.121.0 (2026-08-07) Full Changelog: [v0.120.2...v0.121.0](https://github.com/anthropics/anthropic-sdk-python/compare/v0.120.2...v0.121.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a71b17fe..19815a85b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,7 +121,8 @@ To refresh these snapshots, run the tests with the `--http-record` flag: ## Linting and formatting -This repository uses [ruff](https://github.com/astral-sh/ruff) to lint and format the code in the repository. +This repository uses [ruff](https://github.com/astral-sh/ruff) and +[black](https://github.com/psf/black) to format the code in the repository. To lint: diff --git a/api.md b/api.md index df1523f2f..22b2ebde2 100644 --- a/api.md +++ b/api.md @@ -1268,9 +1268,6 @@ from anthropic.types.beta import ( BetaDreamSessionsInput, BetaDreamStatus, BetaDreamUsage, - BetaOutputBehavior, - BetaOutputBehaviorCreateNew, - BetaOutputBehaviorUpdateExisting, ) ``` diff --git a/examples/messages_stream.py b/examples/messages_stream.py index 943a1ea46..7a1a1868b 100755 --- a/examples/messages_stream.py +++ b/examples/messages_stream.py @@ -1,4 +1,4 @@ -#!/usr/bin/env -S uv run python +#!/usr/bin/env -S rye run python import asyncio diff --git a/pyproject.toml b/pyproject.toml index 31969ec2b..5207f53b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anthropic" -version = "0.122.0" +version = "0.121.0" description = "The official Python library for the anthropic API" dynamic = ["readme"] license = "MIT" @@ -118,6 +118,7 @@ include = [ "/*.lock", "/*.md", "/mypy.ini", + "/noxfile.py", "bin/*", "examples/*", "src/*", @@ -159,6 +160,7 @@ exclude = [ ".git", "_dev", ".venv", + ".nox", "examples/mcp_tool_runner.py", # mcp requires Python 3.10+, lint runs on 3.9 ] @@ -213,6 +215,12 @@ cache_fine_grained = true # and would not be worth it. disable_error_code = "func-returns-value,overload-cannot-match" +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "anthropic.lib.vertex._auth" disallow_untyped_calls = false diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py index 9bbb53bb5..0cf2bd2fd 100644 --- a/scripts/utils/ruffen-docs.py +++ b/scripts/utils/ruffen-docs.py @@ -63,7 +63,7 @@ def finish_fragment() -> None: fragment_lines = fragment.splitlines() code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" for line in fragment_lines[1:]: - # Skip blank lines to handle the formatter adding a blank above + # Skip blank lines to handle Black adding a blank above # functions within blocks. A blank line would end the REPL # continuation prompt. # diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 915086cfd..7e37147cd 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -44,7 +44,6 @@ AccessTokenAuth, CredentialsFile, AccessTokenProvider, - BaseURLBoundProvider, default_credentials, ) from .lib.credentials._auth import ( @@ -71,24 +70,18 @@ def _close_credentials(credentials: object) -> None: close() -def _bind_credentials_base_url(credentials: AccessTokenProvider | None, base_url: str) -> AccessTokenProvider | None: - """Return the provider this client should exchange tokens through. +def _bind_credentials_base_url(credentials: AccessTokenProvider | None, base_url: str) -> None: + """If the credential provider supports ``bind_base_url``, pass it the + client's resolved ``base_url`` so the token exchange and API calls hit + the same deployment without the caller passing the URL twice. - See :class:`BaseURLBoundProvider`; any other provider (plain callables, - custom impls) is returned untouched and resolves its own exchange URL. + Providers without the hook (plain callables, custom impls) are left + untouched and MUST resolve their own token-exchange ``base_url`` — the + client does not second-guess them. """ - if isinstance(credentials, BaseURLBoundProvider): - return credentials.for_base_url(base_url) - return credentials - - -def _keeps_base_url(current: httpx.URL, requested: str | httpx.URL | None) -> bool: - """Whether a ``copy()`` stays on the parent's deployment. - - Tokens are only valid for the deployment that minted them, so the parent's - :class:`TokenCache` is only shared in that case. - """ - return requested is None or str(httpx.URL(requested)).rstrip("/") == str(current).rstrip("/") + bind = getattr(credentials, "bind_base_url", None) + if callable(bind): + bind(base_url) def _warn_explicit_shadow(*, api_key: str | None, auth_token: str | None, credentials: object) -> None: @@ -218,8 +211,8 @@ def __init__( or profile is not None ) if not has_explicit_credential: - api_key = os.environ.get("ANTHROPIC_API_KEY") or None - auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") or None + api_key = os.environ.get("ANTHROPIC_API_KEY") + auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") self.api_key = api_key self.auth_token = auth_token # --- end credentials support --- @@ -272,7 +265,7 @@ def __init__( credential_headers = result.extra_headers if not base_url_is_explicit and result.base_url: base_url = result.base_url - credentials = _bind_credentials_base_url(credentials, str(base_url)) + _bind_credentials_base_url(credentials, str(base_url)) self.credentials = credentials _warn_explicit_shadow(api_key=api_key, auth_token=auth_token, credentials=credentials) if _is_base_client(self): @@ -483,10 +476,10 @@ def copy( resolved_credentials = self.credentials if isinstance(credentials, NotGiven) else credentials if resolved_credentials is not None and _is_base_client(self): _extra_kwargs = {"credentials": resolved_credentials, **_extra_kwargs} - # Share the parent's TokenCache only while the provider and - # deployment are unchanged; a new credentials= or base_url= gets - # its own cache. - if isinstance(credentials, NotGiven) and _keeps_base_url(self.base_url, base_url): + # Reuse the parent's TokenCache when the credentials provider is + # unchanged so with_options() copies don't trigger an independent + # token exchange. A new credentials= gets a fresh cache. + if isinstance(credentials, NotGiven): _extra_kwargs = {"_token_cache": self._token_cache, **_extra_kwargs} # --- end credentials support --- return self.__class__( @@ -638,8 +631,8 @@ def __init__( or profile is not None ) if not has_explicit_credential: - api_key = os.environ.get("ANTHROPIC_API_KEY") or None - auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") or None + api_key = os.environ.get("ANTHROPIC_API_KEY") + auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") self.api_key = api_key self.auth_token = auth_token # --- end credentials support --- @@ -692,7 +685,7 @@ def __init__( credential_headers = result.extra_headers if not base_url_is_explicit and result.base_url: base_url = result.base_url - credentials = _bind_credentials_base_url(credentials, str(base_url)) + _bind_credentials_base_url(credentials, str(base_url)) self.credentials = credentials _warn_explicit_shadow(api_key=api_key, auth_token=auth_token, credentials=credentials) if _is_base_client(self): @@ -899,10 +892,10 @@ def copy( resolved_credentials = self.credentials if isinstance(credentials, NotGiven) else credentials if resolved_credentials is not None and _is_base_client(self): _extra_kwargs = {"credentials": resolved_credentials, **_extra_kwargs} - # Share the parent's TokenCache only while the provider and - # deployment are unchanged; a new credentials= or base_url= gets - # its own cache. - if isinstance(credentials, NotGiven) and _keeps_base_url(self.base_url, base_url): + # Reuse the parent's TokenCache when the credentials provider is + # unchanged so with_options() copies don't trigger an independent + # token exchange. A new credentials= gets a fresh cache. + if isinstance(credentials, NotGiven): _extra_kwargs = {"_token_cache": self._token_cache, **_extra_kwargs} # --- end credentials support --- return self.__class__( diff --git a/src/anthropic/_files.py b/src/anthropic/_files.py index 5232ee2e4..42cfbeb99 100644 --- a/src/anthropic/_files.py +++ b/src/anthropic/_files.py @@ -27,11 +27,13 @@ def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: def is_file_content(obj: object) -> TypeGuard[FileContent]: - return isinstance(obj, bytes) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + return ( + isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + ) def assert_is_file_content(obj: object, *, key: str | None = None) -> None: - if not is_file_content(obj) and not is_tuple_t(obj): + if not is_file_content(obj): prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" raise RuntimeError( f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/anthropics/anthropic-sdk-python/tree/main#file-uploads" diff --git a/src/anthropic/_version.py b/src/anthropic/_version.py index 4062d7d8d..94b5e1f32 100644 --- a/src/anthropic/_version.py +++ b/src/anthropic/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "anthropic" -__version__ = "0.122.0" # x-release-please-version +__version__ = "0.121.0" # x-release-please-version diff --git a/src/anthropic/lib/aws/_client.py b/src/anthropic/lib/aws/_client.py index 4bc1a42bd..6b88ffe18 100644 --- a/src/anthropic/lib/aws/_client.py +++ b/src/anthropic/lib/aws/_client.py @@ -5,8 +5,7 @@ import httpx -from ..._types import Omit, Headers, Timeout, NotGiven, not_given -from ..._utils import asyncify +from ..._types import NOT_GIVEN, Omit, Headers, Timeout, NotGiven from ..._client import Anthropic, AsyncAnthropic from ._credentials import ( resolve_region, @@ -44,7 +43,7 @@ def __init__( workspace_id: str | None = None, skip_auth: bool = False, base_url: str | httpx.URL | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + 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, @@ -181,14 +180,14 @@ def copy( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodO credentials: AccessTokenProvider | None = None, webhook_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: # The AWS client authenticates with SigV4 (or an API key), not a token @@ -252,7 +251,7 @@ def __init__( workspace_id: str | None = None, skip_auth: bool = False, base_url: str | httpx.URL | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + 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, @@ -359,7 +358,7 @@ async def _prepare_request(self, request: httpx.Request) -> None: data = request.read().decode() - headers = await asyncify(get_auth_headers)( + headers = get_auth_headers( method=request.method, url=str(request.url), headers=request.headers, @@ -389,14 +388,14 @@ def copy( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodO credentials: AccessTokenProvider | None = None, webhook_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: # The AWS client authenticates with SigV4 (or an API key), not a token diff --git a/src/anthropic/lib/bedrock/_beta_messages.py b/src/anthropic/lib/bedrock/_beta_messages.py index 0fbfbce00..332f6fbab 100644 --- a/src/anthropic/lib/bedrock/_beta_messages.py +++ b/src/anthropic/lib/bedrock/_beta_messages.py @@ -13,9 +13,6 @@ class Messages(SyncAPIResource): create = FirstPartyMessagesAPI.create - parse = FirstPartyMessagesAPI.parse - stream = FirstPartyMessagesAPI.stream - tool_runner = FirstPartyMessagesAPI.tool_runner @cached_property def with_raw_response(self) -> MessagesWithRawResponse: @@ -39,9 +36,6 @@ def with_streaming_response(self) -> MessagesWithStreamingResponse: class AsyncMessages(AsyncAPIResource): create = FirstPartyAsyncMessagesAPI.create - parse = FirstPartyAsyncMessagesAPI.parse - stream = FirstPartyAsyncMessagesAPI.stream - tool_runner = FirstPartyAsyncMessagesAPI.tool_runner @cached_property def with_raw_response(self) -> AsyncMessagesWithRawResponse: diff --git a/src/anthropic/lib/bedrock/_client.py b/src/anthropic/lib/bedrock/_client.py index 8402393eb..e91ca176c 100644 --- a/src/anthropic/lib/bedrock/_client.py +++ b/src/anthropic/lib/bedrock/_client.py @@ -10,8 +10,8 @@ from ... import _exceptions from ._beta import Beta, AsyncBeta -from ..._types import Timeout, NotGiven, not_given -from ..._utils import is_dict, asyncify, is_given +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 @@ -144,7 +144,7 @@ def __init__( 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, + 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, @@ -247,14 +247,14 @@ def copy( 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, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ @@ -324,7 +324,7 @@ def __init__( 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, + 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, @@ -405,7 +405,7 @@ async def _prepare_request(self, request: httpx.Request) -> None: data = request.read().decode() - headers = await asyncify(get_auth_headers)( + headers = get_auth_headers( method=request.method, url=str(request.url), headers=request.headers, @@ -427,14 +427,14 @@ def copy( 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, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ diff --git a/src/anthropic/lib/bedrock/_mantle.py b/src/anthropic/lib/bedrock/_mantle.py index 3a506e66b..e4dad6c1d 100644 --- a/src/anthropic/lib/bedrock/_mantle.py +++ b/src/anthropic/lib/bedrock/_mantle.py @@ -8,8 +8,8 @@ from ... import _exceptions from ..._qs import Querystring -from ..._types import Omit, Timeout, NotGiven, not_given -from ..._utils import asyncify, is_given +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 @@ -185,7 +185,7 @@ def __init__( api_key: str | None = None, skip_auth: bool = False, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -291,14 +291,14 @@ def copy( aws_profile: str | None = None, skip_auth: bool | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ @@ -381,7 +381,7 @@ def __init__( api_key: str | None = None, skip_auth: bool = False, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -462,7 +462,7 @@ async def _prepare_request(self, request: httpx.Request) -> None: data = request.read().decode() - headers = await asyncify(get_auth_headers)( + headers = get_auth_headers( method=request.method, url=str(request.url), headers=request.headers, @@ -487,14 +487,14 @@ def copy( aws_profile: str | None = None, skip_auth: bool | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ diff --git a/src/anthropic/lib/credentials/__init__.py b/src/anthropic/lib/credentials/__init__.py index ba5758143..e3c2507b4 100644 --- a/src/anthropic/lib/credentials/__init__.py +++ b/src/anthropic/lib/credentials/__init__.py @@ -5,7 +5,6 @@ AccessToken as AccessToken, CredentialResult as CredentialResult, AccessTokenProvider as AccessTokenProvider, - BaseURLBoundProvider as BaseURLBoundProvider, IdentityTokenProvider as IdentityTokenProvider, ) from ._workload import ( @@ -24,7 +23,6 @@ __all__ = [ "AccessToken", "AccessTokenProvider", - "BaseURLBoundProvider", "CredentialResult", "IdentityTokenProvider", "StaticToken", diff --git a/src/anthropic/lib/credentials/_providers.py b/src/anthropic/lib/credentials/_providers.py index e24500d38..40a30c81b 100644 --- a/src/anthropic/lib/credentials/_providers.py +++ b/src/anthropic/lib/credentials/_providers.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import copy import json import stat import time @@ -231,8 +230,10 @@ def bind_base_url(self, base_url: str) -> None: exchange. Slots between the config file's own ``base_url`` field and the hard-coded default; a ``base_url`` in the config file still wins. - Rebinding affects every client holding this instance; clients bind - through :meth:`for_base_url` instead. + The owning client binds exactly once at construction; sharing one + instance across clients with different ``base_url`` values is + unsupported and silently picks the last bind when the config file + doesn't pin a host. """ bound = base_url.rstrip("/") # Validate eagerly so an invalid bind fails at bind time, not at the @@ -243,35 +244,6 @@ def bind_base_url(self, base_url: str) -> None: self._base_url = self._resolve_base_url(self._config) _require_https(self._base_url, field=f"{self._config_path}: base_url") - def for_base_url(self, base_url: str) -> "CredentialsFile": - """Return the provider a client with ``base_url`` should exchange through. - - Binds in place, unless another client already bound this instance to a - different host (e.g. the parent of ``copy(base_url=...)``). That binding - is left alone, and what happens depends on the profile: - - * pins its own ``base_url``: the bind is irrelevant, return ``self``. - * ``oidc_federation``: return a copy bound to ``base_url``. It shares the - identity token and http client, but not the on-disk token cache, whose - tokens belong to the original deployment. - * ``user_oauth``: return ``self``. The refresh token is tied to the - deployment that issued it, so there is nothing per-host to copy. - """ - bound = base_url.rstrip("/") - if self._bound_base_url is None or self._bound_base_url == bound: - self.bind_base_url(bound) - return self - config = self._load_config() - if config.get("base_url") or self._auth_block().get("type") != AUTH_TYPE_OIDC_FEDERATION: - return self - provider = copy.copy(self) - provider._http_client = self._get_http_client() - provider._owned_http_client = None - provider._workload_delegate = None - provider._credentials_path = None - provider.bind_base_url(bound) - return provider - def _resolve_base_url(self, config: Dict[str, Any]) -> str: """base_url precedence: top-level config field → bound (the owning client's base_url, via :meth:`bind_base_url`) → default. Validated diff --git a/src/anthropic/lib/credentials/_types.py b/src/anthropic/lib/credentials/_types.py index 2dfe0593c..4574d42bc 100644 --- a/src/anthropic/lib/credentials/_types.py +++ b/src/anthropic/lib/credentials/_types.py @@ -2,20 +2,14 @@ from typing import Dict, Callable, Optional, Protocol from dataclasses import field, dataclass -from typing_extensions import override, runtime_checkable +from typing_extensions import override def _empty_headers() -> Dict[str, str]: return {} -__all__ = [ - "AccessToken", - "AccessTokenProvider", - "BaseURLBoundProvider", - "IdentityTokenProvider", - "CredentialResult", -] +__all__ = ["AccessToken", "AccessTokenProvider", "IdentityTokenProvider", "CredentialResult"] @dataclass(frozen=True) @@ -59,25 +53,6 @@ class AccessTokenProvider(Protocol): def __call__(self, *, force_refresh: bool = False) -> AccessToken: ... -@runtime_checkable -class BaseURLBoundProvider(AccessTokenProvider, Protocol): - """A provider whose token exchange targets a specific deployment. - - The client calls :meth:`for_base_url` with its own ``base_url`` at - construction. Providers that don't implement this resolve their own - exchange endpoint and are used as-is. - """ - - def for_base_url(self, base_url: str) -> AccessTokenProvider: - """Return the provider a client with ``base_url`` should exchange through. - - Either ``self``, bound to ``base_url``, or — if another client already - bound ``self`` to a different deployment — a separate provider bound to - ``base_url``, so that client's binding is left alone. - """ - ... - - # Innermost layer: returns the raw external JWT string (used as the # ``identity_token_provider`` argument to :class:`WorkloadIdentityCredentials`). IdentityTokenProvider = Callable[[], str] diff --git a/src/anthropic/lib/credentials/_workload.py b/src/anthropic/lib/credentials/_workload.py index 914f1f9e1..b6482cbbc 100644 --- a/src/anthropic/lib/credentials/_workload.py +++ b/src/anthropic/lib/credentials/_workload.py @@ -1,6 +1,5 @@ from __future__ import annotations -import copy import time import logging from types import TracebackType @@ -181,7 +180,7 @@ def __init__( # on the jwt-bearer request. self._scope = scope # The client passing this object as ``credentials=`` calls - # :meth:`for_base_url` to set its own endpoint, so the token exchange + # :meth:`bind_base_url` to set its own endpoint, so the token exchange # and the API calls hit the same deployment. There is intentionally no # constructor kwarg for this: a token minted by one deployment is only # valid against that deployment, so splitting exchange-base from @@ -205,31 +204,14 @@ def _base_url(self) -> str: def bind_base_url(self, base_url: str) -> None: """Set the API ``base_url`` the token exchange POSTs to. - For standalone use (no client) or tests. Clients bind through - :meth:`for_base_url`, which never rebinds an instance another client - is already exchanging through. + Called by :class:`anthropic.Anthropic` when this object is passed as + ``credentials=``, so callers don't pass the same URL twice. For + standalone use (no client) or tests, call this directly. """ bound = base_url.rstrip("/") _require_https(bound, field="base_url") self._bound_base_url = bound - def for_base_url(self, base_url: str) -> "WorkloadIdentityCredentials": - """Return the provider a client with ``base_url`` should exchange through. - - Binds in place, unless another client already bound this instance to a - different host (e.g. the parent of ``copy(base_url=...)``). Rebinding - would move that client's token exchange too, so a copy bound to - ``base_url`` is returned instead; it shares the identity token and - borrows (never closes) this instance's ``httpx.Client``. - """ - bound = base_url.rstrip("/") - provider = self - if self._bound_base_url is not None and self._bound_base_url != bound: - provider = copy.copy(self) - provider._owns_http_client = False - provider.bind_base_url(bound) - return provider - def close(self) -> None: """Close the underlying ``httpx.Client`` if we created it.""" if self._owns_http_client: diff --git a/src/anthropic/lib/foundry.py b/src/anthropic/lib/foundry.py index 9a3ba47bb..a0bfea567 100644 --- a/src/anthropic/lib/foundry.py +++ b/src/anthropic/lib/foundry.py @@ -8,7 +8,7 @@ import httpx -from .._types import Omit, Headers, Timeout, NotGiven, not_given +from .._types import NOT_GIVEN, Omit, Headers, Timeout, NotGiven from .._utils import is_given from .._client import Anthropic, AsyncAnthropic from .._compat import model_copy @@ -101,7 +101,7 @@ def __init__( api_key: str | None = None, azure_ad_token_provider: AzureADTokenProvider | None = None, webhook_key: str | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -118,7 +118,7 @@ def __init__( api_key: str | None = None, azure_ad_token_provider: AzureADTokenProvider | None = None, webhook_key: str | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -135,7 +135,7 @@ def __init__( azure_ad_token_provider: AzureADTokenProvider | None = None, webhook_key: str | None = None, base_url: str | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -212,14 +212,14 @@ def copy( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodO azure_ad_token_provider: AzureADTokenProvider | None = None, webhook_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ @@ -326,7 +326,7 @@ def __init__( api_key: str | None = None, azure_ad_token_provider: AsyncAzureADTokenProvider | None = None, webhook_key: str | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -343,7 +343,7 @@ def __init__( api_key: str | None = None, azure_ad_token_provider: AsyncAzureADTokenProvider | None = None, webhook_key: str | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -360,7 +360,7 @@ def __init__( azure_ad_token_provider: AsyncAzureADTokenProvider | None = None, webhook_key: str | None = None, base_url: str | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -437,14 +437,14 @@ def copy( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodO azure_ad_token_provider: AsyncAzureADTokenProvider | None = None, webhook_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ diff --git a/src/anthropic/lib/google_cloud/_client.py b/src/anthropic/lib/google_cloud/_client.py index 6d3a7df3b..9f2783580 100644 --- a/src/anthropic/lib/google_cloud/_client.py +++ b/src/anthropic/lib/google_cloud/_client.py @@ -9,7 +9,7 @@ import httpx -from ..._types import Headers, Timeout, NotGiven, not_given +from ..._types import NOT_GIVEN, Headers, Timeout, NotGiven from ..._utils import asyncify, is_given from ..._client import Anthropic, AsyncAnthropic from ..._models import FinalRequestOptions @@ -208,7 +208,7 @@ def __init__( credentials: GoogleCredentials | None = None, skip_auth: bool = False, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -367,15 +367,15 @@ def copy( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodO *, project: str | None = None, location: str | None = None, - workspace_id: str | None | NotGiven = not_given, - token_provider: TokenProvider | None | NotGiven = not_given, - credentials: GoogleCredentials | None | NotGiven = not_given, + workspace_id: str | None | NotGiven = NOT_GIVEN, + token_provider: TokenProvider | None | NotGiven = NOT_GIVEN, + credentials: GoogleCredentials | None | NotGiven = NOT_GIVEN, skip_auth: bool | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.Client | None = None, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, - max_retries: int | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, + 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, @@ -470,7 +470,7 @@ def __init__( credentials: GoogleCredentials | None = None, skip_auth: bool = False, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + 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, @@ -606,15 +606,15 @@ def copy( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodO *, project: str | None = None, location: str | None = None, - workspace_id: str | None | NotGiven = not_given, - token_provider: AsyncTokenProvider | None | NotGiven = not_given, - credentials: GoogleCredentials | None | NotGiven = not_given, + workspace_id: str | None | NotGiven = NOT_GIVEN, + token_provider: AsyncTokenProvider | None | NotGiven = NOT_GIVEN, + credentials: GoogleCredentials | None | NotGiven = NOT_GIVEN, skip_auth: bool | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.AsyncClient | None = None, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, - max_retries: int | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, + 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, diff --git a/src/anthropic/lib/streaming/_beta_messages.py b/src/anthropic/lib/streaming/_beta_messages.py index 0f1088a59..c0f67fb54 100644 --- a/src/anthropic/lib/streaming/_beta_messages.py +++ b/src/anthropic/lib/streaming/_beta_messages.py @@ -12,7 +12,7 @@ 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 NotGiven, not_given +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 ( @@ -28,6 +28,7 @@ ) from ..._streaming import Stream, AsyncStream from ...types.beta import BetaRawMessageStreamEvent +from ...types.beta.beta_usage import BetaUsage from ..._utils._utils import is_given from .._parse._response import ResponseFormatT, parse_text from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaContentBlock @@ -312,7 +313,7 @@ def __init__( self, api_request: Awaitable[AsyncStream[BetaRawMessageStreamEvent]], *, - output_format: ResponseFormatT | NotGiven = not_given, + output_format: ResponseFormatT | NotGiven = NOT_GIVEN, ) -> None: self.__stream: BetaAsyncMessageStream[ResponseFormatT] | None = None self.__api_request = api_request @@ -365,7 +366,7 @@ def build_events( ) ) elif event.delta.type == "input_json_delta": - if isinstance(content_block, TRACKS_TOOL_INPUT): + if content_block.type == "tool_use" or content_block.type == "mcp_tool_use": events_to_fire.append( build( BetaInputJsonEvent, @@ -451,7 +452,7 @@ def accumulate_event( event: BetaRawMessageStreamEvent, current_snapshot: ParsedBetaMessage[ResponseFormatT] | None, request_headers: httpx.Headers, - output_format: ResponseFormatT | NotGiven = not_given, + output_format: ResponseFormatT | NotGiven = NOT_GIVEN, ) -> ParsedBetaMessage[ResponseFormatT]: if not isinstance(cast(Any, event), BaseModel): event = cast( # pyright: ignore[reportUnnecessaryCast] @@ -540,31 +541,30 @@ def accumulate_event( 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.stop_details = event.delta.stop_details - if event.delta.container is not None: - current_snapshot.container = event.delta.container - current_snapshot.usage.output_tokens = event.usage.output_tokens - if event.context_management is not None: - current_snapshot.context_management = event.context_management - - # Usage counts on a message_delta are cumulative totals, so they overwrite rather - # than add; optional ones are omitted when not applicable, in which case the - # message_start value must survive. - 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.output_tokens_details is not None: - current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details - if event.usage.iterations is not None: - current_snapshot.usage.iterations = event.usage.iterations - if event.usage.fallback_credit is not None: - current_snapshot.usage.fallback_credit = event.usage.fallback_credit + if event.delta.stop_details is not None: + current_snapshot.stop_details = event.delta.stop_details + if current_snapshot.usage is None: + current_snapshot.usage = BetaUsage.construct(**event.usage.model_dump()) + else: + current_snapshot.usage.output_tokens = event.usage.output_tokens + current_snapshot.context_management = event.context_management + + if current_snapshot.usage is not None: + # 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 + if event.usage.fallback_credit is not None: + current_snapshot.usage.fallback_credit = event.usage.fallback_credit return current_snapshot diff --git a/src/anthropic/lib/streaming/_beta_types.py b/src/anthropic/lib/streaming/_beta_types.py index bd1812812..8685815a0 100644 --- a/src/anthropic/lib/streaming/_beta_types.py +++ b/src/anthropic/lib/streaming/_beta_types.py @@ -88,10 +88,7 @@ class BetaCompactionEvent(BaseModel): class ParsedBetaMessageStopEvent(BetaRawMessageStopEvent, GenericModel, Generic[ResponseFormatT]): type: Literal["message_stop"] - if TYPE_CHECKING: - message: ParsedBetaMessage[ResponseFormatT] - else: - message: ParsedBetaMessage + message: ParsedBetaMessage[ResponseFormatT] class ParsedBetaContentBlockStopEvent(BetaRawContentBlockStopEvent, GenericModel, Generic[ResponseFormatT]): diff --git a/src/anthropic/lib/streaming/_messages.py b/src/anthropic/lib/streaming/_messages.py index 012746ecf..bbb9a0884 100644 --- a/src/anthropic/lib/streaming/_messages.py +++ b/src/anthropic/lib/streaming/_messages.py @@ -21,7 +21,8 @@ ParsedContentBlockStopEvent, ) from ...types import RawMessageStreamEvent -from ..._types import NotGiven, not_given +from ...types.usage import Usage +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 @@ -307,7 +308,7 @@ def __init__( self, api_request: Awaitable[AsyncStream[RawMessageStreamEvent]], *, - output_format: ResponseFormatT | NotGiven = not_given, + output_format: ResponseFormatT | NotGiven = NOT_GIVEN, ) -> None: self.__stream: AsyncMessageStream[ResponseFormatT] | None = None self.__api_request = api_request @@ -360,7 +361,7 @@ def build_events( ) ) elif event.delta.type == "input_json_delta": - if isinstance(content_block, TRACKS_TOOL_INPUT): + if content_block.type == "tool_use": events_to_fire.append( build( InputJsonEvent, @@ -434,7 +435,7 @@ def accumulate_event( *, event: RawMessageStreamEvent, current_snapshot: ParsedMessage[ResponseFormatT] | None, - output_format: ResponseFormatT | NotGiven = not_given, + output_format: ResponseFormatT | NotGiven = NOT_GIVEN, ) -> ParsedMessage[ResponseFormatT]: if not isinstance(cast(Any, event), BaseModel): event = cast( # pyright: ignore[reportUnnecessaryCast] @@ -458,7 +459,7 @@ def accumulate_event( current_snapshot.content.append( cast( Any, # Pydantic does not support generic unions at runtime - construct_type(type_=ParsedContentBlock, value=event.content_block.to_dict()), + construct_type(type_=ParsedContentBlock, value=event.content_block.model_dump()), ), ) elif event.type == "content_block_delta": @@ -477,12 +478,7 @@ def accumulate_event( json_buf += bytes(event.delta.partial_json, "utf-8") if json_buf: - try: - 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 + content.input = from_json(json_buf, partial_mode=True) setattr(content, JSON_BUF_PROPERTY, json_buf) elif event.delta.type == "citations_delta": @@ -508,23 +504,21 @@ def accumulate_event( elif event.type == "message_delta": current_snapshot.stop_reason = event.delta.stop_reason current_snapshot.stop_sequence = event.delta.stop_sequence - current_snapshot.stop_details = event.delta.stop_details - if event.delta.container is not None: - current_snapshot.container = event.delta.container - current_snapshot.usage.output_tokens = event.usage.output_tokens - - # Usage counts on a message_delta are cumulative totals, so they overwrite rather - # than add; optional ones are omitted when not applicable, in which case the - # message_start value must survive. - 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.output_tokens_details is not None: - current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details + if event.delta.stop_details is not None: + current_snapshot.stop_details = event.delta.stop_details + if current_snapshot.usage is None: + current_snapshot.usage = Usage.construct(**event.usage.model_dump()) + else: + 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 diff --git a/src/anthropic/lib/streaming/_types.py b/src/anthropic/lib/streaming/_types.py index 8e4a1e404..7399e76f4 100644 --- a/src/anthropic/lib/streaming/_types.py +++ b/src/anthropic/lib/streaming/_types.py @@ -110,10 +110,7 @@ class ContentBlockStopEvent(RawContentBlockStopEvent): class ParsedMessageStopEvent(RawMessageStopEvent, GenericModel, Generic[ResponseFormatT]): type: Literal["message_stop"] - if TYPE_CHECKING: - message: ParsedMessage[ResponseFormatT] - else: - message: ParsedMessage + message: ParsedMessage[ResponseFormatT] class ParsedContentBlockStopEvent(RawContentBlockStopEvent, GenericModel, Generic[ResponseFormatT]): diff --git a/src/anthropic/lib/tools/_skills.py b/src/anthropic/lib/tools/_skills.py index 38ebafa42..f706c8c5c 100644 --- a/src/anthropic/lib/tools/_skills.py +++ b/src/anthropic/lib/tools/_skills.py @@ -8,7 +8,6 @@ from __future__ import annotations import os -import stat import shutil import logging import tarfile @@ -17,7 +16,6 @@ from typing import TYPE_CHECKING from pathlib import Path, PurePosixPath from functools import partial -from collections.abc import Iterable import anyio from anyio.to_thread import run_sync @@ -81,36 +79,6 @@ def _archive_top_dir(names: list[str]) -> str: return next(iter(tops)) if len(tops) == 1 and has_nested else "" -def _wrapper_dir(all_names: Iterable[str], plain_names: Iterable[str]) -> str: - """Screen every member name (raising on path traversal, extracted or not), - then return the wrapper directory shared by the members that will be - extracted.""" - for name in all_names: - _safe_member_name(name) - return _archive_top_dir([s for n in plain_names if (s := _safe_member_name(n))]) - - -# Zip creator hosts whose ``external_attr`` high bits are a Unix ``st_mode``. -_ZIP_UNIX_HOSTS = (3, 19) -_SPECIAL_FILE_TYPES = (stat.S_IFLNK, stat.S_IFCHR, stat.S_IFBLK, stat.S_IFIFO, stat.S_IFSOCK) - - -def _zip_unix_mode(info: zipfile.ZipInfo) -> int | None: - return info.external_attr >> 16 if info.create_system in _ZIP_UNIX_HOSTS else None - - -def _zip_info_is_special(info: zipfile.ZipInfo) -> bool: - """True for a Unix-host entry recorded as a symlink, device, FIFO or socket. - Type bits from other hosts are not a mode, so those entries stay plain files.""" - mode = _zip_unix_mode(info) - return mode is not None and stat.S_IFMT(mode) in _SPECIAL_FILE_TYPES - - -def _zip_info_is_dir(info: zipfile.ZipInfo) -> bool: - mode = _zip_unix_mode(info) - return info.is_dir() or (mode is not None and stat.S_ISDIR(mode)) - - def _strip_top(safe: str, top: str) -> str: """Drop the leading ``top`` component from ``safe`` (an already-confined relative path). Returns ``""`` for the bare top-dir entry itself.""" @@ -144,8 +112,7 @@ def _extract_skill_archive(archive_path: Path, dest: Path) -> None: wrapper is stripped so files land directly under ``dest`` rather than a redundant ``dest//`` level. Skills can be third-party, so this refuses any member that would escape ``dest`` (zip-slip / tar-slip) and - skips any member that is not a regular file or directory (symlink, - hardlink, device, FIFO), in zip and tar archives alike. + skips symlink/hardlink/device members in tar archives. """ dest.mkdir(parents=True, exist_ok=True, mode=_SKILL_DIR_MODE) root = dest.resolve() @@ -153,16 +120,18 @@ def _extract_skill_archive(archive_path: Path, dest: Path) -> None: if zipfile.is_zipfile(archive_path): with zipfile.ZipFile(archive_path) as zf: infos = zf.infolist() - plain = [info for info in infos if not _zip_info_is_special(info)] - top = _wrapper_dir((info.filename for info in infos), (info.filename for info in plain)) - for info in plain: + # Compute the wrapper dir from the same confined names the loop + # uses, so a malicious name still raises before anything is written. + safe_names = [s for info in infos if (s := _safe_member_name(info.filename))] + top = _archive_top_dir(safe_names) + for info in infos: safe = _strip_top(_safe_member_name(info.filename), top) if not safe: continue target = (root / safe).resolve() if not _within(target, root): raise ValueError(f"refusing to extract unsafe zip member {info.filename!r}") - if _zip_info_is_dir(info): + if info.is_dir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) @@ -175,9 +144,9 @@ def _extract_skill_archive(archive_path: Path, dest: Path) -> None: # tarfile.open with "r:*" transparently handles tar / tar.gz / tar.bz2 / tar.xz. with tarfile.open(archive_path, mode="r:*") as tf: - all_members = tf.getmembers() - members = [m for m in all_members if m.isreg() or m.isdir()] - top = _wrapper_dir((m.name for m in all_members), (m.name for m in members)) + members = [m for m in tf.getmembers() if not (m.issym() or m.islnk() or m.isdev())] + safe_names = [s for m in members if (s := _safe_member_name(m.name))] + top = _archive_top_dir(safe_names) for member in members: safe = _strip_top(_safe_member_name(member.name), top) if not safe: diff --git a/src/anthropic/lib/tools/agent_toolset.py b/src/anthropic/lib/tools/agent_toolset.py index d1787aad2..9efbc0501 100644 --- a/src/anthropic/lib/tools/agent_toolset.py +++ b/src/anthropic/lib/tools/agent_toolset.py @@ -36,19 +36,18 @@ import os import re import uuid -import errno import base64 import shutil import logging import subprocess -from stat import S_ISLNK, S_ISREG +from stat import S_ISREG from typing import TYPE_CHECKING, Any, List, Optional, NamedTuple, cast from pathlib import Path, PurePosixPath from functools import partial from itertools import islice from contextlib import asynccontextmanager from dataclasses import field, dataclass -from collections.abc import Mapping, Callable, Iterable, Iterator, Awaitable, AsyncIterator +from collections.abc import Mapping, Callable, Awaitable, AsyncIterator import anyio import anyio.abc @@ -157,30 +156,25 @@ def _default_bash_env() -> dict[str, str]: return {k: v for k, v in os.environ.items() if not k.startswith("ANTHROPIC_")} -def _fs_reason(e: OSError) -> str: - """Map a filesystem ``OSError`` to a consistent, runtime-independent phrase. +def _fs_error(op: str, file_path: str, e: OSError) -> ToolError: + """Map a filesystem ``OSError`` to a consistent, runtime-independent message. - The raw ``OSError`` string is platform-specific (``[Errno 2] ENOENT: ...``) - and can embed a host path; normalise the common cases so the model sees the - same wording everywhere and never the runner's absolute paths. + The raw ``OSError`` string is platform-specific (``[Errno 2] ENOENT: ...``); + normalise the common cases so the model sees the same wording everywhere. """ if isinstance(e, FileNotFoundError): - return "no such file or directory" - if isinstance(e, NotADirectoryError): - return "not a directory" - if isinstance(e, IsADirectoryError): - return "is a directory" - if isinstance(e, PermissionError): - return "permission denied" - if isinstance(e, FileExistsError): - return "file already exists" - if e.errno == errno.ELOOP: - return "too many levels of symbolic links" - return (e.strerror or "i/o error").lower() - - -def _fs_error(op: str, file_path: str, e: OSError) -> ToolError: - return ToolError(f"{op}: {file_path}: {_fs_reason(e)}") + reason = "no such file or directory" + elif isinstance(e, NotADirectoryError): + reason = "not a directory" + elif isinstance(e, IsADirectoryError): + reason = "is a directory" + elif isinstance(e, PermissionError): + reason = "permission denied" + elif isinstance(e, FileExistsError): + reason = "file already exists" + else: + reason = (e.strerror or "i/o error").lower() + return ToolError(f"{op}: {file_path}: {reason}") def _empty_skill_dirs() -> list[Path]: @@ -308,74 +302,22 @@ async def __aexit__(self, *exc: object) -> None: await self._cleanup_skills() -_MAX_SYMLINK_HOPS = 40 -_MISSING_ERRNOS = (errno.ENOENT, errno.ENOTDIR) - - -def _symlink_loop_error() -> OSError: - return OSError(errno.ELOOP, os.strerror(errno.ELOOP)) - - -def _canonicalize(path: Path) -> Path: - """Return ``path`` with ``.``/``..`` collapsed lexically and every symlink - followed, or raise ``OSError`` — the same outcome on every supported Python, - and never a partly resolved path. - - Trailing components that do not exist yet are kept as spelled, so a new - file under new directories still canonicalises; a dangling symlink met on - the way is read and followed by hand, and more than ``_MAX_SYMLINK_HOPS`` - such hops count as a loop. Non-strict ``Path.resolve()`` is avoided because - its symlink-loop handling differs between CPython versions. - """ - prefix = Path(os.path.normpath(str(path))) - missing_tail: list[str] = [] - hops = 0 - while True: - try: - return prefix.resolve(strict=True).joinpath(*reversed(missing_tail)) - except RuntimeError: - # CPython < 3.13 reports a symlink loop from ``resolve(strict=True)`` this way. - raise _symlink_loop_error() from None - except OSError as e: - if e.errno not in _MISSING_ERRNOS: - raise - unresolved = e - try: - is_symlink = S_ISLNK(os.lstat(prefix).st_mode) - except OSError as e: - if e.errno not in _MISSING_ERRNOS or prefix.parent == prefix: - raise - missing_tail.append(prefix.name) - prefix = prefix.parent - continue - if not is_symlink: - raise unresolved - hops += 1 - if hops > _MAX_SYMLINK_HOPS: - raise _symlink_loop_error() - prefix = Path(os.path.normpath(os.path.join(prefix.parent, os.readlink(prefix)))) - - def resolve_path(ctx: AgentToolContext, p: str) -> Path: """Resolve ``p`` against the workdir; reject results that escape it. Absolute and relative inputs go through the same canonicalise-then-contain check — an absolute path that lands inside the workdir is permitted, only - paths that resolve *outside* are rejected. ``.`` and ``..`` components are - collapsed lexically first; then every symlink (including the leaf, even a - dangling one) is followed before the containment check, so a symlink under - the workdir that targets ``/etc`` is rejected — and the resolved path is - what the tool then operates on, so it can't be followed afterwards either. - A symlink loop or an unreadable component rejects the path outright rather - than falling back to an unresolved path. See the trust model on - :class:`AgentToolContext`. + paths that resolve *outside* are rejected. ``Path.resolve()`` follows every + symlink (including the leaf, even a dangling one) before the containment + check, so a symlink under the workdir that targets ``/etc`` is rejected — + and the resolved path is what the tool then operates on, so it can't be + followed afterwards either. See the trust model on :class:`AgentToolContext`. """ candidate = Path(p) + if ctx.unrestricted_paths and candidate.is_absolute(): + return candidate.resolve() root = Path(ctx.workdir).resolve() - try: - full = _canonicalize(candidate if candidate.is_absolute() else root / candidate) - except OSError as e: - raise ValueError(f"path {p!r}: {_fs_reason(e)}") from e + full = (candidate if candidate.is_absolute() else root / candidate).resolve() if not ctx.unrestricted_paths and not _within(full, root): raise ValueError(f"path {p!r} escapes workdir") return full @@ -743,18 +685,6 @@ def _mtime_or_zero(p: Path) -> float: return 0.0 -def _confined(matches: Iterable[Path], root: Path) -> Iterator[Path]: - """Yield the matches whose canonical path is inside ``root``; a match that - cannot be canonicalised (symlink loop, unreadable) is dropped, not raised.""" - for match in matches: - try: - canonical = _canonicalize(match) - except OSError: - continue - if _within(canonical, root): - yield match - - def beta_glob_tool(ctx: AgentToolContext) -> BetaAsyncFunctionTool[Any]: @beta_async_tool(name="glob", input_schema=BetaManagedAgentsAgentToolset20260401GlobInput) async def glob(pattern: str, path: Optional[str] = None) -> str: @@ -790,8 +720,8 @@ async def glob(pattern: str, path: Optional[str] = None) -> str: if confine is not None: # Post-filter: a symlink traversed mid-pattern (glob follows # symlinks for non-``**`` segments) must not let a result escape the - # confinement root. - matches = list(_confined(matches, confine)) + # confinement root. ``resolve()`` canonicalises symlinks. + matches = [m for m in matches if _within(m.resolve(), confine)] if not matches: return "no matches" matches.sort(key=_mtime_or_zero, reverse=True) diff --git a/src/anthropic/lib/vertex/_beta_messages.py b/src/anthropic/lib/vertex/_beta_messages.py index 0fa38f974..72b97b049 100644 --- a/src/anthropic/lib/vertex/_beta_messages.py +++ b/src/anthropic/lib/vertex/_beta_messages.py @@ -13,9 +13,7 @@ class Messages(SyncAPIResource): create = FirstPartyMessagesAPI.create - parse = FirstPartyMessagesAPI.parse stream = FirstPartyMessagesAPI.stream - tool_runner = FirstPartyMessagesAPI.tool_runner count_tokens = FirstPartyMessagesAPI.count_tokens @cached_property @@ -40,9 +38,7 @@ def with_streaming_response(self) -> MessagesWithStreamingResponse: class AsyncMessages(AsyncAPIResource): create = FirstPartyAsyncMessagesAPI.create - parse = FirstPartyAsyncMessagesAPI.parse stream = FirstPartyAsyncMessagesAPI.stream - tool_runner = FirstPartyAsyncMessagesAPI.tool_runner count_tokens = FirstPartyAsyncMessagesAPI.count_tokens @cached_property diff --git a/src/anthropic/lib/vertex/_client.py b/src/anthropic/lib/vertex/_client.py index 8a7e0ca6e..3c713996d 100644 --- a/src/anthropic/lib/vertex/_client.py +++ b/src/anthropic/lib/vertex/_client.py @@ -9,7 +9,7 @@ from ... import _exceptions from ._auth import load_auth, refresh_auth from ._beta import Beta, AsyncBeta -from ..._types import NotGiven, not_given +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 @@ -96,12 +96,12 @@ class AnthropicVertex(BaseVertexClient[httpx.Client, Stream[Any]], SyncAPIClient def __init__( self, *, - region: str | NotGiven = not_given, - project_id: str | NotGiven = not_given, + 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, + 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, @@ -111,7 +111,7 @@ def __init__( _strict_response_validation: bool = False, ) -> None: if not is_given(region): - region = os.environ.get("CLOUD_ML_REGION", not_given) + 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." @@ -184,19 +184,19 @@ def _ensure_access_token(self) -> str: def copy( self, *, - region: str | NotGiven = not_given, - project_id: str | NotGiven = not_given, + 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, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ @@ -224,7 +224,7 @@ def copy( 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, + 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, @@ -260,12 +260,12 @@ class AsyncAnthropicVertex(BaseVertexClient[httpx.AsyncClient, AsyncStream[Any]] def __init__( self, *, - region: str | NotGiven = not_given, - project_id: str | NotGiven = not_given, + 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, + 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, @@ -275,7 +275,7 @@ def __init__( _strict_response_validation: bool = False, ) -> None: if not is_given(region): - region = os.environ.get("CLOUD_ML_REGION", not_given) + 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." @@ -348,19 +348,19 @@ async def _ensure_access_token(self) -> str: def copy( self, *, - region: str | NotGiven = not_given, - project_id: str | NotGiven = not_given, + 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, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = not_given, + 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, - middleware: Sequence[MiddlewareInput] | None | NotGiven = not_given, + middleware: Sequence[MiddlewareInput] | None | NotGiven = NOT_GIVEN, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ @@ -388,7 +388,7 @@ def copy( 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, + 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, diff --git a/src/anthropic/resources/beta/dreams.py b/src/anthropic/resources/beta/dreams.py index 9fd6c4040..2b6601541 100644 --- a/src/anthropic/resources/beta/dreams.py +++ b/src/anthropic/resources/beta/dreams.py @@ -21,7 +21,6 @@ from ...types.anthropic_beta_param import AnthropicBetaParam from ...types.beta.beta_dream_status import BetaDreamStatus from ...types.beta.beta_dream_input_param import BetaDreamInputParam -from ...types.beta.beta_output_behavior_param import BetaOutputBehaviorParam __all__ = ["Dreams", "AsyncDreams"] @@ -52,7 +51,6 @@ def create( inputs: Iterable[BetaDreamInputParam], model: dream_create_params.Model, instructions: Optional[str] | Omit = omit, - output_behavior: BetaOutputBehaviorParam | Omit = omit, betas: List[AnthropicBetaParam] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -67,10 +65,6 @@ def create( Args: model: Model identifier and configuration applied to every pipeline stage. - output_behavior: The default destination: the job creates a new output memory store as a clone of - the memory_store input and writes the consolidated memories into it. The input - store is never mutated. - betas: Optional header to specify the beta version(s) you want to use. extra_headers: Send extra headers @@ -99,7 +93,6 @@ def create( "inputs": inputs, "model": model, "instructions": instructions, - "output_behavior": output_behavior, }, dream_create_params.DreamCreateParams, ), @@ -357,7 +350,6 @@ async def create( inputs: Iterable[BetaDreamInputParam], model: dream_create_params.Model, instructions: Optional[str] | Omit = omit, - output_behavior: BetaOutputBehaviorParam | Omit = omit, betas: List[AnthropicBetaParam] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -372,10 +364,6 @@ async def create( Args: model: Model identifier and configuration applied to every pipeline stage. - output_behavior: The default destination: the job creates a new output memory store as a clone of - the memory_store input and writes the consolidated memories into it. The input - store is never mutated. - betas: Optional header to specify the beta version(s) you want to use. extra_headers: Send extra headers @@ -404,7 +392,6 @@ async def create( "inputs": inputs, "model": model, "instructions": instructions, - "output_behavior": output_behavior, }, dream_create_params.DreamCreateParams, ), diff --git a/src/anthropic/resources/beta/messages/messages.py b/src/anthropic/resources/beta/messages/messages.py index b45dc6d38..a018e5e51 100644 --- a/src/anthropic/resources/beta/messages/messages.py +++ b/src/anthropic/resources/beta/messages/messages.py @@ -21,7 +21,7 @@ BatchesWithStreamingResponse, AsyncBatchesWithStreamingResponse, ) -from ...._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from ...._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from ...._utils import is_given, required_args, maybe_transform, strip_not_given, async_maybe_transform from ...._compat import cached_property from ...._models import TypeAdapter @@ -1315,7 +1315,7 @@ def parse( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ParsedBetaMessage[ResponseFormatT]: _validate_output_config_conflict(output_config, output_format) _warn_output_format_deprecated(output_format) @@ -1349,7 +1349,7 @@ def parse( _helper_header("beta.messages.parse"), strip_not_given( { - "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given, + "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN, "anthropic-user-profile-id": user_profile_id, } ), @@ -1382,7 +1382,7 @@ def parser(response: BetaMessage) -> ParsedBetaMessage[ResponseFormatT]: response=response, output_format=cast( ResponseFormatT, - output_format if is_given(output_format) and output_format is not None else not_given, + output_format if is_given(output_format) and output_format is not None else NOT_GIVEN, ), ) @@ -1467,7 +1467,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaToolRunner[ResponseFormatT]: ... @overload @@ -1508,7 +1508,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaStreamingToolRunner[ResponseFormatT]: ... @overload @@ -1549,7 +1549,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaStreamingToolRunner[ResponseFormatT] | BetaToolRunner[ResponseFormatT]: ... def tool_runner( @@ -1589,7 +1589,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaStreamingToolRunner[ResponseFormatT] | BetaToolRunner[ResponseFormatT]: """Create a Message stream""" _validate_output_config_conflict(output_config, output_format) @@ -1620,7 +1620,7 @@ def tool_runner( _helper_header("BetaToolRunner"), strip_not_given( { - "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given, + "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN, "anthropic-user-profile-id": user_profile_id, } ), @@ -1729,7 +1729,7 @@ def stream( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaMessageStreamManager[ResponseFormatT]: _validate_output_config_conflict(output_config, output_format) _warn_output_format_deprecated(output_format) @@ -1756,7 +1756,7 @@ def stream( }, strip_not_given( { - "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given, + "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN, "anthropic-user-profile-id": user_profile_id, } ), @@ -1817,7 +1817,7 @@ def stream( "tool_choice": tool_choice, "stream": True, }, - message_create_params.MessageCreateParamsStreaming, + message_create_params.MessageCreateParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -1828,7 +1828,7 @@ def stream( ) return BetaMessageStreamManager( make_request, - output_format=not_given if is_dict(output_format) else cast(ResponseFormatT, output_format), + output_format=NOT_GIVEN if is_dict(output_format) else cast(ResponseFormatT, output_format), ) def count_tokens( @@ -3333,7 +3333,7 @@ async def parse( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ParsedBetaMessage[ResponseFormatT]: _validate_output_config_conflict(output_config, output_format) _warn_output_format_deprecated(output_format) @@ -3366,7 +3366,7 @@ async def parse( _helper_header("beta.messages.parse"), strip_not_given( { - "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given, + "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN, "anthropic-user-profile-id": user_profile_id, } ), @@ -3399,7 +3399,7 @@ def parser(response: BetaMessage) -> ParsedBetaMessage[ResponseFormatT]: response=response, output_format=cast( ResponseFormatT, - output_format if is_given(output_format) and output_format is not None else not_given, + output_format if is_given(output_format) and output_format is not None else NOT_GIVEN, ), ) @@ -3484,7 +3484,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaAsyncToolRunner[ResponseFormatT]: ... @overload @@ -3525,7 +3525,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaAsyncStreamingToolRunner[ResponseFormatT]: ... @overload @@ -3566,7 +3566,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaAsyncStreamingToolRunner[ResponseFormatT] | BetaAsyncToolRunner[ResponseFormatT]: ... def tool_runner( @@ -3606,7 +3606,7 @@ def tool_runner( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaAsyncToolRunner[ResponseFormatT] | BetaAsyncStreamingToolRunner[ResponseFormatT]: """Create a Message stream""" _validate_output_config_conflict(output_config, output_format) @@ -3630,7 +3630,7 @@ def tool_runner( _helper_header("BetaToolRunner"), strip_not_given( { - "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given, + "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN, "anthropic-user-profile-id": user_profile_id, } ), @@ -3739,7 +3739,7 @@ def stream( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BetaAsyncMessageStreamManager[ResponseFormatT]: _validate_output_config_conflict(output_config, output_format) _warn_output_format_deprecated(output_format) @@ -3765,7 +3765,7 @@ def stream( }, strip_not_given( { - "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given, + "anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN, "anthropic-user-profile-id": user_profile_id, } ), @@ -3825,7 +3825,7 @@ def stream( "tool_choice": tool_choice, "stream": True, }, - message_create_params.MessageCreateParamsStreaming, + message_create_params.MessageCreateParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -3836,7 +3836,7 @@ def stream( ) return BetaAsyncMessageStreamManager( request, - output_format=not_given if is_dict(output_format) else cast(ResponseFormatT, output_format), + output_format=NOT_GIVEN if is_dict(output_format) else cast(ResponseFormatT, output_format), ) async def count_tokens( diff --git a/src/anthropic/resources/beta/user_profiles.py b/src/anthropic/resources/beta/user_profiles.py index 54a89916f..835facade 100644 --- a/src/anthropic/resources/beta/user_profiles.py +++ b/src/anthropic/resources/beta/user_profiles.py @@ -70,9 +70,9 @@ def create( keys up to 64 characters and values up to 512 characters. Values must be non-empty strings. - name: Optional for all profiles. Real-world name of the entity this profile represents - (company or individual); for `resold` profiles, the resold-to company's name - where known. Maximum 255 characters. + name: Display name of the entity this profile represents. Required when relationship + is `resold` (the resold-to company's name); optional otherwise. Maximum 255 + characters. relationship: How the entity behind a user profile relates to the platform that owns the API key. `external`: an individual end-user of the platform. `resold`: a company the @@ -399,9 +399,9 @@ async def create( keys up to 64 characters and values up to 512 characters. Values must be non-empty strings. - name: Optional for all profiles. Real-world name of the entity this profile represents - (company or individual); for `resold` profiles, the resold-to company's name - where known. Maximum 255 characters. + name: Display name of the entity this profile represents. Required when relationship + is `resold` (the resold-to company's name); optional otherwise. Maximum 255 + characters. relationship: How the entity behind a user profile relates to the platform that owns the API key. `external`: an individual end-user of the platform. `resold`: a company the diff --git a/src/anthropic/resources/messages/messages.py b/src/anthropic/resources/messages/messages.py index 9a07b4d57..2767f8733 100644 --- a/src/anthropic/resources/messages/messages.py +++ b/src/anthropic/resources/messages/messages.py @@ -24,7 +24,7 @@ BatchesWithStreamingResponse, AsyncBatchesWithStreamingResponse, ) -from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from ..._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from ..._utils import is_given, required_args, maybe_transform, strip_not_given, async_maybe_transform from ..._compat import cached_property from ..._models import TypeAdapter @@ -1107,7 +1107,7 @@ def stream( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> MessageStreamManager[ResponseFormatT]: """Create a Message stream""" if model in DEPRECATED_MODELS: @@ -1131,7 +1131,7 @@ def stream( **(extra_headers or {}), } - transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = not_given + transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = NOT_GIVEN if is_dict(output_format): transformed_output_format = cast(JSONOutputFormatParam, output_format) @@ -1183,7 +1183,7 @@ def stream( "tool_choice": tool_choice, "stream": True, }, - message_create_params.MessageCreateParamsStreaming, + message_create_params.MessageCreateParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -1194,7 +1194,7 @@ def stream( ) return MessageStreamManager( make_request, - output_format=not_given if is_dict(output_format) else cast(ResponseFormatT, output_format), + output_format=NOT_GIVEN if is_dict(output_format) else cast(ResponseFormatT, output_format), ) def parse( @@ -1221,7 +1221,7 @@ def parse( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ParsedMessage[ResponseFormatT]: if not stream and not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = self._client._calculate_nonstreaming_timeout( @@ -1247,7 +1247,7 @@ def parse( extra_headers or {}, ) - transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = not_given + transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = NOT_GIVEN if is_given(output_format) and output_format is not None: adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format) @@ -1268,7 +1268,7 @@ def parser(response: Message) -> ParsedMessage[ResponseFormatT]: response=response, output_format=cast( ResponseFormatT, - output_format if is_given(output_format) and output_format is not None else not_given, + output_format if is_given(output_format) and output_format is not None else NOT_GIVEN, ), ) @@ -1534,7 +1534,7 @@ def count_tokens( extra_headers = {**strip_not_given({"anthropic-user-profile-id": user_profile_id}), **(extra_headers or {})} # Transform output_format if provided - transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = not_given + transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = NOT_GIVEN if is_dict(output_format): transformed_output_format = cast(JSONOutputFormatParam, output_format) @@ -2602,7 +2602,7 @@ def stream( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> AsyncMessageStreamManager[ResponseFormatT]: """Create a Message stream""" if model in DEPRECATED_MODELS: @@ -2626,7 +2626,7 @@ def stream( **(extra_headers or {}), } - transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = not_given + transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = NOT_GIVEN if is_dict(output_format): transformed_output_format = cast(JSONOutputFormatParam, output_format) @@ -2677,7 +2677,7 @@ def stream( "tool_choice": tool_choice, "stream": True, }, - message_create_params.MessageCreateParamsStreaming, + message_create_params.MessageCreateParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -2688,7 +2688,7 @@ def stream( ) return AsyncMessageStreamManager( request, - output_format=not_given if is_dict(output_format) else cast(ResponseFormatT, output_format), + output_format=NOT_GIVEN if is_dict(output_format) else cast(ResponseFormatT, output_format), ) async def parse( @@ -2715,7 +2715,7 @@ async def parse( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ParsedMessage[ResponseFormatT]: if not stream and not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = self._client._calculate_nonstreaming_timeout( @@ -2741,7 +2741,7 @@ async def parse( extra_headers or {}, ) - transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = not_given + transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = NOT_GIVEN if is_given(output_format) and output_format is not None: adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format) @@ -2762,7 +2762,7 @@ def parser(response: Message) -> ParsedMessage[ResponseFormatT]: response=response, output_format=cast( ResponseFormatT, - output_format if is_given(output_format) and output_format is not None else not_given, + output_format if is_given(output_format) and output_format is not None else NOT_GIVEN, ), ) @@ -3028,7 +3028,7 @@ async def count_tokens( extra_headers = {**strip_not_given({"anthropic-user-profile-id": user_profile_id}), **(extra_headers or {})} # Transform output_format if provided - transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = not_given + transformed_output_format: Optional[JSONOutputFormatParam] | NotGiven = NOT_GIVEN if is_dict(output_format): transformed_output_format = cast(JSONOutputFormatParam, output_format) diff --git a/src/anthropic/types/beta/__init__.py b/src/anthropic/types/beta/__init__.py index cc2e7162f..52824c22d 100644 --- a/src/anthropic/types/beta/__init__.py +++ b/src/anthropic/types/beta/__init__.py @@ -62,7 +62,6 @@ from .beta_citations_delta import BetaCitationsDelta as BetaCitationsDelta from .beta_fallbacks_param import BetaFallbacksParam as BetaFallbacksParam from .beta_limited_network import BetaLimitedNetwork as BetaLimitedNetwork -from .beta_output_behavior import BetaOutputBehavior as BetaOutputBehavior from .beta_packages_params import BetaPackagesParams as BetaPackagesParams from .beta_signature_delta import BetaSignatureDelta as BetaSignatureDelta from .beta_web_fetch_block import BetaWebFetchBlock as BetaWebFetchBlock @@ -132,7 +131,6 @@ from .beta_fallback_credit_usage import BetaFallbackCreditUsage as BetaFallbackCreditUsage from .beta_mcp_tool_config_param import BetaMCPToolConfigParam as BetaMCPToolConfigParam from .beta_mcp_tool_result_block import BetaMCPToolResultBlock as BetaMCPToolResultBlock -from .beta_output_behavior_param import BetaOutputBehaviorParam as BetaOutputBehaviorParam from .beta_output_tokens_details import BetaOutputTokensDetails as BetaOutputTokensDetails from .beta_server_tool_use_block import BetaServerToolUseBlock as BetaServerToolUseBlock from .beta_thinking_config_param import BetaThinkingConfigParam as BetaThinkingConfigParam @@ -211,7 +209,6 @@ from .beta_managed_agents_model_param import BetaManagedAgentsModelParam as BetaManagedAgentsModelParam from .beta_managed_agents_start_event import BetaManagedAgentsStartEvent as BetaManagedAgentsStartEvent from .beta_memory_tool_20250818_param import BetaMemoryTool20250818Param as BetaMemoryTool20250818Param -from .beta_output_behavior_create_new import BetaOutputBehaviorCreateNew as BetaOutputBehaviorCreateNew from .beta_tool_reference_block_param import BetaToolReferenceBlockParam as BetaToolReferenceBlockParam from .beta_unrestricted_network_param import BetaUnrestrictedNetworkParam as BetaUnrestrictedNetworkParam from .beta_advisor_tool_20260301_param import BetaAdvisorTool20260301Param as BetaAdvisorTool20260301Param @@ -289,7 +286,6 @@ from .beta_context_management_config_param import BetaContextManagementConfigParam as BetaContextManagementConfigParam from .beta_managed_agents_effort_low_param import BetaManagedAgentsEffortLowParam as BetaManagedAgentsEffortLowParam from .beta_managed_agents_effort_max_param import BetaManagedAgentsEffortMaxParam as BetaManagedAgentsEffortMaxParam -from .beta_output_behavior_update_existing import BetaOutputBehaviorUpdateExisting as BetaOutputBehaviorUpdateExisting from .beta_tool_text_editor_20241022_param import BetaToolTextEditor20241022Param as BetaToolTextEditor20241022Param from .beta_tool_text_editor_20250124_param import BetaToolTextEditor20250124Param as BetaToolTextEditor20250124Param from .beta_tool_text_editor_20250429_param import BetaToolTextEditor20250429Param as BetaToolTextEditor20250429Param @@ -312,7 +308,6 @@ ) from .beta_managed_agents_server_tool_usage import BetaManagedAgentsServerToolUsage as BetaManagedAgentsServerToolUsage from .beta_managed_agents_unknown_run_error import BetaManagedAgentsUnknownRunError as BetaManagedAgentsUnknownRunError -from .beta_output_behavior_create_new_param import BetaOutputBehaviorCreateNewParam as BetaOutputBehaviorCreateNewParam from .beta_request_tool_removal_block_param import BetaRequestToolRemovalBlockParam as BetaRequestToolRemovalBlockParam from .beta_tool_change_tool_reference_param import BetaToolChangeToolReferenceParam as BetaToolChangeToolReferenceParam from .beta_tool_computer_use_20241022_param import BetaToolComputerUse20241022Param as BetaToolComputerUse20241022Param @@ -551,9 +546,6 @@ from .beta_managed_agents_user_tool_result_event import ( BetaManagedAgentsUserToolResultEvent as BetaManagedAgentsUserToolResultEvent, ) -from .beta_output_behavior_update_existing_param import ( - BetaOutputBehaviorUpdateExistingParam as BetaOutputBehaviorUpdateExistingParam, -) from .beta_tool_search_tool_regex_20251119_param import ( BetaToolSearchToolRegex20251119Param as BetaToolSearchToolRegex20251119Param, ) diff --git a/src/anthropic/types/beta/beta_dream.py b/src/anthropic/types/beta/beta_dream.py index abcbe633c..07a61afc7 100644 --- a/src/anthropic/types/beta/beta_dream.py +++ b/src/anthropic/types/beta/beta_dream.py @@ -10,7 +10,6 @@ from .beta_dream_usage import BetaDreamUsage from .beta_dream_output import BetaDreamOutput from .beta_dream_status import BetaDreamStatus -from .beta_output_behavior import BetaOutputBehavior from .beta_dream_model_config import BetaDreamModelConfig __all__ = ["BetaDream"] @@ -18,7 +17,7 @@ class BetaDream(BaseModel): """ - An asynchronous memory-consolidation job that reads a memory store plus a set of session transcripts and writes consolidated memories into an output memory store — a new store by default, or an existing store chosen via output_behavior. The Dreams API is in research preview: the request and response shapes are volatile and may change without the deprecation period that applies to generally-available endpoints. + An asynchronous memory-consolidation job that reads a memory store plus a set of session transcripts and writes consolidated memories into a new output memory store. The Dreams API is in research preview: the request and response shapes are volatile and may change without the deprecation period that applies to generally-available endpoints. """ id: str @@ -45,13 +44,6 @@ class BetaDream(BaseModel): Same wire shape as the Agents API ModelConfig. """ - output_behavior: BetaOutputBehavior - """ - The default destination: the job creates a new output memory store as a clone of - the memory_store input and writes the consolidated memories into it. The input - store is never mutated. - """ - outputs: List[BetaDreamOutput] session_id: Optional[str] = None diff --git a/src/anthropic/types/beta/beta_dream_memory_store_input.py b/src/anthropic/types/beta/beta_dream_memory_store_input.py index d7b945ad9..a90fb09ba 100644 --- a/src/anthropic/types/beta/beta_dream_memory_store_input.py +++ b/src/anthropic/types/beta/beta_dream_memory_store_input.py @@ -8,10 +8,7 @@ class BetaDreamMemoryStoreInput(BaseModel): - """An input memory store the dream reads from. - - The dream never mutates this store unless it is also the destination: with output_behavior {type: "update_existing"} the job consolidates this store in place. - """ + """An input memory store the dream reads from. The dream never mutates this store.""" memory_store_id: str diff --git a/src/anthropic/types/beta/beta_dream_memory_store_input_param.py b/src/anthropic/types/beta/beta_dream_memory_store_input_param.py index e21efb27c..43f4a461c 100644 --- a/src/anthropic/types/beta/beta_dream_memory_store_input_param.py +++ b/src/anthropic/types/beta/beta_dream_memory_store_input_param.py @@ -8,10 +8,7 @@ class BetaDreamMemoryStoreInputParam(TypedDict, total=False): - """An input memory store the dream reads from. - - The dream never mutates this store unless it is also the destination: with output_behavior {type: "update_existing"} the job consolidates this store in place. - """ + """An input memory store the dream reads from. The dream never mutates this store.""" memory_store_id: Required[str] diff --git a/src/anthropic/types/beta/beta_output_behavior.py b/src/anthropic/types/beta/beta_output_behavior.py deleted file mode 100644 index 8324317ec..000000000 --- a/src/anthropic/types/beta/beta_output_behavior.py +++ /dev/null @@ -1,14 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Union -from typing_extensions import Annotated, TypeAlias - -from ..._utils import PropertyInfo -from .beta_output_behavior_create_new import BetaOutputBehaviorCreateNew -from .beta_output_behavior_update_existing import BetaOutputBehaviorUpdateExisting - -__all__ = ["BetaOutputBehavior"] - -BetaOutputBehavior: TypeAlias = Annotated[ - Union[BetaOutputBehaviorCreateNew, BetaOutputBehaviorUpdateExisting], PropertyInfo(discriminator="type") -] diff --git a/src/anthropic/types/beta/beta_output_behavior_create_new.py b/src/anthropic/types/beta/beta_output_behavior_create_new.py deleted file mode 100644 index 99e824162..000000000 --- a/src/anthropic/types/beta/beta_output_behavior_create_new.py +++ /dev/null @@ -1,15 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from ..._models import BaseModel - -__all__ = ["BetaOutputBehaviorCreateNew"] - - -class BetaOutputBehaviorCreateNew(BaseModel): - """ - The default destination: the job creates a new output memory store as a clone of the memory_store input and writes the consolidated memories into it. The input store is never mutated. - """ - - type: Literal["create_new"] diff --git a/src/anthropic/types/beta/beta_output_behavior_create_new_param.py b/src/anthropic/types/beta/beta_output_behavior_create_new_param.py deleted file mode 100644 index 9a533e212..000000000 --- a/src/anthropic/types/beta/beta_output_behavior_create_new_param.py +++ /dev/null @@ -1,15 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -__all__ = ["BetaOutputBehaviorCreateNewParam"] - - -class BetaOutputBehaviorCreateNewParam(TypedDict, total=False): - """ - The default destination: the job creates a new output memory store as a clone of the memory_store input and writes the consolidated memories into it. The input store is never mutated. - """ - - type: Required[Literal["create_new"]] diff --git a/src/anthropic/types/beta/beta_output_behavior_param.py b/src/anthropic/types/beta/beta_output_behavior_param.py deleted file mode 100644 index 1cc3d153e..000000000 --- a/src/anthropic/types/beta/beta_output_behavior_param.py +++ /dev/null @@ -1,13 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Union -from typing_extensions import TypeAlias - -from .beta_output_behavior_create_new_param import BetaOutputBehaviorCreateNewParam -from .beta_output_behavior_update_existing_param import BetaOutputBehaviorUpdateExistingParam - -__all__ = ["BetaOutputBehaviorParam"] - -BetaOutputBehaviorParam: TypeAlias = Union[BetaOutputBehaviorCreateNewParam, BetaOutputBehaviorUpdateExistingParam] diff --git a/src/anthropic/types/beta/beta_output_behavior_update_existing.py b/src/anthropic/types/beta/beta_output_behavior_update_existing.py deleted file mode 100644 index 4fca26564..000000000 --- a/src/anthropic/types/beta/beta_output_behavior_update_existing.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from ..._models import BaseModel - -__all__ = ["BetaOutputBehaviorUpdateExisting"] - - -class BetaOutputBehaviorUpdateExisting(BaseModel): - """ - The job writes the consolidated memories into this existing memory store instead of creating one. In EAP the store must be the job's own memory_store input, so the job consolidates the store in place. - """ - - memory_store_id: str - - type: Literal["update_existing"] diff --git a/src/anthropic/types/beta/beta_output_behavior_update_existing_param.py b/src/anthropic/types/beta/beta_output_behavior_update_existing_param.py deleted file mode 100644 index 0f1c90b81..000000000 --- a/src/anthropic/types/beta/beta_output_behavior_update_existing_param.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, TypedDict - -__all__ = ["BetaOutputBehaviorUpdateExistingParam"] - - -class BetaOutputBehaviorUpdateExistingParam(TypedDict, total=False): - """ - The job writes the consolidated memories into this existing memory store instead of creating one. In EAP the store must be the job's own memory_store input, so the job consolidates the store in place. - """ - - memory_store_id: Required[str] - - type: Required[Literal["update_existing"]] diff --git a/src/anthropic/types/beta/beta_user_profile.py b/src/anthropic/types/beta/beta_user_profile.py index d7b68ab8d..24c26d5ac 100644 --- a/src/anthropic/types/beta/beta_user_profile.py +++ b/src/anthropic/types/beta/beta_user_profile.py @@ -46,7 +46,7 @@ class BetaUserProfile(BaseModel): """Platform's own identifier for this user. Not enforced unique.""" name: Optional[str] = None - """Real-world name of the entity this profile represents (company or individual). + """Display name of the entity this profile represents. For `resold` this is the resold-to company's name. """ diff --git a/src/anthropic/types/beta/dream_create_params.py b/src/anthropic/types/beta/dream_create_params.py index ad3a8e62f..5316e1607 100644 --- a/src/anthropic/types/beta/dream_create_params.py +++ b/src/anthropic/types/beta/dream_create_params.py @@ -8,7 +8,6 @@ from ..._utils import PropertyInfo from ..anthropic_beta_param import AnthropicBetaParam from .beta_dream_input_param import BetaDreamInputParam -from .beta_output_behavior_param import BetaOutputBehaviorParam from .beta_dream_model_config_param import BetaDreamModelConfigParam __all__ = ["DreamCreateParams", "Model"] @@ -22,13 +21,6 @@ class DreamCreateParams(TypedDict, total=False): instructions: Optional[str] - output_behavior: BetaOutputBehaviorParam - """ - The default destination: the job creates a new output memory store as a clone of - the memory_store input and writes the consolidated memories into it. The input - store is never mutated. - """ - betas: Annotated[List[AnthropicBetaParam], PropertyInfo(alias="anthropic-beta")] """Optional header to specify the beta version(s) you want to use.""" diff --git a/src/anthropic/types/beta/user_profile_create_params.py b/src/anthropic/types/beta/user_profile_create_params.py index f1e299691..1880ea1dc 100644 --- a/src/anthropic/types/beta/user_profile_create_params.py +++ b/src/anthropic/types/beta/user_profile_create_params.py @@ -26,11 +26,10 @@ class UserProfileCreateParams(TypedDict, total=False): """ name: Optional[str] - """Optional for all profiles. + """Display name of the entity this profile represents. - Real-world name of the entity this profile represents (company or individual); - for `resold` profiles, the resold-to company's name where known. Maximum 255 - characters. + Required when relationship is `resold` (the resold-to company's name); optional + otherwise. Maximum 255 characters. """ relationship: Literal["external", "resold", "internal"] diff --git a/tests/api_resources/beta/test_dreams.py b/tests/api_resources/beta/test_dreams.py index f63812d8e..300a6a538 100644 --- a/tests/api_resources/beta/test_dreams.py +++ b/tests/api_resources/beta/test_dreams.py @@ -43,7 +43,6 @@ def test_method_create_with_all_params(self, client: Anthropic) -> None: ], model="string", instructions="x", - output_behavior={"type": "create_new"}, betas=["message-batches-2024-09-24"], ) assert_matches_type(BetaDream, dream, path=["response"]) @@ -290,7 +289,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncAnthropic) ], model="string", instructions="x", - output_behavior={"type": "create_new"}, betas=["message-batches-2024-09-24"], ) assert_matches_type(BetaDream, dream, path=["response"]) diff --git a/tests/lib/environments/test_poller.py b/tests/lib/environments/test_poller.py index dabbd8cab..acedd4cd6 100644 --- a/tests/lib/environments/test_poller.py +++ b/tests/lib/environments/test_poller.py @@ -2,11 +2,8 @@ import pytest -from anthropic.lib._retry import is_fatal_status_error from anthropic.lib.environments._poller import _jitter, _backoff -from .test_poller_method import _api_status_error - @pytest.mark.parametrize( ("description", "attempt", "want"), @@ -24,26 +21,3 @@ def test_jitter_within_bounds() -> None: for _ in range(100): v = _jitter(1.0, 3.0) assert 1.0 <= v < 3.0 - - -@pytest.mark.parametrize( - ("status", "fatal"), - [ - (400, True), - (401, True), - (403, True), - (404, True), - (408, False), - (409, False), - (412, True), - (422, True), - (429, False), - (500, False), - ], -) -def test_is_fatal_status_error(status: int, fatal: bool) -> None: - assert is_fatal_status_error(_api_status_error(status)) is fatal - - -def test_is_fatal_status_error_ignores_non_api_errors() -> None: - assert is_fatal_status_error(KeyError("bug")) is False diff --git a/tests/lib/environments/test_poller_method.py b/tests/lib/environments/test_poller_method.py index 319e52f04..82412473b 100644 --- a/tests/lib/environments/test_poller_method.py +++ b/tests/lib/environments/test_poller_method.py @@ -157,9 +157,8 @@ def test_iter_work_calls_stop_when_body_raises() -> None: assert fake.stop_calls == [("work_boom", fake.stop_calls[0][1])] -@pytest.mark.parametrize("status", [500, 409]) -def test_iter_work_backs_off_on_transient_error(status: int) -> None: - fake = FakeWork(poll_script=[_api_status_error(status), _StubWork(id="work_2")]) +def test_iter_work_backs_off_on_transient_error() -> None: + fake = FakeWork(poll_script=[_api_status_error(500), _StubWork(id="work_2")]) it = iter_work(cast(Any, fake), environment_id="env_1") item = _drain_one(it) @@ -175,24 +174,6 @@ def test_iter_work_raises_on_permanent_4xx() -> None: next(it) -def test_iter_work_stop_conflict_is_silent(caplog: pytest.LogCaptureFixture) -> None: - """A 409 from ``stop`` means the work already ended; it is neither logged - nor allowed to break the loop.""" - fake = FakeWork(poll_script=[_StubWork(id="work_done")]) - - def _stop(_work_id: str, **_kwargs: Any) -> None: - raise _api_status_error(409) - - fake.stop = _stop # type: ignore[method-assign] - it = iter_work(cast(Any, fake), environment_id="env_1") - - _drain_one(it) - with pytest.raises(_StopTest): - next(it) - assert len(fake.poll_calls) == 2 - assert not [r for r in caplog.records if "stop failed" in r.getMessage()] - - def test_iter_work_backs_off_on_httpx_transport_error() -> None: """A raw ``httpx`` transport error (not wrapped in an SDK ``APIError``) is still transient and must be retried, not propagated.""" @@ -341,9 +322,8 @@ async def test_aiter_work_calls_stop_when_body_raises() -> None: @pytest.mark.asyncio() -@pytest.mark.parametrize("status", [500, 409]) -async def test_aiter_work_backs_off_on_transient_error(status: int) -> None: - fake = FakeAsyncWork(poll_script=[_api_status_error(status), _StubWork(id="work_2")]) +async def test_aiter_work_backs_off_on_transient_error() -> None: + fake = FakeAsyncWork(poll_script=[_api_status_error(500), _StubWork(id="work_2")]) ait = aiter_work(cast(Any, fake), environment_id="env_1") item = await _adrain_one(ait) @@ -351,23 +331,6 @@ async def test_aiter_work_backs_off_on_transient_error(status: int) -> None: assert len(fake.poll_calls) == 2 -@pytest.mark.asyncio() -async def test_aiter_work_stop_conflict_is_silent(caplog: pytest.LogCaptureFixture) -> None: - fake = FakeAsyncWork(poll_script=[_StubWork(id="work_done")]) - - async def _stop(_work_id: str, **_kwargs: Any) -> None: - raise _api_status_error(409) - - fake.stop = _stop # type: ignore[method-assign] - ait = aiter_work(cast(Any, fake), environment_id="env_1") - - await _adrain_one(ait) - with pytest.raises(_StopTest): - await ait.__anext__() - assert len(fake.poll_calls) == 2 - assert not [r for r in caplog.records if "stop failed" in r.getMessage()] - - async def test_aiter_work_propagates_non_api_error_instead_of_retrying() -> None: """Async counterpart: a non-API/non-transport error propagates instead of being retried forever.""" diff --git a/tests/lib/environments/test_worker.py b/tests/lib/environments/test_worker.py index a7c6887dd..274ea13db 100644 --- a/tests/lib/environments/test_worker.py +++ b/tests/lib/environments/test_worker.py @@ -19,22 +19,18 @@ import os import asyncio -import logging import contextlib from types import SimpleNamespace -from typing import Any, cast +from typing import Any from collections.abc import AsyncIterator from typing_extensions import override -import anyio import pytest from anthropic import Anthropic, AsyncAnthropic from anthropic._compat import PYDANTIC_V1 from anthropic.lib.environments import _worker as worker_mod -from anthropic.lib.environments._worker import EnvironmentWorker, _heartbeat_loop - -from .test_poller_method import _api_status_error +from anthropic.lib.environments._worker import EnvironmentWorker class _FakeWorkResource: @@ -537,105 +533,3 @@ async def _iter() -> AsyncIterator[Any]: assert order.index("heartbeat") < order.index("setup_end") # The work item was still force-stopped on exit. assert len(work.stop_calls) == 1 - - -# ---------- heartbeat loop --------------------------------------------------- -# -# ``_heartbeat_loop`` is driven directly with a scripted ``work`` fake and a -# fake clock, so the lease-staleness ceiling can be crossed without real -# waiting. The first scripted beat reports ``ttl_seconds=0`` so the loop keeps -# the (patched, tiny) default interval instead of the 1 s floor it applies to -# a server-provided ttl. - -_HANG = object() -_FAST_INTERVAL = 0.01 -_FAKE_TTL = 5.0 - - -class _FakeClock: - def __init__(self) -> None: - self.now = 0.0 - - def monotonic(self) -> float: - return self.now - - -class _ScriptedHeartbeatWork: - """``heartbeat`` pops ``(clock_advance, outcome)`` per call: an exception is - raised, ``_HANG`` never returns, anything else is the response.""" - - def __init__(self, clock: _FakeClock, script: list[tuple[float, Any]]) -> None: - self._clock = clock - self._script = list(script) - self.calls = 0 - - async def heartbeat(self, work_id: str, **_kwargs: Any) -> Any: # noqa: ARG002 - self.calls += 1 - advance, outcome = self._script.pop(0) - self._clock.now += advance - if isinstance(outcome, BaseException): - raise outcome - if outcome is _HANG: - await anyio.sleep_forever() - return outcome - - -def _beat_ok(ttl_seconds: int = 0) -> Any: - return SimpleNamespace(last_heartbeat="hb", ttl_seconds=ttl_seconds, state="running", lease_extended=True) - - -async def _run_heartbeat_loop( - monkeypatch: pytest.MonkeyPatch, script: list[tuple[float, Any]] -) -> tuple[_ScriptedHeartbeatWork, anyio.Event]: - clock = _FakeClock() - monkeypatch.setattr(worker_mod, "time", SimpleNamespace(monotonic=clock.monotonic)) - monkeypatch.setattr(worker_mod, "_HEARTBEAT_DEFAULT", _FAST_INTERVAL) - monkeypatch.setattr(worker_mod, "_HEARTBEAT_TTL_DEFAULT", _FAKE_TTL) - work = _ScriptedHeartbeatWork(clock, script) - stop = anyio.Event() - with anyio.fail_after(5): - await _heartbeat_loop(cast(Any, work), work_id="w_1", environment_id="e_1", stop=stop) - return work, stop - - -def _messages(caplog: pytest.LogCaptureFixture) -> list[str]: - return [r.getMessage() for r in caplog.records if r.name == worker_mod.__name__] - - -@pytest.mark.asyncio() -async def test_heartbeat_conflict_is_retried_until_the_lease_is_stale( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - caplog.set_level(logging.WARNING, logger=worker_mod.__name__) - work, stop = await _run_heartbeat_loop( - monkeypatch, - [(0, _beat_ok()), (_FAKE_TTL - 2, _api_status_error(409)), (_FAKE_TTL, _api_status_error(409))], - ) - assert work.calls == 3 - assert stop.is_set() - messages = _messages(caplog) - assert any(m.startswith("transient heartbeat failure") for m in messages) - assert any(m.startswith("lease assumed lost") for m in messages) - assert not any(m.startswith("permanent heartbeat failure") for m in messages) - - -@pytest.mark.asyncio() -async def test_heartbeat_that_never_returns_is_cut_off_each_interval( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - caplog.set_level(logging.WARNING, logger=worker_mod.__name__) - work, stop = await _run_heartbeat_loop(monkeypatch, [(0, _beat_ok()), (_FAKE_TTL - 2, _HANG), (_FAKE_TTL, _HANG)]) - assert work.calls == 3 - assert stop.is_set() - assert any(m.startswith("lease assumed lost") for m in _messages(caplog)) - - -@pytest.mark.asyncio() -async def test_heartbeat_permanent_4xx_stops_immediately( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - caplog.set_level(logging.WARNING, logger=worker_mod.__name__) - work, stop = await _run_heartbeat_loop(monkeypatch, [(0, _api_status_error(401))]) - assert work.calls == 1 - assert stop.is_set() - assert any(m.startswith("permanent heartbeat failure") for m in _messages(caplog)) diff --git a/tests/lib/streaming/fixtures/context_management_response.txt b/tests/lib/streaming/fixtures/context_management_response.txt deleted file mode 100644 index 7b0dce0ae..000000000 --- a/tests/lib/streaming/fixtures/context_management_response.txt +++ /dev/null @@ -1,17 +0,0 @@ -event: message_start -data: {"type":"message_start","message":{"id":"msg_01ContextManagement000000001","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5","stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":25,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello there!"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":8},"context_management":{"applied_edits":[{"type":"clear_tool_uses_20250919","cleared_input_tokens":1500,"cleared_tool_uses":2}]}} - -event: message_stop -data: {"type":"message_stop"} diff --git a/tests/lib/streaming/fixtures/message_delta_fields_response.txt b/tests/lib/streaming/fixtures/message_delta_fields_response.txt deleted file mode 100644 index 3b28f34cb..000000000 --- a/tests/lib/streaming/fixtures/message_delta_fields_response.txt +++ /dev/null @@ -1,17 +0,0 @@ -event: message_start -data: {"type":"message_start","message":{"id":"msg_01MessageDeltaFields00000001","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5","stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":25,"cache_creation_input_tokens":10,"cache_read_input_tokens":5,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello there!"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":{"id":"container_01AbCdEfGh","expires_at":"2025-01-01T00:00:00Z"}},"usage":{"input_tokens":40,"cache_creation_input_tokens":12,"cache_read_input_tokens":7,"output_tokens":8,"output_tokens_details":{"thinking_tokens":3},"server_tool_use":{"web_search_requests":1,"web_fetch_requests":0}}} - -event: message_stop -data: {"type":"message_stop"} diff --git a/tests/lib/streaming/fixtures/message_delta_omitted_usage_response.txt b/tests/lib/streaming/fixtures/message_delta_omitted_usage_response.txt deleted file mode 100644 index 0e619cf6c..000000000 --- a/tests/lib/streaming/fixtures/message_delta_omitted_usage_response.txt +++ /dev/null @@ -1,17 +0,0 @@ -event: message_start -data: {"type":"message_start","message":{"id":"msg_01MessageDeltaOmitted00000001","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5","stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":25,"cache_creation_input_tokens":10,"cache_read_input_tokens":5,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"priority"}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello there!"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":8}} - -event: message_stop -data: {"type":"message_stop"} diff --git a/tests/lib/streaming/fixtures/missing_usage_response.txt b/tests/lib/streaming/fixtures/missing_usage_response.txt new file mode 100644 index 000000000..95a60cf81 --- /dev/null +++ b/tests/lib/streaming/fixtures/missing_usage_response.txt @@ -0,0 +1,18 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/lib/streaming/fixtures/server_tool_use_response.txt b/tests/lib/streaming/fixtures/server_tool_use_response.txt deleted file mode 100644 index 478bda606..000000000 --- a/tests/lib/streaming/fixtures/server_tool_use_response.txt +++ /dev/null @@ -1,56 +0,0 @@ -event: message_start -data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_011CdnAF53d41LwwHrjS6qm7","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2234,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_01Uz7MGoWkFSzxwLydECtuz5","name":"web_search","input":{}}} - -event: ping -data: {"type": "ping"} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\": \""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"anthropic cl"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"aude re"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"lease notes"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: content_block_start -data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01Uz7MGoWkFSzxwLydECtuz5","content":[{"type":"web_search_result","title":"Claude Platform release notes - Claude Platform Docs","url":"https://docs.anthropic.com/en/release-notes/overview","encrypted_content":"Eq8C_opaque_encrypted_content_0","page_age":null},{"type":"web_search_result","title":"Release notes | Claude Help Center","url":"https://support.claude.com/en/articles/12138966-release-notes","encrypted_content":"Eq8C_opaque_encrypted_content_1","page_age":"2 weeks ago"}],"caller":{"type":"direct"}}} - -event: content_block_stop -data: {"type":"content_block_stop","index":1} - -event: content_block_start -data: {"type":"content_block_start","index":2,"content_block":{"citations":[],"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":2,"delta":{"type":"citations_delta","citation":{"type":"web_search_result_location","cited_text":"... Our latest model, Claude Opus 4.7, is now generally available. ","url":"https://support.claude.com/en/articles/12138966-release-notes","title":"Release notes | Claude Help Center","encrypted_index":"Eo8B_opaque_encrypted_index"}}} - -event: content_block_delta -data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Claude Opus 4.7 is now generally available with"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":" improvements in software engineering and vision capabilities."}} - -event: content_block_stop -data: {"type":"content_block_stop","index":2} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":9281,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":97,"server_tool_use":{"web_search_requests":1,"web_fetch_requests":0}}} - -event: message_stop -data: {"type":"message_stop"} diff --git a/tests/lib/streaming/fixtures/tool_use_invalid_json_response.txt b/tests/lib/streaming/fixtures/tool_use_invalid_json_response.txt deleted file mode 100644 index 14f5a17bb..000000000 --- a/tests/lib/streaming/fixtures/tool_use_invalid_json_response.txt +++ /dev/null @@ -1,44 +0,0 @@ -event: message_start -data: {"type":"message_start","message":{"id":"msg_019Q1hrJbZG26Fb9BQhrkHEr","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":377,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: ping -data: {"type": "ping"} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'ll check the current weather in Paris for you."}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: content_block_start -data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01NRLabsLyVHZPKxbKvkfSMn","name":"get_weather","caller":{"type":"direct"},"input":{}}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"locati"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"on\": \"P"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"aris\", \"unit\": "}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"celsius}"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":1} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":65}} - -event: message_stop -data: {"type":"message_stop"} \ No newline at end of file diff --git a/tests/lib/streaming/test_beta_messages.py b/tests/lib/streaming/test_beta_messages.py index 6cbaf3629..627f16db9 100644 --- a/tests/lib/streaming/test_beta_messages.py +++ b/tests/lib/streaming/test_beta_messages.py @@ -3,6 +3,7 @@ import os import json from typing import Any, Set, Dict, TypeVar, cast +from unittest import TestCase import httpx import pytest @@ -12,11 +13,7 @@ from anthropic._utils import assert_overloads_in_sync, assert_signatures_in_sync from anthropic._compat import PYDANTIC_V1 from anthropic.types.beta.beta_message import BetaMessage -from anthropic.lib.streaming._beta_types import ( - BetaInputJsonEvent, - BetaCompactionEvent, - ParsedBetaMessageStreamEvent, -) +from anthropic.lib.streaming._beta_types import BetaCompactionEvent, ParsedBetaMessageStreamEvent from anthropic.resources.messages.messages import DEPRECATED_MODELS from anthropic.lib.streaming._beta_messages import TRACKS_TOOL_INPUT, BetaMessageStream, BetaAsyncMessageStream @@ -199,7 +196,9 @@ def assert_message_matches(message: BetaMessage, expected: Dict[str, Any]) -> No indent=2, exclude_none=True, exclude={"content": {"__all__": {"__json_buf"}}} ) - assert json.loads(actual_message_json) == expected + test_case = TestCase() + test_case.maxDiff = None + test_case.assertEqual(expected, json.loads(actual_message_json)) def assert_basic_response(events: list[ParsedBetaMessageStreamEvent], message: BetaMessage) -> None: @@ -261,45 +260,6 @@ def assert_fallback_response(events: list[ParsedBetaMessageStreamEvent], message assert text_block.text == "Hello there!" -EXPECTED_SERVER_TOOL_USE_EVENT_TYPES = [ - "message_start", - "content_block_start", - *["content_block_delta", "input_json"] * 6, - "content_block_stop", - "content_block_start", - "content_block_stop", - "content_block_start", - "content_block_delta", - "citation", - "content_block_delta", - "text", - "content_block_delta", - "text", - "content_block_stop", - "message_delta", -] - - -def assert_server_tool_use_response(events: list[ParsedBetaMessageStreamEvent], message: BetaMessage) -> None: - assert [e.type for e in events] == EXPECTED_SERVER_TOOL_USE_EVENT_TYPES - - server_tool_use = message.content[0] - assert server_tool_use.type == "server_tool_use" - assert server_tool_use.input == {"query": "anthropic claude release notes"} - - # input_json events must fire for server_tool_use blocks, not just client tool_use - input_json_events = [e for e in events if isinstance(e, BetaInputJsonEvent)] - assert [e.partial_json for e in input_json_events] == [ - "", - '{"query": "', - "anthropic cl", - "aude re", - "lease notes", - '"}', - ] - assert input_json_events[-1].snapshot == {"query": "anthropic claude release notes"} - - def assert_fallback_credit_response(message: BetaMessage) -> None: # `message_delta` carried `usage.fallback_credit`; the accumulated final # message must surface it rather than dropping it @@ -308,34 +268,6 @@ def assert_fallback_credit_response(message: BetaMessage) -> None: assert message.usage.output_tokens == 8 -def assert_message_delta_fields_response(message: BetaMessage) -> None: - # every field the final `message_delta` carried must land on the accumulated message - assert message.container is not None - assert message.container.id == "container_01AbCdEfGh" - assert message.usage.output_tokens == 8 - assert message.usage.input_tokens == 40 - assert message.usage.cache_creation_input_tokens == 12 - assert message.usage.cache_read_input_tokens == 7 - assert message.usage.output_tokens_details is not None - assert message.usage.output_tokens_details.thinking_tokens == 3 - assert message.usage.server_tool_use is not None - assert message.usage.server_tool_use.web_search_requests == 1 - # never re-sent on `message_delta`, so these must survive from `message_start` - assert message.usage.service_tier == "standard" - assert message.usage.cache_creation is not None - assert message.usage.cache_creation.ephemeral_5m_input_tokens == 10 - - -def assert_context_management_response(message: BetaMessage) -> None: - # `context_management` is a top-level key of the `message_delta` event and is - # never sent on `message_start`, so the event is its only source - assert message.context_management is not None - applied_edit = message.context_management.applied_edits[0] - assert applied_edit.type == "clear_tool_uses_20250919" - assert applied_edit.cleared_tool_uses == 2 - assert applied_edit.cleared_input_tokens == 1500 - - class TestSyncMessages: @pytest.mark.respx(base_url=base_url) def test_basic_response(self, respx_mock: MockRouter) -> None: @@ -377,19 +309,6 @@ def test_tool_use(self, respx_mock: MockRouter) -> None: assert_tool_use_response([event for event in stream], stream.get_final_message()) - @pytest.mark.respx(base_url=base_url) - def test_server_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("server_tool_use_response.txt")) - ) - - with sync_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_server_tool_use_response([event for event in stream], stream.get_final_message()) - @pytest.mark.respx(base_url=base_url) def test_context_manager(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( @@ -440,27 +359,6 @@ def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: ) as stream: assert_refusal_response(stream.get_final_message()) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.filterwarnings("error") - def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: - # trailing blank line terminates the final `message_stop` SSE so it is dispatched - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=iter([*get_response("basic_response.txt"), b"\n"])) - ) - - with sync_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", - ) as stream: - stop_event = [event for event in stream][-1] - - assert stop_event.type == "message_stop" - assert stop_event.message.content[0].type == "text" - # must not emit `PydanticSerializationUnexpectedValue` warnings - stop_event.model_dump() - stop_event.model_dump_json() - @pytest.mark.respx(base_url=base_url) def test_compaction(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( @@ -504,32 +402,6 @@ def test_fallback_credit_usage_propagated(self, respx_mock: MockRouter) -> None: ) as stream: assert_fallback_credit_response(stream.get_final_message()) - @pytest.mark.respx(base_url=base_url) - def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("message_delta_fields_response.txt")) - ) - - with sync_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_fields_response(stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_context_management_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("context_management_response.txt")) - ) - - with sync_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_context_management_response(stream.get_final_message()) - class TestAsyncMessages: @pytest.mark.asyncio @@ -613,20 +485,6 @@ async def test_tool_use(self, respx_mock: MockRouter) -> None: assert_tool_use_response([event async for event in stream], await stream.get_final_message()) - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_server_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("server_tool_use_response.txt"))) - ) - - async with async_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_server_tool_use_response([event async for event in stream], await stream.get_final_message()) - @pytest.mark.asyncio @pytest.mark.respx(base_url=base_url) async def test_incomplete_response(self, respx_mock: MockRouter) -> None: @@ -666,28 +524,6 @@ async def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> ) as stream: assert_refusal_response(await stream.get_final_message()) - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - @pytest.mark.filterwarnings("error") - async def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: - # trailing blank line terminates the final `message_stop` SSE so it is dispatched - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(iter([*get_response("basic_response.txt"), b"\n"]))) - ) - - async with async_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", - ) as stream: - stop_event = [event async for event in stream][-1] - - assert stop_event.type == "message_stop" - assert stop_event.message.content[0].type == "text" - # must not emit `PydanticSerializationUnexpectedValue` warnings - stop_event.model_dump() - stop_event.model_dump_json() - @pytest.mark.asyncio @pytest.mark.respx(base_url=base_url) async def test_compaction(self, respx_mock: MockRouter) -> None: @@ -734,34 +570,6 @@ async def test_fallback_credit_usage_propagated(self, respx_mock: MockRouter) -> ) as stream: assert_fallback_credit_response(await stream.get_final_message()) - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("message_delta_fields_response.txt"))) - ) - - async with async_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_fields_response(await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_context_management_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("context_management_response.txt"))) - ) - - async with async_client.beta.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_context_management_response(await stream.get_final_message()) - @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_definition_in_sync(sync: bool) -> None: diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index 7bdc7b275..f0e4c3ebc 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import Any, Set, TypeVar, Iterator, cast +from typing import Any, Set, TypeVar, cast import httpx import pytest @@ -10,7 +10,7 @@ from anthropic import Stream, Anthropic, AsyncStream, AsyncAnthropic from anthropic._utils import assert_signatures_in_sync from anthropic._compat import PYDANTIC_V1 -from anthropic.lib.streaming import InputJsonEvent, ParsedMessageStreamEvent +from anthropic.lib.streaming import ParsedMessageStreamEvent from anthropic.types.message import Message from anthropic.resources.messages import DEPRECATED_MODELS from anthropic.lib.streaming._messages import TRACKS_TOOL_INPUT @@ -25,12 +25,6 @@ _T = TypeVar("_T") -# the accumulator must wrap the raw parser error with context and echo the offending JSON -INVALID_TOOL_JSON_ERROR = ( - r"^Unable to parse tool parameter JSON from model\. Please retry your request or adjust your prompt\. " - r'Error: .+\. JSON: \{"location": "Paris", "unit": celsius\}$' -) - def assert_basic_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: assert message.id == "msg_4QpJur2dWWDjF6C758FbBw5vm12BaVipnK" @@ -44,8 +38,6 @@ def assert_basic_response(events: list[ParsedMessageStreamEvent[None]], message: content = message.content[0] assert content.type == "text" assert content.text == "Hello there!" - # accumulated blocks must serialize like a non-streaming response: keys the API didn't send stay unset - assert content.to_dict() == {"type": "text", "text": "Hello there!"} assert [e.type for e in events] == [ "message_start", @@ -113,56 +105,6 @@ def assert_tool_use_response(events: list[ParsedMessageStreamEvent[None]], messa ] -def assert_server_tool_use_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: - assert [e.type for e in events] == [ - "message_start", - "content_block_start", - *["content_block_delta", "input_json"] * 6, - "content_block_stop", - "content_block_start", - "content_block_stop", - "content_block_start", - "content_block_delta", - "citation", - "content_block_delta", - "text", - "content_block_delta", - "text", - "content_block_stop", - "message_delta", - ] - - server_tool_use = message.content[0] - assert server_tool_use.type == "server_tool_use" - assert server_tool_use.input == {"query": "anthropic claude release notes"} - - # input_json events must fire for server_tool_use blocks, not just client tool_use - input_json_events = [e for e in events if isinstance(e, InputJsonEvent)] - assert [e.partial_json for e in input_json_events] == [ - "", - '{"query": "', - "anthropic cl", - "aude re", - "lease notes", - '"}', - ] - assert input_json_events[-1].snapshot == {"query": "anthropic claude release notes"} - - -def get_tool_use_response_without_caller() -> Iterator[bytes]: - return (line.replace(b'"caller":{"type":"direct"},', b"") for line in get_response("tool_use_response.txt")) - - -def assert_tool_use_caller_unset(message: Message) -> None: - tool_use = message.content[1] - assert tool_use.type == "tool_use" - assert tool_use.input == {"location": "Paris"} - assert tool_use.caller is None - # an omitted `caller` must stay unset so it doesn't round-trip as `"caller": null`, which the API rejects - assert "caller" not in tool_use.model_fields_set - assert "caller" not in tool_use.to_dict() - - def assert_refusal_response(message: Message) -> None: assert message.stop_reason == "refusal" assert message.stop_details is not None @@ -171,36 +113,6 @@ def assert_refusal_response(message: Message) -> None: assert message.stop_details.explanation == "This request was refused due to policy." -def assert_message_delta_fields_response(message: Message) -> None: - # every field the final `message_delta` carried must land on the accumulated message - assert message.container is not None - assert message.container.id == "container_01AbCdEfGh" - assert message.usage.output_tokens == 8 - assert message.usage.input_tokens == 40 - assert message.usage.cache_creation_input_tokens == 12 - assert message.usage.cache_read_input_tokens == 7 - assert message.usage.output_tokens_details is not None - assert message.usage.output_tokens_details.thinking_tokens == 3 - assert message.usage.server_tool_use is not None - assert message.usage.server_tool_use.web_search_requests == 1 - # never re-sent on `message_delta`, so these must survive from `message_start` - assert message.usage.service_tier == "standard" - assert message.usage.cache_creation is not None - assert message.usage.cache_creation.ephemeral_5m_input_tokens == 10 - - -def assert_message_delta_omitted_usage_response(message: Message) -> None: - # the delta omitted every optional usage key, so the `message_start` values stand - assert message.usage.output_tokens == 8 - assert message.usage.input_tokens == 25 - assert message.usage.cache_creation_input_tokens == 10 - assert message.usage.cache_read_input_tokens == 5 - assert message.usage.service_tier == "priority" - assert message.usage.cache_creation is not None - assert message.usage.cache_creation.ephemeral_5m_input_tokens == 10 - assert message.container is None - - class TestSyncMessages: @pytest.mark.respx(base_url=base_url) def test_basic_response(self, respx_mock: MockRouter) -> None: @@ -281,46 +193,6 @@ def test_tool_use(self, respx_mock: MockRouter) -> None: assert_tool_use_response([event for event in stream], stream.get_final_message()) - @pytest.mark.respx(base_url=base_url) - def test_server_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("server_tool_use_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_server_tool_use_response([event for event in stream], stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_tool_use_invalid_json(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("tool_use_invalid_json_response.txt")) - ) - - with pytest.raises(ValueError, match=INVALID_TOOL_JSON_ERROR): - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What's the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - stream.until_done() - - @pytest.mark.respx(base_url=base_url) - def test_tool_use_caller_omitted(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_tool_use_response_without_caller()) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What is the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - assert_tool_use_caller_unset(stream.get_final_message()) - @pytest.mark.respx(base_url=base_url) def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( @@ -335,51 +207,29 @@ def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: assert_refusal_response(stream.get_final_message()) @pytest.mark.respx(base_url=base_url) - def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("message_delta_fields_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_fields_response(stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("message_delta_omitted_usage_response.txt")) - ) + def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Test that streaming works when message_start omits usage. - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_omitted_usage_response(stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.filterwarnings("error") - def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: - # trailing blank line terminates the final `message_stop` SSE so it is dispatched + Reproduces https://github.com/anthropics/anthropic-sdk-python/issues/1806 + Per Anthropic's streaming docs, message_start can omit usage (e.g. thinking streams). + The accumulator should not crash and should initialize usage from message_delta. + """ respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=iter([*get_response("basic_response.txt"), b"\n"])) + return_value=httpx.Response(200, content=get_response("missing_usage_response.txt")) ) with sync_client.messages.stream( max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", + messages=[{"role": "user", "content": "hi"}], + model="claude-test", ) as stream: - stop_event = [event for event in stream][-1] - - assert stop_event.type == "message_stop" - assert stop_event.message.content[0].type == "text" - # must not emit `PydanticSerializationUnexpectedValue` warnings - stop_event.model_dump() - stop_event.model_dump_json() + message = stream.get_final_message() + assert message.usage is not None + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + assert len(message.content) == 1 + assert message.content[0].type == "text" + assert message.content[0].text == "hi" class TestAsyncMessages: @@ -466,49 +316,6 @@ async def test_tool_use(self, respx_mock: MockRouter) -> None: assert_tool_use_response([event async for event in stream], await stream.get_final_message()) - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_server_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("server_tool_use_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_server_tool_use_response([event async for event in stream], await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_tool_use_invalid_json(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("tool_use_invalid_json_response.txt"))) - ) - - with pytest.raises(ValueError, match=INVALID_TOOL_JSON_ERROR): - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What's the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - await stream.until_done() - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_tool_use_caller_omitted(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_tool_use_response_without_caller())) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What is the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - assert_tool_use_caller_unset(await stream.get_final_message()) - @pytest.mark.asyncio @pytest.mark.respx(base_url=base_url) async def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: @@ -525,55 +332,24 @@ async def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> @pytest.mark.asyncio @pytest.mark.respx(base_url=base_url) - async def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("message_delta_fields_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_fields_response(await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response( - 200, content=to_async_iter(get_response("message_delta_omitted_usage_response.txt")) - ) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_omitted_usage_response(await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - @pytest.mark.filterwarnings("error") - async def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: - # trailing blank line terminates the final `message_stop` SSE so it is dispatched + async def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Async version: test that streaming works when message_start omits usage.""" respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(iter([*get_response("basic_response.txt"), b"\n"]))) + return_value=httpx.Response(200, content=to_async_iter(get_response("missing_usage_response.txt"))) ) async with async_client.messages.stream( max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", + messages=[{"role": "user", "content": "hi"}], + model="claude-test", ) as stream: - stop_event = [event async for event in stream][-1] - - assert stop_event.type == "message_stop" - assert stop_event.message.content[0].type == "text" - # must not emit `PydanticSerializationUnexpectedValue` warnings - stop_event.model_dump() - stop_event.model_dump_json() + message = await stream.get_final_message() + assert message.usage is not None + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + assert len(message.content) == 1 + assert message.content[0].type == "text" + assert message.content[0].text == "hi" @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) diff --git a/tests/lib/test_aws.py b/tests/lib/test_aws.py index c81de4751..2ab04ec89 100644 --- a/tests/lib/test_aws.py +++ b/tests/lib/test_aws.py @@ -1,6 +1,5 @@ import re -import threading -from typing import Dict, List, cast +from typing import cast from typing_extensions import Protocol import httpx @@ -456,27 +455,3 @@ def test_scoped_bearer_client_helper_on_aws_async() -> None: assert isinstance(scoped, AsyncAnthropicAWS) assert scoped._use_sigv4 is True assert scoped.workspace_id == "ws-123" - - -@pytest.mark.asyncio() -async def test_sigv4_signing_runs_off_event_loop_async(monkeypatch: pytest.MonkeyPatch) -> None: - client = AsyncAnthropicAWS( - aws_access_key="AKID", - aws_secret_key="secret", - aws_region="us-east-1", - workspace_id="ws-123", - ) - signing_threads: List[int] = [] - - def fake_get_auth_headers(**_: object) -> Dict[str, str]: - signing_threads.append(threading.get_ident()) - return {"Authorization": "AWS4-HMAC-SHA256 stub"} - - monkeypatch.setattr("anthropic.lib.aws._auth.get_auth_headers", fake_get_auth_headers) - - request = httpx.Request("POST", "https://aws-external-anthropic.us-east-1.api.aws/v1/messages", content=b"{}") - await client._prepare_request(request) - - assert len(signing_threads) == 1 - assert signing_threads[0] != threading.get_ident() - assert request.headers["Authorization"] == "AWS4-HMAC-SHA256 stub" diff --git a/tests/lib/test_aws_auth.py b/tests/lib/test_aws_auth.py index 2d1ab2510..64c72f756 100644 --- a/tests/lib/test_aws_auth.py +++ b/tests/lib/test_aws_auth.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from unittest.mock import patch import httpx import pytest @@ -583,28 +584,20 @@ def test_uppercases_method(self) -> None: ) assert "Authorization" in headers - def test_raises_on_missing_credentials(self, monkeypatch: pytest.MonkeyPatch) -> None: - class NoCredentialsSession: - region_name = "us-east-1" - - def get_credentials(self) -> None: - return None - - def fake_get_session(**_kwargs: object) -> NoCredentialsSession: - return NoCredentialsSession() - - monkeypatch.setattr("anthropic.lib.aws._auth._get_session", fake_get_session) - - with pytest.raises(RuntimeError, match="Could not resolve AWS credentials"): - get_auth_headers( - method="POST", - url="https://gateway.us-east-1.api.aws/v1/messages", - headers=httpx.Headers({}), - aws_access_key=None, - aws_secret_key=None, - aws_session_token=None, - region="us-east-1", - profile=None, - data="{}", - service_name="aws-external-anthropic", - ) + def test_raises_on_missing_credentials(self) -> None: + with patch("anthropic.lib.aws._auth._get_session") as mock_session: + mock_session.return_value.get_credentials.return_value = None + mock_session.return_value.region_name = "us-east-1" + with pytest.raises(RuntimeError, match="Could not resolve AWS credentials"): + get_auth_headers( + method="POST", + url="https://gateway.us-east-1.api.aws/v1/messages", + headers=httpx.Headers({}), + aws_access_key=None, + aws_secret_key=None, + aws_session_token=None, + region="us-east-1", + profile=None, + data="{}", + service_name="aws-external-anthropic", + ) diff --git a/tests/lib/test_bedrock.py b/tests/lib/test_bedrock.py index 5ce4b9b14..2bfb458a1 100644 --- a/tests/lib/test_bedrock.py +++ b/tests/lib/test_bedrock.py @@ -1,8 +1,6 @@ import re -import json import typing as t import tempfile -import threading from typing import TypedDict, cast from typing_extensions import Protocol @@ -10,8 +8,7 @@ import pytest from respx import MockRouter -from anthropic import AnthropicBedrock, AsyncAnthropicBedrock, beta_tool, beta_async_tool -from anthropic._compat import PYDANTIC_V1 +from anthropic import AnthropicBedrock, AsyncAnthropicBedrock from anthropic.lib.bedrock._stream_decoder import _chunk_bytes_to_sse sync_client = AnthropicBedrock( @@ -322,130 +319,3 @@ def test_async_copy_x_stainless_helper_header_appends() -> None: client = async_client.with_options(default_headers={"x-stainless-helper": "parent"}) copied = client.with_options(default_headers={"x-stainless-helper": "child"}) assert copied.default_headers["x-stainless-helper"] == "parent, child" - - -def _bedrock_message(content: t.List[t.Dict[str, t.Any]], stop_reason: str) -> httpx.Response: - return httpx.Response( - 200, - json={ - "id": "msg_01", - "type": "message", - "role": "assistant", - "model": "claude", - "content": content, - "stop_reason": stop_reason, - "stop_sequence": None, - "usage": {"input_tokens": 10, "output_tokens": 5}, - }, - ) - - -def _tool_runner_responses() -> t.List[httpx.Response]: - return [ - _bedrock_message( - [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}}], - "tool_use", - ), - _bedrock_message([{"type": "text", "text": "It is sunny in Paris."}], "end_turn"), - ] - - -def _assert_tool_runner_calls(calls: t.List[MockRequestCall]) -> None: - assert len(calls) == 2 - for call in calls: - assert call.request.url.path == "/model/anthropic.claude-haiku-4-5-20251001-v1:0/invoke" - assert call.request.headers["Authorization"].startswith("AWS4-HMAC-SHA256 ") - assert "x-amz-date" in call.request.headers - body = json.loads(call.request.content) - assert "model" not in body and "stream" not in body and "output_format" not in body - assert body["anthropic_version"] == "bedrock-2023-05-31" - second = json.loads(calls[1].request.content) - assert [m["role"] for m in second["messages"]] == ["user", "assistant", "user"] - assert second["messages"][1]["content"][0]["type"] == "tool_use" - assert second["messages"][2]["content"][0] == { - "type": "tool_result", - "tool_use_id": "toolu_01", - "content": "sunny in Paris", - } - - -@pytest.mark.skipif(PYDANTIC_V1, reason="tool functions are only supported with pydantic v2") -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.respx() -def test_beta_tool_runner_routes_through_invoke(respx_mock: MockRouter) -> None: - @beta_tool - def get_weather(city: str) -> str: - """Get the weather. - - Args: - city: city name - """ - return f"sunny in {city}" - - respx_mock.post(re.compile(r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/model/.*")).mock( - side_effect=_tool_runner_responses() - ) - - final = sync_client.beta.messages.tool_runner( - model="anthropic.claude-haiku-4-5-20251001-v1:0", - max_tokens=256, - messages=[{"role": "user", "content": "weather in Paris?"}], - tools=[get_weather], - ).until_done() - - assert final.stop_reason == "end_turn" - _assert_tool_runner_calls(cast("list[MockRequestCall]", respx_mock.calls)) - - -@pytest.mark.skipif(PYDANTIC_V1, reason="tool functions are only supported with pydantic v2") -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.respx() -@pytest.mark.asyncio() -async def test_beta_tool_runner_routes_through_invoke_async(respx_mock: MockRouter) -> None: - @beta_async_tool - async def get_weather(city: str) -> str: - """Get the weather. - - Args: - city: city name - """ - return f"sunny in {city}" - - respx_mock.post(re.compile(r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/model/.*")).mock( - side_effect=_tool_runner_responses() - ) - - final = await async_client.beta.messages.tool_runner( - model="anthropic.claude-haiku-4-5-20251001-v1:0", - max_tokens=256, - messages=[{"role": "user", "content": "weather in Paris?"}], - tools=[get_weather], - ).until_done() - - assert final.stop_reason == "end_turn" - _assert_tool_runner_calls(cast("list[MockRequestCall]", respx_mock.calls)) - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_beta_messages_helpers_are_bound() -> None: - for client in (sync_client, async_client): - for name in ("create", "parse", "stream", "tool_runner"): - assert callable(getattr(client.beta.messages, name)) - - -@pytest.mark.asyncio() -async def test_sigv4_signing_runs_off_event_loop_async(monkeypatch: pytest.MonkeyPatch) -> None: - signing_threads: t.List[int] = [] - - def fake_get_auth_headers(**_: object) -> t.Dict[str, str]: - signing_threads.append(threading.get_ident()) - return {"Authorization": "AWS4-HMAC-SHA256 stub"} - - monkeypatch.setattr("anthropic.lib.bedrock._auth.get_auth_headers", fake_get_auth_headers) - - request = httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/model/x/invoke", content=b"{}") - await async_client._prepare_request(request) - - assert len(signing_threads) == 1 - assert signing_threads[0] != threading.get_ident() - assert request.headers["Authorization"] == "AWS4-HMAC-SHA256 stub" diff --git a/tests/lib/test_bedrock_mantle.py b/tests/lib/test_bedrock_mantle.py index 5f35209a0..017326325 100644 --- a/tests/lib/test_bedrock_mantle.py +++ b/tests/lib/test_bedrock_mantle.py @@ -1,32 +1,12 @@ from __future__ import annotations -import threading +from unittest.mock import MagicMock, patch import httpx import pytest from anthropic import AnthropicBedrockMantle, AsyncAnthropicBedrockMantle -MANTLE_MESSAGES_URL = "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages" - - -class GetAuthHeadersRecorder: - """Stands in for `anthropic.lib.bedrock._mantle.get_auth_headers` and records each call's kwargs.""" - - def __init__(self) -> None: - self.calls: list[dict[str, object]] = [] - - def __call__(self, **kwargs: object) -> dict[str, str]: - self.calls.append(kwargs) - return {"Authorization": "AWS4-HMAC-SHA256 ...", "X-Amz-Date": "20260327T000000Z"} - - -@pytest.fixture -def get_auth_headers_recorder(monkeypatch: pytest.MonkeyPatch) -> GetAuthHeadersRecorder: - recorder = GetAuthHeadersRecorder() - monkeypatch.setattr("anthropic.lib.bedrock._mantle.get_auth_headers", recorder) - return recorder - class TestBaseURL: def test_derives_base_url_from_region(self) -> None: @@ -43,19 +23,19 @@ def test_different_region(self) -> None: ) assert str(client.base_url).startswith("https://bedrock-mantle.us-west-2.api.aws/anthropic") - def test_uses_base_url_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "https://custom.example.com") - client = AnthropicBedrockMantle( - api_key="test-key", - ) + def test_uses_base_url_env_var(self) -> None: + with patch.dict("os.environ", {"ANTHROPIC_BEDROCK_MANTLE_BASE_URL": "https://custom.example.com"}): + client = AnthropicBedrockMantle( + api_key="test-key", + ) assert str(client.base_url).startswith("https://custom.example.com") - def test_base_url_arg_takes_precedence_over_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "https://from-env.example.com") - client = AnthropicBedrockMantle( - api_key="test-key", - base_url="https://from-arg.example.com", - ) + def test_base_url_arg_takes_precedence_over_env(self) -> None: + with patch.dict("os.environ", {"ANTHROPIC_BEDROCK_MANTLE_BASE_URL": "https://from-env.example.com"}): + client = AnthropicBedrockMantle( + api_key="test-key", + base_url="https://from-arg.example.com", + ) assert str(client.base_url).startswith("https://from-arg.example.com") def test_raises_without_region_or_base_url(self) -> None: @@ -66,63 +46,73 @@ def test_raises_without_region_or_base_url(self) -> None: class TestSigV4ServiceName: - def test_uses_bedrock_mantle_service_name(self, get_auth_headers_recorder: GetAuthHeadersRecorder) -> None: + def test_uses_bedrock_mantle_service_name(self) -> None: client = AnthropicBedrockMantle( aws_access_key="AKID", aws_secret_key="secret", aws_region="us-east-1", ) - request = httpx.Request( - "POST", - MANTLE_MESSAGES_URL, - headers={"content-type": "application/json"}, - content=b'{"model": "claude-sonnet-4-20250514"}', - ) - client._prepare_request(request) + mock_request = MagicMock(spec=httpx.Request) + mock_request.method = "POST" + mock_request.url = httpx.URL("https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages") + mock_request.headers = httpx.Headers({"content-type": "application/json"}) + mock_request.read.return_value = b'{"model": "claude-sonnet-4-20250514"}' + + with patch("anthropic.lib.bedrock._mantle.get_auth_headers") as mock_auth: + mock_auth.return_value = { + "Authorization": "AWS4-HMAC-SHA256 ...", + "X-Amz-Date": "20260327T000000Z", + } + client._prepare_request(mock_request) - assert len(get_auth_headers_recorder.calls) == 1 - assert get_auth_headers_recorder.calls[0]["service_name"] == "bedrock-mantle" - assert request.headers["Authorization"] == "AWS4-HMAC-SHA256 ..." + mock_auth.assert_called_once() + call_kwargs = mock_auth.call_args.kwargs + assert call_kwargs["service_name"] == "bedrock-mantle" class TestEnvironmentVariables: - def test_uses_mantle_api_key_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "mantle-key") - client = AnthropicBedrockMantle( - base_url="https://example.com", - ) + def test_uses_mantle_api_key_env_var(self) -> None: + with patch.dict("os.environ", {"AWS_BEARER_TOKEN_BEDROCK": "mantle-key"}, clear=False): + client = AnthropicBedrockMantle( + base_url="https://example.com", + ) assert client.api_key == "mantle-key" - def test_falls_back_to_aws_api_key_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("ANTHROPIC_AWS_API_KEY", "aws-key") - client = AnthropicBedrockMantle( - base_url="https://example.com", - ) + def test_falls_back_to_aws_api_key_env_var(self) -> None: + with patch.dict("os.environ", {"ANTHROPIC_AWS_API_KEY": "aws-key"}, clear=False): + client = AnthropicBedrockMantle( + base_url="https://example.com", + ) assert client.api_key == "aws-key" - def test_mantle_api_key_takes_precedence_over_aws(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "mantle-key") - monkeypatch.setenv("ANTHROPIC_AWS_API_KEY", "aws-key") - client = AnthropicBedrockMantle( - base_url="https://example.com", - ) + def test_mantle_api_key_takes_precedence_over_aws(self) -> None: + with patch.dict( + "os.environ", + { + "AWS_BEARER_TOKEN_BEDROCK": "mantle-key", + "ANTHROPIC_AWS_API_KEY": "aws-key", + }, + clear=False, + ): + client = AnthropicBedrockMantle( + base_url="https://example.com", + ) assert client.api_key == "mantle-key" - def test_region_from_aws_region_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("AWS_REGION", "eu-west-1") - client = AnthropicBedrockMantle( - api_key="test-key", - ) + def test_region_from_aws_region_env_var(self) -> None: + with patch.dict("os.environ", {"AWS_REGION": "eu-west-1"}, clear=False): + client = AnthropicBedrockMantle( + api_key="test-key", + ) assert client.aws_region == "eu-west-1" assert client.base_url == "https://bedrock-mantle.eu-west-1.api.aws/anthropic/" - def test_region_from_aws_default_region_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("AWS_REGION", raising=False) - monkeypatch.setenv("AWS_DEFAULT_REGION", "ap-southeast-1") - client = AnthropicBedrockMantle( - api_key="test-key", - ) + def test_region_from_aws_default_region_env_var(self) -> None: + with patch.dict("os.environ", {"AWS_DEFAULT_REGION": "ap-southeast-1"}, clear=False): + client = AnthropicBedrockMantle( + api_key="test-key", + ) assert client.aws_region == "ap-southeast-1" @@ -204,17 +194,17 @@ def test_skip_auth_returns_empty_auth_headers(self) -> None: class TestSkipAuth: - def test_skip_auth_does_not_sign_request(self, get_auth_headers_recorder: GetAuthHeadersRecorder) -> None: + def test_skip_auth_does_not_sign_request(self) -> None: client = AnthropicBedrockMantle( skip_auth=True, base_url="https://example.com", ) - request = httpx.Request("POST", "https://example.com/v1/messages", content=b"{}") - client._prepare_request(request) + mock_request = MagicMock(spec=httpx.Request) - assert get_auth_headers_recorder.calls == [] - assert "Authorization" not in request.headers + with patch("anthropic.lib.bedrock._mantle.get_auth_headers") as mock_auth: + client._prepare_request(mock_request) + mock_auth.assert_not_called() class TestPartialCredentials: @@ -254,28 +244,6 @@ def test_async_base_url_from_region(self) -> None: ) assert client.base_url == "https://bedrock-mantle.us-east-1.api.aws/anthropic/" - @pytest.mark.asyncio() - async def test_sigv4_signing_runs_off_event_loop(self, monkeypatch: pytest.MonkeyPatch) -> None: - client = AsyncAnthropicBedrockMantle( - aws_access_key="AKID", - aws_secret_key="secret", - aws_region="us-east-1", - ) - signing_threads: list[int] = [] - - def fake_get_auth_headers(**_: object) -> dict[str, str]: - signing_threads.append(threading.get_ident()) - return {"Authorization": "AWS4-HMAC-SHA256 stub"} - - monkeypatch.setattr("anthropic.lib.bedrock._mantle.get_auth_headers", fake_get_auth_headers) - - request = httpx.Request("POST", "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages", content=b"{}") - await client._prepare_request(request) - - assert len(signing_threads) == 1 - assert signing_threads[0] != threading.get_ident() - assert request.headers["Authorization"] == "AWS4-HMAC-SHA256 stub" - class TestCopy: def test_copy_preserves_config(self) -> None: diff --git a/tests/lib/test_credentials.py b/tests/lib/test_credentials.py index 1cbcb5494..3f5358a05 100644 --- a/tests/lib/test_credentials.py +++ b/tests/lib/test_credentials.py @@ -31,7 +31,6 @@ ) from anthropic._version import __version__ from anthropic._base_client import FinalRequestOptions -from anthropic.lib.credentials import BaseURLBoundProvider from anthropic.lib.credentials._constants import ( TOKEN_ENDPOINT, GRANT_TYPE_JWT_BEARER, @@ -41,9 +40,6 @@ BASE_URL = "https://api.anthropic.com" TOKEN_URL = f"{BASE_URL}{TOKEN_ENDPOINT}" -# A second deployment, for tests that build clients against two hosts at once. -OTHER_BASE_URL = "https://api.other.example" -OTHER_TOKEN_URL = f"{OTHER_BASE_URL}{TOKEN_ENDPOINT}" _ALL_ENV = [ "ANTHROPIC_API_KEY", @@ -171,17 +167,6 @@ def test_with_expiry(self) -> None: assert tok.expires_at == 123 -class TestBaseURLBoundProvider: - def test_deployment_bound_providers_implement_it(self) -> None: - assert isinstance(_workload_credentials(), BaseURLBoundProvider) - assert isinstance(CredentialsFile("default"), BaseURLBoundProvider) - assert issubclass(InMemoryConfig, CredentialsFile) - - def test_other_providers_do_not(self) -> None: - assert not isinstance(StaticToken("tok"), BaseURLBoundProvider) - assert not isinstance(EnvToken(), BaseURLBoundProvider) - - class TestStaticToken: def test_returns_token(self) -> None: p = StaticToken("sk-ant-oat01-static") @@ -424,58 +409,6 @@ def write(profile: str, *, with_base_url: Optional[str]) -> None: with pytest.raises(AnthropicError, match="https"): CredentialsFile("p3").bind_base_url("http://evil.example") - @pytest.mark.respx() - def test_for_base_url_federation_profile(self, respx_mock: MockRouter, tmp_path: pathlib.Path) -> None: - """A second host gets its own provider exchanging there; the original - keeps exchanging at the first host, and only the original uses the - profile's on-disk cache.""" - jwt_path = tmp_path / "jwt" - jwt_path.write_text("ext-jwt") - _write_profile( - tmp_path, - "fed", - { - "type": "workload_identity", - "identity_token": {"source": "file", "path": str(jwt_path)}, - "federation_rule_id": "fdrl_01abc", - "organization_id": "org-uuid", - }, - ) - _mock_token_exchange(respx_mock, BASE_URL, "tok-primary") - _mock_token_exchange(respx_mock, OTHER_BASE_URL, "tok-other") - creds = CredentialsFile("fed") - assert creds.for_base_url(BASE_URL) is creds - assert creds.for_base_url(f"{BASE_URL}/") is creds - other = creds.for_base_url(OTHER_BASE_URL) - assert other is not creds - - assert other().token == "tok-other" - assert not (tmp_path / "credentials" / "fed.json").exists() - assert creds().token == "tok-primary" - assert json.loads((tmp_path / "credentials" / "fed.json").read_text())["access_token"] == "tok-primary" - # The other host's provider never consults the file the original wrote. - assert other().token == "tok-other" - assert [str(r.url) for r in _requests_to(respx_mock, OTHER_BASE_URL)] == [OTHER_TOKEN_URL, OTHER_TOKEN_URL] - assert [str(r.url) for r in _requests_to(respx_mock, BASE_URL)] == [TOKEN_URL] - - def test_for_base_url_returns_self_when_bind_is_moot(self, tmp_path: pathlib.Path) -> None: - """No per-host provider is made when the bind can't change where the - tokens come from: the profile pins its own ``base_url``, or it is a - user_oauth profile whose refresh token is tied to the issuing host.""" - _write_profile( - tmp_path, - "pinned", - {"type": "workload_identity", "federation_rule_id": "fdrl_x", "base_url": "https://pinned.example"}, - ) - pinned = CredentialsFile("pinned") - assert pinned.for_base_url(BASE_URL) is pinned - assert pinned.for_base_url(OTHER_BASE_URL) is pinned - - _write_profile(tmp_path, "oauth", {"type": "authorized_user", "client_id": "cid"}) - oauth = CredentialsFile("oauth") - assert oauth.for_base_url(BASE_URL) is oauth - assert oauth.for_base_url(OTHER_BASE_URL) is oauth - @pytest.mark.respx(base_url=BASE_URL) def test_workload_identity_disk_cache_stale_reexchange( self, respx_mock: MockRouter, tmp_path: pathlib.Path @@ -1105,33 +1038,6 @@ def test_bind_base_url_http_rejected(self) -> None: with pytest.raises(AnthropicError, match="must use https"): creds.bind_base_url("http://evil.example") - @pytest.mark.respx() - def test_for_base_url(self, respx_mock: MockRouter) -> None: - """The first bind (and re-binds to the same host) happen in place; a - bind to another host yields a separate provider and leaves the - original's exchange endpoint alone.""" - _mock_token_exchange(respx_mock, BASE_URL, "tok-primary") - _mock_token_exchange(respx_mock, OTHER_BASE_URL, "tok-other") - creds = _workload_credentials() - assert creds.for_base_url(f"{BASE_URL}/") is creds - assert creds.for_base_url(BASE_URL) is creds - other = creds.for_base_url(OTHER_BASE_URL) - assert other is not creds - - assert other().token == "tok-other" - assert creds().token == "tok-primary" - # The per-host provider borrows the original's connection pool, so - # closing it must not take the original down with it. - other.close() - assert creds().token == "tok-primary" - assert [str(r.url) for r in _requests_to(respx_mock, BASE_URL)] == [TOKEN_URL, TOKEN_URL] - - def test_for_base_url_http_rejected(self) -> None: - creds = _workload_credentials() - creds.for_base_url(BASE_URL) - with pytest.raises(AnthropicError, match="must use https"): - creds.for_base_url("http://evil.example") - def test_exchange_federation_assertion_http_rejected(self) -> None: with pytest.raises(AnthropicError, match="must use https"): exchange_federation_assertion( @@ -1953,60 +1859,21 @@ def _mock_token_endpoint(respx_mock: MockRouter) -> None: ) -def _message_response() -> httpx.Response: - return httpx.Response( - 200, - json={ - "id": "msg_01", - "type": "message", - "role": "assistant", - "model": "claude-opus-4-5", - "content": [{"type": "text", "text": "hi"}], - "stop_reason": "end_turn", - "stop_sequence": None, - "usage": {"input_tokens": 1, "output_tokens": 1}, - }, - ) - - def _mock_messages_endpoint(respx_mock: MockRouter) -> None: - respx_mock.post(f"{BASE_URL}/v1/messages").mock(return_value=_message_response()) - - -def _mock_token_exchange(respx_mock: MockRouter, base_url: str, token: str) -> None: - respx_mock.post(f"{base_url}{TOKEN_ENDPOINT}").mock( - return_value=httpx.Response(200, json={"access_token": token, "token_type": "Bearer", "expires_in": 600}) - ) - - -def _mock_deployment(respx_mock: MockRouter, base_url: str, token: str) -> None: - """Mock one deployment: its token endpoint mints ``token`` and its - messages endpoint accepts anything. Pair with :func:`_requests_to` to - check that a client only ever talks to (and presents tokens from) the - deployment it was built for.""" - _mock_token_exchange(respx_mock, base_url, token) - respx_mock.post(f"{base_url}/v1/messages").mock(return_value=_message_response()) - - -def _requests_to(respx_mock: MockRouter, base_url: str) -> List[httpx.Request]: - calls = cast("list[MockRequestCall]", respx_mock.calls) - return [c.request for c in calls if str(c.request.url).startswith(f"{base_url}/")] - - -def _assert_exchanged_and_called_own_deployment(respx_mock: MockRouter, base_url: str, token: str) -> None: - """The traffic seen by ``base_url`` is exactly one exchange followed by - one request bearing the token that exchange minted.""" - requests = _requests_to(respx_mock, base_url) - assert [str(r.url) for r in requests] == [f"{base_url}{TOKEN_ENDPOINT}", f"{base_url}/v1/messages"] - assert json.loads(requests[0].content)["assertion"] == "ext-jwt" - assert requests[1].headers["Authorization"] == f"Bearer {token}" - - -def _workload_credentials() -> WorkloadIdentityCredentials: - return WorkloadIdentityCredentials( - identity_token_provider=lambda: "ext-jwt", - federation_rule_id="fdrl_01abc", - organization_id="org-uuid", + respx_mock.post(f"{BASE_URL}/v1/messages").mock( + return_value=httpx.Response( + 200, + json={ + "id": "msg_01", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-5", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) ) @@ -2385,119 +2252,6 @@ def test_with_options_alias(self) -> None: assert copied.max_retries == 7 assert copied.credentials is client.credentials - @pytest.mark.respx() - def test_copy_with_different_base_url_exchanges_per_client(self, respx_mock: MockRouter) -> None: - """``copy(base_url=...)`` must not move the parent's token exchange: - each client exchanges its assertion at its own host and only ever - presents the token that host minted.""" - _mock_deployment(respx_mock, BASE_URL, "tok-primary") - _mock_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - parent = Anthropic(credentials=_workload_credentials()) - child = parent.copy(base_url=OTHER_BASE_URL) - - # Child first: a parent whose provider had been rebound by the copy - # would now exchange at (and send the other host's token to) the - # wrong deployment. - _send_message(child) - _send_message(parent) - - _assert_exchanged_and_called_own_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - _assert_exchanged_and_called_own_deployment(respx_mock, BASE_URL, "tok-primary") - - @pytest.mark.respx() - def test_shared_provider_binds_per_client(self, respx_mock: MockRouter) -> None: - """The same isolation holds when one provider instance is passed to - two clients directly: the first client keeps its binding.""" - _mock_deployment(respx_mock, BASE_URL, "tok-primary") - _mock_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - creds = _workload_credentials() - primary = Anthropic(credentials=creds) - other = Anthropic(credentials=creds, base_url=OTHER_BASE_URL) - assert primary.credentials is creds - - _send_message(other) - _send_message(primary) - - _assert_exchanged_and_called_own_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - _assert_exchanged_and_called_own_deployment(respx_mock, BASE_URL, "tok-primary") - - @pytest.mark.respx() - def test_copy_with_same_base_url_shares_token_exchange(self, respx_mock: MockRouter) -> None: - """A copy targeting the same deployment (here spelled without the - trailing slash the client normalises to) keeps sharing the parent's - provider and cache: one exchange serves both.""" - _mock_deployment(respx_mock, BASE_URL, "tok-primary") - parent = Anthropic(credentials=_workload_credentials()) - child = parent.copy(base_url=BASE_URL) - assert child.credentials is parent.credentials - - _send_message(child) - _send_message(parent) - - requests = _requests_to(respx_mock, BASE_URL) - assert [str(r.url) for r in requests] == [TOKEN_URL, f"{BASE_URL}/v1/messages", f"{BASE_URL}/v1/messages"] - assert all(r.headers["Authorization"] == "Bearer tok-primary" for r in requests[1:]) - - @pytest.mark.respx() - def test_copy_of_federation_profile_client_exchanges_per_client( - self, respx_mock: MockRouter, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Profile-backed federation: the copy exchanges at its own host and - stays out of the profile's on-disk token cache, which belongs to the - parent's deployment.""" - monkeypatch.setattr("anthropic.lib.credentials._constants._config_dir", lambda: tmp_path) - jwt_path = tmp_path / "jwt" - jwt_path.write_text("ext-jwt") - _write_profile( - tmp_path, - "fed", - { - "type": "workload_identity", - "identity_token": {"source": "file", "path": str(jwt_path)}, - "federation_rule_id": "fdrl_01abc", - "organization_id": "org-uuid", - }, - ) - _mock_deployment(respx_mock, BASE_URL, "tok-primary") - _mock_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - parent = Anthropic(profile="fed") - child = parent.copy(base_url=OTHER_BASE_URL) - - _send_message(child) - _send_message(parent) - - _assert_exchanged_and_called_own_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - _assert_exchanged_and_called_own_deployment(respx_mock, BASE_URL, "tok-primary") - on_disk = json.loads((tmp_path / "credentials" / "fed.json").read_text()) - assert on_disk["access_token"] == "tok-primary" - - @pytest.mark.respx() - def test_copy_of_user_oauth_profile_client_keeps_refresh_endpoint( - self, respx_mock: MockRouter, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A user_oauth profile's refresh token belongs to the deployment that - issued it: a copy pointed at another host still refreshes at the - parent's host rather than posting the refresh token elsewhere.""" - monkeypatch.setattr("anthropic.lib.credentials._constants._config_dir", lambda: tmp_path) - _write_profile( - tmp_path, - "oauth", - {"type": "authorized_user", "client_id": "cid"}, - {"access_token": "stale", "expires_at": int(time.time()) - 1, "refresh_token": "rt"}, - ) - _mock_token_exchange(respx_mock, BASE_URL, "tok-refreshed") - respx_mock.post(f"{OTHER_BASE_URL}/v1/messages").mock(return_value=_message_response()) - parent = Anthropic(profile="oauth") - child = parent.copy(base_url=OTHER_BASE_URL) - assert child.credentials is parent.credentials - - _send_message(child) - - assert [str(r.url) for r in _requests_to(respx_mock, BASE_URL)] == [TOKEN_URL] - other_requests = _requests_to(respx_mock, OTHER_BASE_URL) - assert [str(r.url) for r in other_requests] == [f"{OTHER_BASE_URL}/v1/messages"] - assert other_requests[0].headers["Authorization"] == "Bearer tok-refreshed" - @pytest.mark.respx() def test_config_param_builds_in_memory_federation(self, respx_mock: MockRouter, tmp_path: pathlib.Path) -> None: """``Anthropic(config={...})`` accepts a config-file-shaped dict and @@ -2847,35 +2601,6 @@ async def test_async_workload_identity_exchange(self, respx_mock: MockRouter) -> assert len(msg_calls) == 1 assert msg_calls[0].request.headers["Authorization"] == "Bearer sk-ant-oat01-test" - @pytest.mark.respx() - async def test_async_copy_with_different_base_url_exchanges_per_client(self, respx_mock: MockRouter) -> None: - """Async mirror of the sync test: ``copy(base_url=...)`` leaves the - parent exchanging at, and presenting tokens from, its own host.""" - _mock_deployment(respx_mock, BASE_URL, "tok-primary") - _mock_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - parent = AsyncAnthropic(credentials=_workload_credentials()) - child = parent.copy(base_url=OTHER_BASE_URL) - - await _send_message_async(child) - await _send_message_async(parent) - - _assert_exchanged_and_called_own_deployment(respx_mock, OTHER_BASE_URL, "tok-other") - _assert_exchanged_and_called_own_deployment(respx_mock, BASE_URL, "tok-primary") - - @pytest.mark.respx() - async def test_async_copy_with_same_base_url_shares_token_exchange(self, respx_mock: MockRouter) -> None: - _mock_deployment(respx_mock, BASE_URL, "tok-primary") - parent = AsyncAnthropic(credentials=_workload_credentials()) - child = parent.copy(base_url=BASE_URL) - assert child.credentials is parent.credentials - - await _send_message_async(child) - await _send_message_async(parent) - - requests = _requests_to(respx_mock, BASE_URL) - assert [str(r.url) for r in requests] == [TOKEN_URL, f"{BASE_URL}/v1/messages", f"{BASE_URL}/v1/messages"] - assert all(r.headers["Authorization"] == "Bearer tok-primary" for r in requests[1:]) - @pytest.mark.respx() async def test_async_authorized_user_refresh_flow( self, @@ -3360,36 +3085,6 @@ def test_env_api_key_alone_does_not_warn( assert client.api_key == "sk-from-env" assert not any("takes precedence" in r.message for r in caplog.records) - # -- step 2: empty env values are unset, not empty credentials ----------- - - @pytest.mark.parametrize("client_cls", [Anthropic, AsyncAnthropic]) - def test_empty_env_auth_token_is_unset( - self, clean_env: pytest.MonkeyPatch, client_cls: type[Anthropic] | type[AsyncAnthropic] - ) -> None: - """``ANTHROPIC_AUTH_TOKEN=`` (present but empty) must not produce a - malformed ``Authorization: Bearer `` header alongside the api key.""" - clean_env.setenv("ANTHROPIC_API_KEY", "sk-from-env") - clean_env.setenv("ANTHROPIC_AUTH_TOKEN", "") - client = client_cls() - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) - assert request.headers.get("Authorization") is None - assert request.headers.get("X-Api-Key") == "sk-from-env" - assert client.auth_token is None - - @pytest.mark.parametrize("client_cls", [Anthropic, AsyncAnthropic]) - def test_empty_env_credentials_fall_through_to_no_auth( - self, clean_env: pytest.MonkeyPatch, client_cls: type[Anthropic] | type[AsyncAnthropic] - ) -> None: - """Both env vars empty → same "no auth configured" error as unset, - rather than sending empty ``X-Api-Key`` / ``Authorization: Bearer ``.""" - clean_env.setenv("ANTHROPIC_API_KEY", "") - clean_env.setenv("ANTHROPIC_AUTH_TOKEN", "") - client = client_cls() - with pytest.raises(TypeError, match="Could not resolve authentication method"): - client._build_request(FinalRequestOptions(method="get", url="/foo")) - assert client.api_key is None - assert client.auth_token is None - # -- step 1 alone: credentials= works normally -------------------------- def test_credentials_only_still_works(self, caplog: pytest.LogCaptureFixture) -> None: diff --git a/tests/lib/test_vertex.py b/tests/lib/test_vertex.py index 8b418fde4..4adcc8253 100644 --- a/tests/lib/test_vertex.py +++ b/tests/lib/test_vertex.py @@ -2,16 +2,15 @@ import os import sys -import json -from typing import Any, Dict, List, cast +from typing import Any, cast +from unittest.mock import Mock from typing_extensions import Protocol import httpx import pytest from respx import MockRouter -from anthropic import AnthropicVertex, AsyncAnthropicVertex, beta_tool, beta_async_tool -from anthropic._compat import PYDANTIC_V1 +from anthropic import AnthropicVertex, AsyncAnthropicVertex from anthropic.lib.vertex._auth import refresh_auth from anthropic.lib._extras._common import MissingDependencyError @@ -22,53 +21,6 @@ class MockRequestCall(Protocol): request: httpx.Request -TOOL_RUNNER_URL = "https://region-aiplatform.googleapis.com/v1/projects/project/locations/region/publishers/anthropic/models/claude-haiku-4-5@20251001:rawPredict" - - -def _vertex_message(content: List[Dict[str, Any]], stop_reason: str) -> httpx.Response: - return httpx.Response( - 200, - json={ - "id": "msg_01", - "type": "message", - "role": "assistant", - "model": "claude", - "content": content, - "stop_reason": stop_reason, - "stop_sequence": None, - "usage": {"input_tokens": 10, "output_tokens": 5}, - }, - ) - - -def _tool_runner_responses() -> List[httpx.Response]: - return [ - _vertex_message( - [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}}], - "tool_use", - ), - _vertex_message([{"type": "text", "text": "It is sunny in Paris."}], "end_turn"), - ] - - -def _assert_tool_runner_calls(calls: List[MockRequestCall]) -> None: - assert len(calls) == 2 - for call in calls: - assert call.request.url == TOOL_RUNNER_URL - assert call.request.headers["Authorization"] == "Bearer my-access-token" - body = json.loads(call.request.content) - assert "model" not in body and "stream" not in body and "output_format" not in body - assert body["anthropic_version"] == "vertex-2023-10-16" - second = json.loads(calls[1].request.content) - assert [m["role"] for m in second["messages"]] == ["user", "assistant", "user"] - assert second["messages"][1]["content"][0]["type"] == "tool_use" - assert second["messages"][2]["content"][0] == { - "type": "tool_result", - "tool_use_id": "toolu_01", - "content": "sunny in Paris", - } - - class TestAnthropicVertex: client = AnthropicVertex(region="region", project_id="project", access_token="my-access-token") @@ -219,47 +171,12 @@ def test_env_var_base_url_override(self, monkeypatch: pytest.MonkeyPatch) -> Non ) assert str(client.base_url).rstrip("/") == "https://test.googleapis.com/v1" - @pytest.mark.skipif(PYDANTIC_V1, reason="tool functions are only supported with pydantic v2") - @pytest.mark.respx() - def test_beta_tool_runner_routes_through_raw_predict(self, respx_mock: MockRouter) -> None: - @beta_tool - def get_weather(city: str) -> str: - """Get the weather. - - Args: - city: city name - """ - return f"sunny in {city}" - - respx_mock.post(url__startswith="https://region-aiplatform.googleapis.com/").mock( - side_effect=_tool_runner_responses() - ) - - final = self.client.beta.messages.tool_runner( - model="claude-haiku-4-5@20251001", - max_tokens=256, - messages=[{"role": "user", "content": "weather in Paris?"}], - tools=[get_weather], - ).until_done() - - assert final.stop_reason == "end_turn" - _assert_tool_runner_calls(cast("list[MockRequestCall]", respx_mock.calls)) - - def test_beta_messages_helpers_are_bound(self) -> None: - for name in ("create", "parse", "stream", "tool_runner", "count_tokens"): - assert callable(getattr(self.client.beta.messages, name)) - def test_refresh_without_google_auth_raises_actionable_error(monkeypatch: pytest.MonkeyPatch) -> None: # `None` in sys.modules makes the import fail even when google-auth is installed. monkeypatch.setitem(sys.modules, "google.auth.transport.requests", cast(Any, None)) - - class FakeCredentials: - def refresh(self, _request: object) -> None: - raise AssertionError("should not be reached: building the request needs google-auth") - with pytest.raises(MissingDependencyError, match=r"anthropic\[vertex\]"): - refresh_auth(cast(Any, FakeCredentials())) + refresh_auth(cast(Any, Mock())) class TestAsyncAnthropicVertex: @@ -414,34 +331,3 @@ def test_env_var_base_url_override(self, monkeypatch: pytest.MonkeyPatch) -> Non base_url="https://test.googleapis.com/v1", ) assert str(client.base_url).rstrip("/") == "https://test.googleapis.com/v1" - - @pytest.mark.skipif(PYDANTIC_V1, reason="tool functions are only supported with pydantic v2") - @pytest.mark.respx() - @pytest.mark.asyncio() - async def test_beta_tool_runner_routes_through_raw_predict(self, respx_mock: MockRouter) -> None: - @beta_async_tool - async def get_weather(city: str) -> str: - """Get the weather. - - Args: - city: city name - """ - return f"sunny in {city}" - - respx_mock.post(url__startswith="https://region-aiplatform.googleapis.com/").mock( - side_effect=_tool_runner_responses() - ) - - final = await self.client.beta.messages.tool_runner( - model="claude-haiku-4-5@20251001", - max_tokens=256, - messages=[{"role": "user", "content": "weather in Paris?"}], - tools=[get_weather], - ).until_done() - - assert final.stop_reason == "end_turn" - _assert_tool_runner_calls(cast("list[MockRequestCall]", respx_mock.calls)) - - def test_beta_messages_helpers_are_bound(self) -> None: - for name in ("create", "parse", "stream", "tool_runner", "count_tokens"): - assert callable(getattr(self.client.beta.messages, name)) diff --git a/tests/lib/tools/test_agent_toolset.py b/tests/lib/tools/test_agent_toolset.py index a3c32ad57..8ca5889d1 100644 --- a/tests/lib/tools/test_agent_toolset.py +++ b/tests/lib/tools/test_agent_toolset.py @@ -1,9 +1,7 @@ from __future__ import annotations import os -import re import sys -import time import base64 from typing import Any, cast from pathlib import Path @@ -73,67 +71,6 @@ def test_resolve_path_absolute_inside_workdir(tmp_path: Path) -> None: resolve_path(env, str(tmp_path / "out")) -needs_symlinks = pytest.mark.skipif(sys.platform == "win32", reason="symlink fixtures need a POSIX filesystem") - - -def _symlink_fixture(tmp_path: Path) -> Path: - """Workdir with two symlink cycles, a link that escapes, and a link whose - target spells its way through a cycle to the escaping link.""" - outside = tmp_path / "outside" - outside.mkdir() - (outside / "secret.txt").write_text("SECRET") - work = tmp_path / "work" - work.mkdir() - (work / "loop_a").symlink_to("loop_b") - (work / "loop_b").symlink_to("loop_a") - (work / "self").symlink_to("self") - (work / "evil_link").symlink_to(outside / "secret.txt") - (work / "L").symlink_to("loop_a/../evil_link") - return work - - -_LOOP_INPUTS = ["loop_a", "loop_a/child.txt", "self", "self/x"] - - -@needs_symlinks -@pytest.mark.parametrize("p", _LOOP_INPUTS) -def test_resolve_path_rejects_symlink_loop(tmp_path: Path, p: str) -> None: - work = _symlink_fixture(tmp_path) - env = AgentToolContext(workdir=str(work)) - started = time.monotonic() - with pytest.raises( - ValueError, match=rf"^path {re.escape(repr(p))}: too many levels of symbolic links$" - ) as exc_info: - resolve_path(env, p) - assert time.monotonic() - started < 2 - assert str(tmp_path) not in str(exc_info.value) - - -@needs_symlinks -def test_resolve_path_dotdot_is_lexical_before_symlinks(tmp_path: Path) -> None: - """``a/../b`` never touches ``a``, so a cycle at ``a`` cannot carry ``b`` - past the containment check on any interpreter.""" - work = _symlink_fixture(tmp_path) - env = AgentToolContext(workdir=str(work)) - for p in ("loop_a/../evil_link", "self/../evil_link"): - with pytest.raises(ValueError, match="escapes workdir"): - resolve_path(env, p) - with pytest.raises(ValueError, match="too many levels of symbolic links|escapes workdir"): - resolve_path(env, "L") - (work / "ok.txt").write_text("ok") - assert resolve_path(env, "loop_a/../ok.txt") == work / "ok.txt" - - -@needs_symlinks -def test_resolve_path_rejects_symlink_escape_live_and_dangling(tmp_path: Path) -> None: - work = _symlink_fixture(tmp_path) - (work / "dangle_out").symlink_to(tmp_path / "outside" / "nope") - env = AgentToolContext(workdir=str(work)) - for p in ("evil_link", "dangle_out"): - with pytest.raises(ValueError, match="escapes workdir"): - resolve_path(env, p) - - def test_resolve_path_segment_aware_sibling(tmp_path: Path) -> None: """A sibling directory sharing a prefix (workdir vs workdir2) must not satisfy the jail.""" root = tmp_path / "work" @@ -183,32 +120,6 @@ async def test_read_view_range(tmp_path: Path) -> None: assert out == "b\nc" -@needs_pydantic_v2 -@pytest.mark.parametrize( - ("view_range", "want"), - [ - ([2, 2], "line2"), - ([2, 0], "line2\nline3"), - ([10, 12], ""), - ([3, 1], ""), - ([], "line1\nline2\nline3"), - ], -) -async def test_read_view_range_edges(tmp_path: Path, view_range: list[int], want: str) -> None: - (tmp_path / "a.txt").write_text("line1\nline2\nline3") - env = AgentToolContext(workdir=str(tmp_path)) - assert await beta_read_tool(env).call({"file_path": "a.txt", "view_range": view_range}) == want - - -@needs_pydantic_v2 -async def test_read_view_range_wrong_arity_is_rejected(tmp_path: Path) -> None: - (tmp_path / "a.txt").write_text("line1\nline2\nline3") - env = AgentToolContext(workdir=str(tmp_path)) - with pytest.raises(ToolError) as exc_info: - await beta_read_tool(env).call({"file_path": "a.txt", "view_range": [2]}) - assert str(exc_info.value) == "read: view_range must be [start_line, end_line]" - - @needs_pydantic_v2 async def test_read_rejects_oversized_file(tmp_path: Path) -> None: (tmp_path / "big.txt").write_bytes(b"a" * (257 * 1024)) @@ -646,80 +557,6 @@ async def test_read_through_symlink_escape_is_rejected(tmp_path: Path) -> None: await beta_read_tool(env).call({"file_path": "escape/secret.txt"}) -@needs_pydantic_v2 -@needs_symlinks -async def test_read_symlink_loop_is_rejected(tmp_path: Path) -> None: - work = _symlink_fixture(tmp_path) - env = AgentToolContext(workdir=str(work)) - with pytest.raises(ToolError) as exc_info: - await beta_read_tool(env).call({"file_path": "loop_a"}) - assert str(exc_info.value) == "read: path 'loop_a': too many levels of symbolic links" - for p in ("loop_a/../evil_link", "L"): - with pytest.raises(ToolError) as exc_info: - await beta_read_tool(env).call({"file_path": p}) - assert "SECRET" not in str(exc_info.value) - - -@needs_pydantic_v2 -@needs_symlinks -async def test_write_under_symlink_loop_creates_nothing(tmp_path: Path) -> None: - work = _symlink_fixture(tmp_path) - before = sorted(os.listdir(work)) - env = AgentToolContext(workdir=str(work)) - with pytest.raises(ToolError, match="too many levels of symbolic links"): - await beta_write_tool(env).call({"file_path": "loop_a/child.txt", "content": "x"}) - assert sorted(os.listdir(work)) == before - - -@needs_pydantic_v2 -@needs_symlinks -async def test_write_through_dangling_symlink_inside_workdir(tmp_path: Path) -> None: - """A dangling link whose target is inside the workdir is followed: the - target and its missing parent directory are created.""" - env = AgentToolContext(workdir=str(tmp_path)) - (tmp_path / "d").symlink_to(tmp_path / "newdir" / "f.txt") - await beta_write_tool(env).call({"file_path": "d", "content": "via link"}) - assert (tmp_path / "newdir" / "f.txt").read_text() == "via link" - - -@needs_pydantic_v2 -@needs_symlinks -async def test_read_follows_symlink_chain_inside_workdir(tmp_path: Path) -> None: - (tmp_path / "real.txt").write_text("payload") - (tmp_path / "c2").symlink_to("real.txt") - (tmp_path / "c1").symlink_to("c2") - (tmp_path / "c0").symlink_to("c1") - env = AgentToolContext(workdir=str(tmp_path)) - assert await beta_read_tool(env).call({"file_path": "c0"}) == "payload" - - -@needs_pydantic_v2 -async def test_write_creates_deeply_nested_missing_directories(tmp_path: Path) -> None: - """Missing path components are not symlink hops, so any depth is allowed.""" - env = AgentToolContext(workdir=str(tmp_path)) - rel = "/".join(f"d{i}" for i in range(50)) + "/f.txt" - await beta_write_tool(env).call({"file_path": rel, "content": "deep"}) - assert (tmp_path / rel).read_text() == "deep" - - -@needs_pydantic_v2 -async def test_read_under_unreadable_directory_reports_permission_denied(tmp_path: Path) -> None: - if os.name != "posix": - pytest.skip("chmod semantics only apply on POSIX") - if os.geteuid() == 0: - pytest.skip("root bypasses POSIX mode bits") - noperm = tmp_path / "noperm" - noperm.mkdir() - noperm.chmod(0) - env = AgentToolContext(workdir=str(tmp_path)) - try: - with pytest.raises(ToolError) as exc_info: - await beta_read_tool(env).call({"file_path": "noperm/x"}) - finally: - noperm.chmod(0o700) - assert str(exc_info.value) == "read: path 'noperm/x': permission denied" - - @needs_pydantic_v2 async def test_glob_rejects_dotdot_pattern(tmp_path: Path) -> None: """``Path.glob`` honours literal ``..`` segments — the tool must reject a @@ -748,17 +585,6 @@ async def test_glob_post_filters_symlink_escape(tmp_path: Path) -> None: assert res == "no matches" -@needs_pydantic_v2 -@needs_symlinks -async def test_glob_drops_matches_that_cannot_be_canonicalised(tmp_path: Path) -> None: - work = _symlink_fixture(tmp_path) - (work / "ok.txt").write_text("ok") - env = AgentToolContext(workdir=str(work)) - res = await beta_glob_tool(env).call({"pattern": "*"}) - assert isinstance(res, str) - assert [Path(line).name for line in res.splitlines()] == ["ok.txt"] - - @needs_pydantic_v2 async def test_grep_skips_symlinked_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The fallback walker must not read through a symlink that escapes the workdir.""" diff --git a/tests/lib/tools/test_mcp_tool.py b/tests/lib/tools/test_mcp_tool.py index e43c146a8..8064a0f6f 100644 --- a/tests/lib/tools/test_mcp_tool.py +++ b/tests/lib/tools/test_mcp_tool.py @@ -4,6 +4,7 @@ import json import base64 from typing import Any +from unittest.mock import AsyncMock import anyio import pytest @@ -52,20 +53,11 @@ def _read_result(contents: list[Any]) -> ReadResourceResult: return ReadResourceResult.model_validate({"contents": contents}) -class FakeClientSession: - """Quacks like the `call_tool` part of `mcp.ClientSession`, recording each call.""" - - def __init__(self, result: CallToolResult | None = None) -> None: - self.result = result or CallToolResult(content=[TextContent(type="text", text="tool output")], isError=False) - self.calls: list[dict[str, Any]] = [] - - async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult: - self.calls.append({"name": name, "arguments": arguments}) - return self.result - - def _mock_client(result: CallToolResult | None = None) -> Any: - return FakeClientSession(result) + """Return a mock that quacks like ClientSession.call_tool.""" + default = CallToolResult(content=[TextContent(type="text", text="tool output")], isError=False) + mock = type("MockClient", (), {"call_tool": AsyncMock(return_value=result or default)})() + return mock # ----------------------------------------------------------------------- @@ -344,7 +336,7 @@ async def _test() -> None: assert block["type"] == "text" assert block["text"] == "42" - assert client.calls == [{"name": "calc", "arguments": {"x": 1}}] + client.call_tool.assert_awaited_once_with(name="calc", arguments={"x": 1}) anyio.run(_test) diff --git a/tests/lib/tools/test_skills.py b/tests/lib/tools/test_skills.py index bcbc20080..ad3f2e0d2 100644 --- a/tests/lib/tools/test_skills.py +++ b/tests/lib/tools/test_skills.py @@ -58,51 +58,6 @@ def _make_targz_modes(path: Path, entries: dict[str, tuple[bytes, int]]) -> None tf.addfile(info, io.BytesIO(data)) -def _tar_file( - name: str, data: bytes, *, mode: int = 0o644, type: bytes = tarfile.REGTYPE -) -> tuple[tarfile.TarInfo, bytes]: - info = tarfile.TarInfo(name) - info.size = len(data) - info.mode = mode - info.type = type - return info, data - - -def _tar_special(name: str, type: bytes, linkname: str = "") -> tuple[tarfile.TarInfo, bytes]: - info = tarfile.TarInfo(name) - info.type = type - info.linkname = linkname - if type in (tarfile.CHRTYPE, tarfile.BLKTYPE): - info.devmajor, info.devminor = 1, 3 - return info, b"" - - -def _make_tar(path: Path, members: list[tuple[tarfile.TarInfo, bytes]]) -> None: - with tarfile.open(path, "w:gz") as tf: - for info, data in members: - tf.addfile(info, io.BytesIO(data)) - - -def _zip_entry(name: str, *, mode: int, create_system: int = 3) -> zipfile.ZipInfo: - info = zipfile.ZipInfo(name) - info.create_system = create_system - info.external_attr = mode << 16 - return info - - -def _make_zip_infos(path: Path, entries: list[tuple[zipfile.ZipInfo, bytes]]) -> None: - with zipfile.ZipFile(path, "w") as zf: - for info, data in entries: - zf.writestr(info, data) - - -def _assert_only_files_and_dirs(root: Path) -> None: - for dirpath, dirnames, filenames in os.walk(root): - for name in (*dirnames, *filenames): - kind = stat.S_IFMT(os.lstat(Path(dirpath) / name).st_mode) - assert kind in (stat.S_IFREG, stat.S_IFDIR), f"{Path(dirpath) / name} is neither file nor directory" - - def test_archive_top_dir_detection() -> None: assert _archive_top_dir(["pdf/SKILL.md", "pdf/scripts/x.py"]) == "pdf" assert _archive_top_dir(["pdf/SKILL.md"]) == "pdf" @@ -239,134 +194,3 @@ def test_extract_drops_setuid_setgid_sticky(make: ArchiveModeMaker, tmp_path: Pa # A non-executable member with setuid set must also drop the bit. assert doc & 0o7000 == 0 assert doc == 0o644 - - -def test_tar_special_members_are_skipped(tmp_path: Path) -> None: - archive = tmp_path / "skill.tar.gz" - _make_tar( - archive, - [ - _tar_file("pdf/SKILL.md", b"# PDF"), - _tar_file("pdf/scripts/run.sh", b"#!/bin/sh\n", mode=0o755), - _tar_file("pdf/old", b"v7", type=tarfile.AREGTYPE), - _tar_special("pdf/abs", tarfile.SYMTYPE, "/etc/passwd"), - _tar_special("pdf/rel", tarfile.SYMTYPE, "SKILL.md"), - _tar_special("pdf/hl", tarfile.LNKTYPE, "pdf/SKILL.md"), - _tar_special("pdf/fifo", tarfile.FIFOTYPE), - _tar_special("pdf/dev", tarfile.CHRTYPE), - ], - ) - dest = tmp_path / "skills" / "pdf" - _extract_skill_archive(archive, dest) - - assert (dest / "SKILL.md").read_bytes() == b"# PDF" - assert (dest / "old").read_bytes() == b"v7" - assert _mode(dest / "scripts" / "run.sh") == 0o755 - for name in ("abs", "rel", "hl", "fifo", "dev", "pdf"): - assert not os.path.lexists(dest / name), name - _assert_only_files_and_dirs(dest) - assert sorted(os.listdir(tmp_path)) == ["skill.tar.gz", "skills"] - assert os.listdir(tmp_path / "skills") == ["pdf"] - - -def test_zip_unix_special_entries_are_skipped_not_written_as_files(tmp_path: Path) -> None: - archive = tmp_path / "skill.zip" - _make_zip_infos( - archive, - [ - (_zip_entry("pdf/SKILL.md", mode=0o644), b"# PDF"), - (_zip_entry("pdf/lnk", mode=stat.S_IFLNK | 0o777), b"/etc/passwd"), - (_zip_entry("pdf/fifo", mode=stat.S_IFIFO | 0o644), b""), - ], - ) - dest = tmp_path / "skills" / "pdf" - _extract_skill_archive(archive, dest) - - assert (dest / "SKILL.md").read_bytes() == b"# PDF" - assert not os.path.lexists(dest / "lnk") - assert not os.path.lexists(dest / "fifo") - _assert_only_files_and_dirs(dest) - - -def test_zip_non_unix_host_type_bits_are_plain_data(tmp_path: Path) -> None: - """Only a Unix creator host makes ``external_attr`` a mode; the same bits - from a FAT/NTFS archiver describe an ordinary file.""" - archive = tmp_path / "skill.zip" - _make_zip_infos( - archive, - [ - (_zip_entry("pdf/SKILL.md", mode=0o644, create_system=0), b"# PDF"), - (_zip_entry("pdf/lnk", mode=stat.S_IFLNK | 0o777, create_system=0), b"/etc/passwd"), - ], - ) - dest = tmp_path / "skills" / "pdf" - _extract_skill_archive(archive, dest) - - assert stat.S_ISREG(os.lstat(dest / "lnk").st_mode) - assert (dest / "lnk").read_bytes() == b"/etc/passwd" - - -def test_tar_top_level_special_does_not_defeat_wrapper_stripping(tmp_path: Path) -> None: - archive = tmp_path / "skill.tar.gz" - _make_tar(archive, [_tar_special("link", tarfile.SYMTYPE, "/tmp"), _tar_file("pdf/SKILL.md", b"# PDF")]) - dest = tmp_path / "skills" / "pdf" - _extract_skill_archive(archive, dest) - - assert (dest / "SKILL.md").read_bytes() == b"# PDF" - assert not (dest / "pdf").exists() - assert not os.path.lexists(dest / "link") - - -def test_tar_pax_global_header_is_not_extracted(tmp_path: Path) -> None: - """``git archive`` tarballs start with a PAX global header; it is metadata, - not a member, and must neither appear on disk nor stop wrapper stripping.""" - archive = tmp_path / "skill.tar" - with tarfile.open(archive, "w", format=tarfile.PAX_FORMAT, pax_headers={"comment": "0123abcd"}) as tf: - info, data = _tar_file("pdf/SKILL.md", b"# PDF") - tf.addfile(info, io.BytesIO(data)) - dest = tmp_path / "skills" / "pdf" - _extract_skill_archive(archive, dest) - - assert sorted(os.listdir(dest)) == ["SKILL.md"] - - -def test_zip_entries_without_type_bits_are_regular_files(tmp_path: Path) -> None: - archive = tmp_path / "skill.zip" - with zipfile.ZipFile(archive, "w") as zf: - zf.writestr(_zip_entry("pdf/scripts/run.sh", mode=0o755), b"#!/bin/sh\n") - zf.writestr("pdf/SKILL.md", b"# PDF") - dest = tmp_path / "skills" / "pdf" - _extract_skill_archive(archive, dest) - - assert (dest / "SKILL.md").read_bytes() == b"# PDF" - assert _mode(dest / "scripts" / "run.sh") == 0o755 - - -def test_zip_unix_directory_bits_without_trailing_slash_make_a_directory(tmp_path: Path) -> None: - archive = tmp_path / "skill.zip" - _make_zip_infos( - archive, - [ - (_zip_entry("pdf/SKILL.md", mode=0o644), b"# PDF"), - (_zip_entry("pdf/sub", mode=stat.S_IFDIR | 0o755), b""), - ], - ) - dest = tmp_path / "skills" / "pdf" - _extract_skill_archive(archive, dest) - - assert (dest / "sub").is_dir() - - -def test_tar_path_screen_applies_to_skipped_members(tmp_path: Path) -> None: - """A member that would be skipped for its type is still subject to the - path-traversal screen, so the archive is refused as a whole.""" - archive = tmp_path / "sub" / "skill.tar.gz" - archive.parent.mkdir() - _make_tar(archive, [_tar_special("../x", tarfile.SYMTYPE, "/etc/passwd"), _tar_file("pdf/SKILL.md", b"# PDF")]) - dest = tmp_path / "sub" / "skills" / "pdf" - with pytest.raises(ValueError): - _extract_skill_archive(archive, dest) - assert not (dest / "SKILL.md").exists() - assert not os.path.lexists(dest.parent / "x") - assert not os.path.lexists(tmp_path / "sub" / "x") - assert not os.path.lexists(tmp_path / "x") diff --git a/tests/test_client.py b/tests/test_client.py index 69e6cb2b0..b6ebf9e4d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,6 +11,7 @@ import dataclasses import tracemalloc from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast +from unittest import mock from typing_extensions import Literal, AsyncIterator, override import httpx @@ -420,11 +421,7 @@ def test_validate_headers(self) -> None: request = client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("X-Api-Key") == api_key - def no_default_credentials(**_kwargs: object) -> None: - return None - - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr("anthropic._client.default_credentials", no_default_credentials) + with mock.patch("anthropic._client.default_credentials", return_value=None): with update_env(**{"ANTHROPIC_API_KEY": Omit()}): client2 = Anthropic(base_url=base_url, api_key=None, _strict_response_validation=True) @@ -984,27 +981,18 @@ class Model(BaseModel): [-1100, "", 8], # test large number potentially overflowing ], ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) def test_parse_retry_after_header( - self, - remaining_retries: int, - retry_after: str, - timeout: float, - client: Anthropic, - monkeypatch: pytest.MonkeyPatch, + self, remaining_retries: int, retry_after: str, timeout: float, client: Anthropic ) -> None: - monkeypatch.setattr("time.time", lambda: 1696004797) - headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_timeout_errors_doesnt_leak( - self, respx_mock: MockRouter, client: Anthropic, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Anthropic) -> None: respx_mock.post("/v1/messages").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): @@ -1021,12 +1009,9 @@ def test_retrying_timeout_errors_doesnt_leak( assert _get_open_connections(client) == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_status_errors_doesnt_leak( - self, respx_mock: MockRouter, client: Anthropic, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Anthropic) -> None: respx_mock.post("/v1/messages").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): @@ -1043,6 +1028,7 @@ def test_retrying_status_errors_doesnt_leak( assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @pytest.mark.parametrize("failure_mode", ["status", "exception"]) def test_retries_taken( @@ -1051,10 +1037,7 @@ def test_retries_taken( failures_before_success: int, failure_mode: Literal["status", "exception"], respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = client.with_options(max_retries=4) nb_retries = 0 @@ -1085,16 +1068,11 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_omit_retry_count_header( - self, - client: Anthropic, - failures_before_success: int, - respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, + self, client: Anthropic, failures_before_success: int, respx_mock: MockRouter ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = client.with_options(max_retries=4) nb_retries = 0 @@ -1123,16 +1101,11 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_overwrite_retry_count_header( - self, - client: Anthropic, - failures_before_success: int, - respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, + self, client: Anthropic, failures_before_success: int, respx_mock: MockRouter ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = client.with_options(max_retries=4) nb_retries = 0 @@ -1161,16 +1134,11 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_retries_taken_new_response_class( - self, - client: Anthropic, - failures_before_success: int, - respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, + self, client: Anthropic, failures_before_success: int, respx_mock: MockRouter ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = client.with_options(max_retries=4) nb_retries = 0 @@ -1565,11 +1533,7 @@ def test_validate_headers(self) -> None: request = client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("X-Api-Key") == api_key - def no_default_credentials(**_kwargs: object) -> None: - return None - - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr("anthropic._client.default_credentials", no_default_credentials) + with mock.patch("anthropic._client.default_credentials", return_value=None): with update_env(**{"ANTHROPIC_API_KEY": Omit()}): client2 = AsyncAnthropic(base_url=base_url, api_key=None, _strict_response_validation=True) @@ -2144,27 +2108,20 @@ class Model(BaseModel): [-1100, "", 8], # test large number potentially overflowing ], ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) async def test_parse_retry_after_header( - self, - remaining_retries: int, - retry_after: str, - timeout: float, - async_client: AsyncAnthropic, - monkeypatch: pytest.MonkeyPatch, + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncAnthropic ) -> None: - monkeypatch.setattr("time.time", lambda: 1696004797) - headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) async def test_retrying_timeout_errors_doesnt_leak( - self, respx_mock: MockRouter, async_client: AsyncAnthropic, monkeypatch: pytest.MonkeyPatch + self, respx_mock: MockRouter, async_client: AsyncAnthropic ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - respx_mock.post("/v1/messages").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): @@ -2181,12 +2138,11 @@ async def test_retrying_timeout_errors_doesnt_leak( assert _get_open_connections(async_client) == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) async def test_retrying_status_errors_doesnt_leak( - self, respx_mock: MockRouter, async_client: AsyncAnthropic, monkeypatch: pytest.MonkeyPatch + self, respx_mock: MockRouter, async_client: AsyncAnthropic ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - respx_mock.post("/v1/messages").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): @@ -2203,6 +2159,7 @@ async def test_retrying_status_errors_doesnt_leak( assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( @@ -2211,10 +2168,7 @@ async def test_retries_taken( failures_before_success: int, failure_mode: Literal["status", "exception"], respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = async_client.with_options(max_retries=4) nb_retries = 0 @@ -2245,16 +2199,11 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) async def test_omit_retry_count_header( - self, - async_client: AsyncAnthropic, - failures_before_success: int, - respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, + self, async_client: AsyncAnthropic, failures_before_success: int, respx_mock: MockRouter ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = async_client.with_options(max_retries=4) nb_retries = 0 @@ -2283,16 +2232,11 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) async def test_overwrite_retry_count_header( - self, - async_client: AsyncAnthropic, - failures_before_success: int, - respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, + self, async_client: AsyncAnthropic, failures_before_success: int, respx_mock: MockRouter ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = async_client.with_options(max_retries=4) nb_retries = 0 @@ -2321,16 +2265,11 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) async def test_retries_taken_new_response_class( - self, - async_client: AsyncAnthropic, - failures_before_success: int, - respx_mock: MockRouter, - monkeypatch: pytest.MonkeyPatch, + self, async_client: AsyncAnthropic, failures_before_success: int, respx_mock: MockRouter ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - client = async_client.with_options(max_retries=4) nb_retries = 0 diff --git a/tests/test_files.py b/tests/test_files.py index ac91ccad9..cbef775f2 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -43,25 +43,6 @@ async def test_async_tuple_input() -> None: assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) -def test_pathlib_in_file_tuple() -> None: - result = to_httpx_files({"file": ("custom.md", readme_path)}) - assert result == IsDict({"file": IsTuple("custom.md", readme_path.read_bytes())}) - - result = to_httpx_files({"file": ("custom.md", readme_path, "text/markdown", {"X-Custom": "1"})}) - assert result == IsDict({"file": IsTuple("custom.md", IsBytes(), "text/markdown", {"X-Custom": "1"})}) - - -@pytest.mark.asyncio -async def test_async_pathlib_in_file_tuple() -> None: - result = await async_to_httpx_files({"file": ("custom.md", readme_path)}) - assert result == IsDict({"file": IsTuple("custom.md", readme_path.read_bytes())}) - - result = await async_to_httpx_files( - {"file": ("custom.md", anyio.Path(readme_path), "text/markdown", {"X-Custom": "1"})} - ) - assert result == IsDict({"file": IsTuple("custom.md", IsBytes(), "text/markdown", {"X-Custom": "1"})}) - - def test_string_not_allowed() -> None: with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): to_httpx_files( diff --git a/tests/test_middleware.py b/tests/test_middleware.py index dfcc1a0bf..368a0c3de 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -5,6 +5,7 @@ import json from typing import Any, Protocol, cast from pathlib import Path +from unittest import mock from typing_extensions import override import httpx @@ -35,7 +36,6 @@ from anthropic.lib.aws import AnthropicAWS, AsyncAnthropicAWS from anthropic._response import BinaryAPIResponse, AsyncBinaryAPIResponse, StreamedBinaryAPIResponse from anthropic.lib.foundry import AnthropicFoundry, AsyncAnthropicFoundry -from anthropic._base_client import BaseClient from anthropic.types.message import Message from anthropic._legacy_response import LegacyAPIResponse @@ -743,10 +743,9 @@ def add_trace_header(request: APIRequest, call_next: CallNext) -> Any: calls = cast("list[MockRequestCall]", respx_mock.calls) assert calls[0].request.headers["x-trace-id"] == "abc-123" + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_middleware_runs_per_attempt(self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_middleware_runs_per_attempt(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( side_effect=[ httpx.Response(500), @@ -772,10 +771,9 @@ def test_middleware_runs_per_attempt(self, respx_mock: MockRouter, monkeypatch: assert recorder.errors == [] assert len(respx_mock.calls) == 3 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url, assert_all_called=False) - def test_middleware_error_is_not_retried(self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_middleware_error_is_not_retried(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=message_body())) recorder = AttemptRecorder() @@ -792,12 +790,9 @@ def test_middleware_error_is_not_retried(self, respx_mock: MockRouter, monkeypat assert recorder.attempts == [0] assert len(respx_mock.calls) == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url, assert_all_called=False) - def test_retryable_error_is_retried_then_propagates( - self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_retryable_error_is_retried_then_propagates(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=message_body())) attempts: list[int] = [] @@ -818,12 +813,9 @@ def give_up(request: APIRequest, call_next: CallNext) -> Any: # noqa: ARG001 assert attempts == [0, 1, 2] assert len(respx_mock.calls) == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_error_with_retryable_cause_is_retried( - self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_error_with_retryable_cause_is_retried(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( side_effect=[ httpx.ConnectError("kaboom"), @@ -849,12 +841,9 @@ def wrap_errors(request: APIRequest, call_next: CallNext) -> Any: assert isinstance(message, Message) assert len(respx_mock.calls) == 2 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_returned_error_response_raises_typed_error_for_caller( - self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_returned_error_response_raises_typed_error_for_caller(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( return_value=httpx.Response(400, json=error_body(type="invalid_request_error", message="bad request")) ) @@ -875,12 +864,9 @@ def test_returned_error_response_raises_typed_error_for_caller( assert recorder.errors == [] assert len(respx_mock.calls) == 1 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_request_modifications_do_not_persist_across_attempts( - self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + def test_request_modifications_do_not_persist_across_attempts(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( side_effect=[ httpx.Response(500), @@ -1650,10 +1636,9 @@ async def test_middleware_exception_propagates(self, respx_mock: MockRouter) -> assert route.call_count == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_middleware_runs_per_attempt(self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + async def test_middleware_runs_per_attempt(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( side_effect=[ httpx.Response(500), @@ -1679,12 +1664,9 @@ async def test_middleware_runs_per_attempt(self, respx_mock: MockRouter, monkeyp assert recorder.errors == [] assert len(respx_mock.calls) == 3 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url, assert_all_called=False) - async def test_retryable_error_is_retried_then_propagates( - self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + async def test_retryable_error_is_retried_then_propagates(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=message_body())) attempts: list[int] = [] @@ -1705,12 +1687,9 @@ async def give_up(request: APIRequest, call_next: AsyncCallNext) -> Any: # noqa assert attempts == [0, 1, 2] assert len(respx_mock.calls) == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url, assert_all_called=False) - async def test_middleware_error_is_not_retried( - self, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(BaseClient, "_calculate_retry_timeout", _low_retry_timeout) - + async def test_middleware_error_is_not_retried(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=message_body())) recorder = AttemptRecorder() diff --git a/uv.lock b/uv.lock index c0e542e7f..9164877aa 100644 --- a/uv.lock +++ b/uv.lock @@ -725,6 +725,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "dirty-equals" version = "0.11" @@ -2444,12 +2456,19 @@ wheels = [ [[package]] name = "standardwebhooks" -version = "1.1.0" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/81/a26b812525dd9ad6cc0f6ba2f8eafd5bf595542e319d95123aa92b51bf22/standardwebhooks-1.1.0.tar.gz", hash = "sha256:e5cb66e21a6356ebb9375aeb57f1348583323015808d475a7c1baaa4b718068a", size = 3232, upload-time = "2026-07-21T15:49:51.45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/dc/0cd2bd9a536e61ad0ee9de3b724492a38faa88ce1828fbc15940824664c3/standardwebhooks-1.1.0-py3-none-any.whl", hash = "sha256:9a88d48a1f198be61517fc7ad328cf58ba02b73f0fbd8941d4213e9c0b6c2a61", size = 3538, upload-time = "2026-07-21T15:49:50.519Z" }, +dependencies = [ + { name = "attrs" }, + { name = "deprecated" }, + { name = "httpx" }, + { name = "python-dateutil" }, + { name = "types-deprecated", version = "1.3.1.20260130", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, + { name = "types-deprecated", version = "1.3.1.20260408", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, + { name = "types-python-dateutil", version = "2.9.0.20260124", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, + { name = "types-python-dateutil", version = "2.9.0.20260408", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/c4/7d/04fc3aa177403472d3ddae90953d8f878dc5fd21ba29c02fc9e97e10703f/standardwebhooks-1.0.1.tar.gz", hash = "sha256:b557bb2e4b16ada179a517ec0fe6cbec5acf976c5619922bf29c457f89a451bd", size = 5103, upload-time = "2026-02-18T19:13:06.793Z" } [[package]] name = "starlette" @@ -2723,6 +2742,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/e5/47a573bbbd0a790f8f9fe452f7188ea72b212d21c9be57d5fc0cbc442075/types_awscrt-0.31.3-py3-none-any.whl", hash = "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458", size = 43340, upload-time = "2026-03-08T02:31:13.38Z" }, ] +[[package]] +name = "types-deprecated" +version = "1.3.1.20260130" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/97/9924e496f88412788c432891cacd041e542425fe0bffff4143a7c1c89ac4/types_deprecated-1.3.1.20260130.tar.gz", hash = "sha256:726b05e5e66d42359b1d6631835b15de62702588c8a59b877aa4b1e138453450", size = 8455, upload-time = "2026-01-30T03:58:17.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/b2/6f920582af7efcd37165cd6321707f3ad5839dd24565a8a982f2bd9c6fd1/types_deprecated-1.3.1.20260130-py3-none-any.whl", hash = "sha256:593934d85c38ca321a9d301f00c42ffe13e4cf830b71b10579185ba0ce172d9a", size = 9077, upload-time = "2026-01-30T03:58:16.633Z" }, +] + +[[package]] +name = "types-deprecated" +version = "1.3.1.20260408" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and extra == 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.14' and extra != 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra == 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1' and extra != 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra != 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra != 'group-9-anthropic-pydantic-v2'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/db/076de3e81b106d3cec17aec9640ab1b2d02f29bad441de280459c161ce65/types_deprecated-1.3.1.20260408.tar.gz", hash = "sha256:62d6a86d0cc754c14bb2de31162d069b1c6a07ce11ee65e5258f8f75308eb3a3", size = 8524, upload-time = "2026-04-08T04:26:39.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/d0/d3258379deb749d949c3c72313981c9d2cceec518b87dcf506f022f5d49f/types_deprecated-1.3.1.20260408-py3-none-any.whl", hash = "sha256:b64e1eab560d4fa9394a27a3099211344b0e0f2f3ac8026d825c86e70d65cdd5", size = 9079, upload-time = "2026-04-08T04:26:38.752Z" }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260124" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/41/4f8eb1ce08688a9e3e23709ed07089ccdeaf95b93745bfb768c6da71197d/types_python_dateutil-2.9.0.20260124.tar.gz", hash = "sha256:7d2db9f860820c30e5b8152bfe78dbdf795f7d1c6176057424e8b3fdd1f581af", size = 16596, upload-time = "2026-01-24T03:18:42.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/c2/aa5e3f4103cc8b1dcf92432415dde75d70021d634ecfd95b2e913cf43e17/types_python_dateutil-2.9.0.20260124-py3-none-any.whl", hash = "sha256:f802977ae08bf2260142e7ca1ab9d4403772a254409f7bbdf652229997124951", size = 18266, upload-time = "2026-01-24T03:18:42.155Z" }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260408" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and extra == 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.14' and extra != 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra == 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1' and extra != 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra != 'group-9-anthropic-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'extra-9-anthropic-mcp' and extra != 'group-9-anthropic-pydantic-v1' and extra != 'group-9-anthropic-pydantic-v2'", +] +sdist = { url = "https://files.pythonhosted.org/packages/88/f3/2427775f80cd5e19a0a71ba8e5ab7645a01a852f43a5fd0ffc24f66338e0/types_python_dateutil-2.9.0.20260408.tar.gz", hash = "sha256:8b056ec01568674235f64ecbcef928972a5fac412f5aab09c516dfa2acfbb582", size = 16981, upload-time = "2026-04-08T04:28:10.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c6/eeba37bfee282a6a97f889faef9352d6172c6a5088eb9a4daf570d9d748d/types_python_dateutil-2.9.0.20260408-py3-none-any.whl", hash = "sha256:473139d514a71c9d1fbd8bb328974bedcb1cc3dba57aad04ffa4157f483c216f", size = 18437, upload-time = "2026-04-08T04:28:10.095Z" }, +] + [[package]] name = "types-s3transfer" version = "0.16.0" @@ -2797,6 +2876,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/fe375f8a012e5f25b2cd31b093860c8c6540be445345c6f886e5d8bca9ef/wrapt-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5e0fa9cc32300daf9eb09a1f5bdc6deb9a79defd70d5356ba453bcd50aef3742", size = 60661, upload-time = "2026-03-06T02:54:06.572Z" }, + { url = "https://files.pythonhosted.org/packages/d8/2a/0dff969ddf4d3f69f051c8f81afbd3a9fc9fb08ab993b1061ee582b6543c/wrapt-2.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:710f6e5dfaf6a5d5c397d2d6758a78fecd9649deb21f1b645f5b57a328d63050", size = 61602, upload-time = "2026-03-06T02:53:44.48Z" }, + { url = "https://files.pythonhosted.org/packages/25/62/b80dd7a6c21486a7b8aea63b6bac509b2e4ea184b0eefe3795aa7202a92c/wrapt-2.1.2-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:305d8a1755116bfdad5dda9e771dcb2138990a1d66e9edd81658816edf51aed1", size = 113340, upload-time = "2026-03-06T02:54:44.626Z" }, + { url = "https://files.pythonhosted.org/packages/82/06/adbe093e07a775d8687cc45329cda9e1b33779357d146c688accbc3a9f1f/wrapt-2.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0d8fc30a43b5fe191cf2b1a0c82bab2571dadd38e7c0062ee87d6df858dd06e", size = 115305, upload-time = "2026-03-06T02:53:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/3f/dd/31c2596c6bf6bfb1874aa637c66e3028baa83d00708d1439db3b395f8371/wrapt-2.1.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5d516e22aedb7c9c1d47cba1c63160b1a6f61ec2f3948d127cd38d5cfbb556f", size = 111691, upload-time = "2026-03-06T02:53:17.845Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/e9ba179f4a00b7eb7ab8afc1f729fc3be8bd468b9f1d33be1fd99476493a/wrapt-2.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:45914e8efbe4b9d5102fcf0e8e2e3258b83a5d5fba9f8f7b6d15681e9d29ffe0", size = 114507, upload-time = "2026-03-06T02:54:49.398Z" }, + { url = "https://files.pythonhosted.org/packages/0f/dd/5ce1332e824503fb7041a8f8b51ec1f06e7033834e38c01416fa1c599668/wrapt-2.1.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:478282ebd3795a089154fb16d3db360e103aa13d3b2ad30f8f6aac0d2207de0e", size = 110945, upload-time = "2026-03-06T02:54:32.088Z" }, + { url = "https://files.pythonhosted.org/packages/1b/17/d1c1d7b63a029205fe8add19db654fd105e2a92a3776c1312e74456ce3ab/wrapt-2.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3756219045f73fb28c5d7662778e4156fbd06cf823c4d2d4b19f97305e52819c", size = 113107, upload-time = "2026-03-06T02:54:05.226Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/aa5b1570ca36a0533ad5fc9d9e436047b9af187f9bd182f5eb6b718fe28b/wrapt-2.1.2-cp39-cp39-win32.whl", hash = "sha256:b8aefb4dbb18d904b96827435a763fa42fc1f08ea096a391710407a60983ced8", size = 57984, upload-time = "2026-03-06T02:53:10.07Z" }, + { url = "https://files.pythonhosted.org/packages/71/3a/a0c92e4c8b6cd8ef179c62249f03f5ce50c142f71fe04c2a14279bd826b4/wrapt-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:e5aeab8fe15c3dff75cfee94260dcd9cded012d4ff06add036c28fae7718593b", size = 60334, upload-time = "2026-03-06T02:53:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/2725632aa7f1f70a9730952444e2ba856bd15ce8ee0210afcdb50f48ab69/wrapt-2.1.2-cp39-cp39-win_arm64.whl", hash = "sha256:f069e113743a21a3defac6677f000068ebb931639f789b5b226598e247a4c89e", size = 58759, upload-time = "2026-03-06T02:53:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "yarl" version = "1.22.0" From 86bc349fcaa4a61e3961cbb2791f118870d5a932 Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:52:07 +0800 Subject: [PATCH 2/6] style: normalize line endings to LF (match upstream) --- src/anthropic/lib/streaming/_beta_messages.py | 1176 +++++++------- src/anthropic/lib/streaming/_messages.py | 1092 ++++++------- tests/lib/streaming/test_messages.py | 1444 ++++++++--------- 3 files changed, 1856 insertions(+), 1856 deletions(-) diff --git a/src/anthropic/lib/streaming/_beta_messages.py b/src/anthropic/lib/streaming/_beta_messages.py index c77e764ec..2cf5e389c 100644 --- a/src/anthropic/lib/streaming/_beta_messages.py +++ b/src/anthropic/lib/streaming/_beta_messages.py @@ -1,588 +1,588 @@ -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 httpx2 as 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 NotGiven, not_given -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 ...types.beta.beta_usage import BetaUsage -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] - - @property - def workspace_id(self) -> str | None: - return self.response.headers.get("anthropic-workspace-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] - - @property - def workspace_id(self) -> str | None: - return self.response.headers.get("anthropic-workspace-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 isinstance(content_block, TRACKS_TOOL_INPUT): - 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, - encrypted_content=content_block.encrypted_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()), - ), - ) - if event.content_block.type == "fallback": - # the final hop's fallback block names the model that served the response — - # keeps the snapshot consistent with the relabeled non-streaming message - current_snapshot.model = event.content_block.to.model - 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 - content.encrypted_content = event.delta.encrypted_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.stop_reason = event.delta.stop_reason - current_snapshot.stop_sequence = event.delta.stop_sequence - if event.delta.stop_details is not None: - current_snapshot.stop_details = event.delta.stop_details - if event.delta.container is not None: - current_snapshot.container = event.delta.container - - # Usage may be absent when message_start omitted it (#1806); the - # message_delta carries the first full usage object, so construct it - # before updating. - if current_snapshot.usage is None: - current_snapshot.usage = BetaUsage.construct(**event.usage.model_dump()) - else: - current_snapshot.usage.output_tokens = event.usage.output_tokens - - if event.context_management is not None: - current_snapshot.context_management = event.context_management - - # Usage counts on a message_delta are cumulative totals, so they - # overwrite rather than add; optional ones are omitted when not - # applicable, in which case the message_start value must survive. - 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.output_tokens_details is not None: - current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details - if event.usage.iterations is not None: - current_snapshot.usage.iterations = event.usage.iterations - if event.usage.fallback_credit is not None: - current_snapshot.usage.fallback_credit = event.usage.fallback_credit - - return current_snapshot +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 httpx2 as 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 NotGiven, not_given +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 ...types.beta.beta_usage import BetaUsage +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] + + @property + def workspace_id(self) -> str | None: + return self.response.headers.get("anthropic-workspace-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] + + @property + def workspace_id(self) -> str | None: + return self.response.headers.get("anthropic-workspace-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 isinstance(content_block, TRACKS_TOOL_INPUT): + 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, + encrypted_content=content_block.encrypted_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()), + ), + ) + if event.content_block.type == "fallback": + # the final hop's fallback block names the model that served the response — + # keeps the snapshot consistent with the relabeled non-streaming message + current_snapshot.model = event.content_block.to.model + 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 + content.encrypted_content = event.delta.encrypted_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.stop_reason = event.delta.stop_reason + current_snapshot.stop_sequence = event.delta.stop_sequence + if event.delta.stop_details is not None: + current_snapshot.stop_details = event.delta.stop_details + if event.delta.container is not None: + current_snapshot.container = event.delta.container + + # Usage may be absent when message_start omitted it (#1806); the + # message_delta carries the first full usage object, so construct it + # before updating. + if current_snapshot.usage is None: + current_snapshot.usage = BetaUsage.construct(**event.usage.model_dump()) + else: + current_snapshot.usage.output_tokens = event.usage.output_tokens + + if event.context_management is not None: + current_snapshot.context_management = event.context_management + + # Usage counts on a message_delta are cumulative totals, so they + # overwrite rather than add; optional ones are omitted when not + # applicable, in which case the message_start value must survive. + 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.output_tokens_details is not None: + current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details + if event.usage.iterations is not None: + current_snapshot.usage.iterations = event.usage.iterations + if event.usage.fallback_credit is not None: + current_snapshot.usage.fallback_credit = event.usage.fallback_credit + + return current_snapshot diff --git a/src/anthropic/lib/streaming/_messages.py b/src/anthropic/lib/streaming/_messages.py index 22f8088d4..932191021 100644 --- a/src/anthropic/lib/streaming/_messages.py +++ b/src/anthropic/lib/streaming/_messages.py @@ -1,546 +1,546 @@ -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 httpx2 as 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.usage import Usage -from ..._types import NotGiven, not_given -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] - - @property - def workspace_id(self) -> str | None: - return self.response.headers.get("anthropic-workspace-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] - - @property - def workspace_id(self) -> str | None: - return self.response.headers.get("anthropic-workspace-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 isinstance(content_block, TRACKS_TOOL_INPUT): - 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.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: - 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 - 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 - if event.delta.stop_details is not None: - current_snapshot.stop_details = event.delta.stop_details - if event.delta.container is not None: - current_snapshot.container = event.delta.container - - # Usage may be absent when message_start omitted it (#1806); the message_delta - # carries the first full usage object, so construct it before updating. - if current_snapshot.usage is None: - current_snapshot.usage = Usage.construct(**event.usage.model_dump()) - else: - current_snapshot.usage.output_tokens = event.usage.output_tokens - - # Usage counts on a message_delta are cumulative totals, so they overwrite - # rather than add; optional ones are omitted when not applicable, in which - # case the message_start value must survive. - 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.output_tokens_details is not None: - current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details - - return current_snapshot +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 httpx2 as 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.usage import Usage +from ..._types import NotGiven, not_given +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] + + @property + def workspace_id(self) -> str | None: + return self.response.headers.get("anthropic-workspace-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] + + @property + def workspace_id(self) -> str | None: + return self.response.headers.get("anthropic-workspace-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 isinstance(content_block, TRACKS_TOOL_INPUT): + 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.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: + 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 + 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 + if event.delta.stop_details is not None: + current_snapshot.stop_details = event.delta.stop_details + if event.delta.container is not None: + current_snapshot.container = event.delta.container + + # Usage may be absent when message_start omitted it (#1806); the message_delta + # carries the first full usage object, so construct it before updating. + if current_snapshot.usage is None: + current_snapshot.usage = Usage.construct(**event.usage.model_dump()) + else: + current_snapshot.usage.output_tokens = event.usage.output_tokens + + # Usage counts on a message_delta are cumulative totals, so they overwrite + # rather than add; optional ones are omitted when not applicable, in which + # case the message_start value must survive. + 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.output_tokens_details is not None: + current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details + + return current_snapshot diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index af3b23f35..3844a7029 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -1,722 +1,722 @@ -from __future__ import annotations - -import os -from typing import Any, Set, TypeVar, Iterator, cast - -import httpx2 as httpx -import pytest -from respx import MockRouter - -from anthropic import Stream, Anthropic, AsyncStream, AsyncAnthropic -from anthropic._utils import assert_signatures_in_sync -from anthropic._compat import PYDANTIC_V1 -from anthropic.lib.streaming import InputJsonEvent, ParsedMessageStreamEvent -from anthropic.types.message import Message -from anthropic.resources.messages import DEPRECATED_MODELS -from anthropic.lib.streaming._messages import TRACKS_TOOL_INPUT - -from .helpers import get_response, to_async_iter - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") -api_key = "my-anthropic-api-key" - -sync_client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) -async_client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) - -_T = TypeVar("_T") - -# the accumulator must wrap the raw parser error with context and echo the offending JSON -INVALID_TOOL_JSON_ERROR = ( - r"^Unable to parse tool parameter JSON from model\. Please retry your request or adjust your prompt\. " - r'Error: .+\. JSON: \{"location": "Paris", "unit": celsius\}$' -) - - -def assert_basic_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: - assert message.id == "msg_4QpJur2dWWDjF6C758FbBw5vm12BaVipnK" - assert message.model == "claude-3-opus-latest" - assert message.role == "assistant" - assert message.stop_reason == "end_turn" - assert message.stop_sequence is None - assert message.type == "message" - assert len(message.content) == 1 - - content = message.content[0] - assert content.type == "text" - assert content.text == "Hello there!" - # accumulated blocks must serialize like a non-streaming response: keys the API didn't send stay unset - assert content.to_dict() == {"type": "text", "text": "Hello there!"} - - assert [e.type for e in events] == [ - "message_start", - "content_block_start", - "content_block_delta", - "text", - "content_block_delta", - "text", - "content_block_delta", - "text", - "content_block_stop", - "message_delta", - ] - - -def assert_tool_use_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: - assert message.id == "msg_019Q1hrJbZG26Fb9BQhrkHEr" - assert message.model == "claude-sonnet-4-20250514" - assert message.role == "assistant" - assert message.stop_reason == "tool_use" - assert message.stop_sequence is None - assert message.type == "message" - assert len(message.content) == 2 - - content = message.content[0] - assert content.type == "text" - assert content.text == "I'll check the current weather in Paris for you." - - tool_use = message.content[1] - assert tool_use.type == "tool_use" - assert tool_use.id == "toolu_01NRLabsLyVHZPKxbKvkfSMn" - assert tool_use.name == "get_weather" - assert tool_use.input == { - "location": "Paris", - } - - assert message.usage.input_tokens == 377 - assert message.usage.output_tokens == 65 - assert message.usage.cache_creation_input_tokens == 0 - assert message.usage.cache_read_input_tokens == 0 - assert message.usage.service_tier == "standard" - assert message.usage.server_tool_use == None - - assert [e.type for e in events] == [ - "message_start", - "content_block_start", - "content_block_delta", - "text", - "content_block_delta", - "text", - "content_block_stop", - "content_block_start", - "content_block_delta", - "input_json", - "content_block_delta", - "input_json", - "content_block_delta", - "input_json", - "content_block_delta", - "input_json", - "content_block_delta", - "input_json", - "content_block_stop", - "message_delta", - ] - - -def assert_server_tool_use_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: - assert [e.type for e in events] == [ - "message_start", - "content_block_start", - *["content_block_delta", "input_json"] * 6, - "content_block_stop", - "content_block_start", - "content_block_stop", - "content_block_start", - "content_block_delta", - "citation", - "content_block_delta", - "text", - "content_block_delta", - "text", - "content_block_stop", - "message_delta", - ] - - server_tool_use = message.content[0] - assert server_tool_use.type == "server_tool_use" - assert server_tool_use.input == {"query": "anthropic claude release notes"} - - # input_json events must fire for server_tool_use blocks, not just client tool_use - input_json_events = [e for e in events if isinstance(e, InputJsonEvent)] - assert [e.partial_json for e in input_json_events] == [ - "", - '{"query": "', - "anthropic cl", - "aude re", - "lease notes", - '"}', - ] - assert input_json_events[-1].snapshot == {"query": "anthropic claude release notes"} - - -def get_tool_use_response_without_caller() -> Iterator[bytes]: - return (line.replace(b'"caller":{"type":"direct"},', b"") for line in get_response("tool_use_response.txt")) - - -def assert_tool_use_caller_unset(message: Message) -> None: - tool_use = message.content[1] - assert tool_use.type == "tool_use" - assert tool_use.input == {"location": "Paris"} - assert tool_use.caller is None - # an omitted `caller` must stay unset so it doesn't round-trip as `"caller": null`, which the API rejects - assert "caller" not in tool_use.model_fields_set - assert "caller" not in tool_use.to_dict() - - -def assert_refusal_response(message: Message) -> None: - assert message.stop_reason == "refusal" - assert message.stop_details is not None - assert message.stop_details.type == "refusal" - assert message.stop_details.category == "cyber" - assert message.stop_details.explanation == "This request was refused due to policy." - - -def assert_message_delta_fields_response(message: Message) -> None: - # every field the final `message_delta` carried must land on the accumulated message - assert message.container is not None - assert message.container.id == "container_01AbCdEfGh" - assert message.usage.output_tokens == 8 - assert message.usage.input_tokens == 40 - assert message.usage.cache_creation_input_tokens == 12 - assert message.usage.cache_read_input_tokens == 7 - assert message.usage.output_tokens_details is not None - assert message.usage.output_tokens_details.thinking_tokens == 3 - assert message.usage.server_tool_use is not None - assert message.usage.server_tool_use.web_search_requests == 1 - # never re-sent on `message_delta`, so these must survive from `message_start` - assert message.usage.service_tier == "standard" - assert message.usage.cache_creation is not None - assert message.usage.cache_creation.ephemeral_5m_input_tokens == 10 - - -def assert_message_delta_omitted_usage_response(message: Message) -> None: - # the delta omitted every optional usage key, so the `message_start` values stand - assert message.usage.output_tokens == 8 - assert message.usage.input_tokens == 25 - assert message.usage.cache_creation_input_tokens == 10 - assert message.usage.cache_read_input_tokens == 5 - assert message.usage.service_tier == "priority" - assert message.usage.cache_creation is not None - assert message.usage.cache_creation.ephemeral_5m_input_tokens == 10 - assert message.container is None - - -class TestSyncMessages: - @pytest.mark.respx(base_url=base_url) - def test_basic_response(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("basic_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Say hello there!", - } - ], - model="claude-3-opus-latest", - ) as stream: - assert not isinstance(cast(Any, stream), Stream) - - assert_basic_response([event for event in stream], stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_context_manager(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response( - 200, - headers={"request-id": "my-req-id", "anthropic-workspace-id": "wrkspc_123"}, - content=get_response("basic_response.txt"), - ) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Say hello there!", - } - ], - model="claude-3-opus-latest", - ) as stream: - assert not stream.response.is_closed - assert stream.request_id == "my-req-id" - assert stream.workspace_id == "wrkspc_123" - - # response should be closed even if the body isn't read - assert stream.response.is_closed - - @pytest.mark.respx(base_url=base_url) - def test_deprecated_model_warning_stream(self, respx_mock: MockRouter) -> None: - for deprecated_model in DEPRECATED_MODELS: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("basic_response.txt")) - ) - - with pytest.warns(DeprecationWarning, match=f"The model '{deprecated_model}' is deprecated"): - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Hello"}], - model=deprecated_model, - ) as stream: - # Consume the stream to ensure the warning is triggered - stream.until_done() - - @pytest.mark.respx(base_url=base_url) - def test_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("tool_use_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Say hello there!", - } - ], - model="claude-sonnet-4-5", - ) as stream: - assert not isinstance(cast(Any, stream), Stream) - - assert_tool_use_response([event for event in stream], stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_server_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("server_tool_use_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_server_tool_use_response([event for event in stream], stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_tool_use_invalid_json(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("tool_use_invalid_json_response.txt")) - ) - - with pytest.raises(ValueError, match=INVALID_TOOL_JSON_ERROR): - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What's the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - stream.until_done() - - @pytest.mark.respx(base_url=base_url) - def test_tool_use_caller_omitted(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_tool_use_response_without_caller()) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What is the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - assert_tool_use_caller_unset(stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("refusal_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", - ) as stream: - assert_refusal_response(stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("message_delta_fields_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_fields_response(stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.respx(base_url=base_url) - def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: - """Test that streaming works when message_start omits usage. - - Reproduces https://github.com/anthropics/anthropic-sdk-python/issues/1806 - Per Anthropic's streaming docs, message_start can omit usage (e.g. thinking streams). - The accumulator should not crash and should initialize usage from message_delta. - """ - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("missing_usage_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "hi"}], - model="claude-test", - ) as stream: - message = stream.get_final_message() - assert message.usage is not None - assert message.usage.output_tokens == 1 - assert message.stop_reason == "end_turn" - assert len(message.content) == 1 - assert message.content[0].type == "text" - assert message.content[0].text == "hi" - - - def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=get_response("message_delta_omitted_usage_response.txt")) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_omitted_usage_response(stream.get_final_message()) - - @pytest.mark.respx(base_url=base_url) - @pytest.mark.filterwarnings("error") - def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: - # trailing blank line terminates the final `message_stop` SSE so it is dispatched - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=iter([*get_response("basic_response.txt"), b"\n"])) - ) - - with sync_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", - ) as stream: - stop_event = [event for event in stream][-1] - - assert stop_event.type == "message_stop" - assert stop_event.message.content[0].type == "text" - # must not emit `PydanticSerializationUnexpectedValue` warnings - stop_event.model_dump() - stop_event.model_dump_json() - - -class TestAsyncMessages: - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_basic_response(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("basic_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Say hello there!", - } - ], - model="claude-3-opus-latest", - ) as stream: - assert not isinstance(cast(Any, stream), AsyncStream) - - assert_basic_response([event async for event in stream], await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_context_manager(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response( - 200, - headers={"request-id": "my-req-id", "anthropic-workspace-id": "wrkspc_123"}, - content=to_async_iter(get_response("basic_response.txt")), - ) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Say hello there!", - } - ], - model="claude-3-opus-latest", - ) as stream: - assert not stream.response.is_closed - assert stream.request_id == "my-req-id" - assert stream.workspace_id == "wrkspc_123" - - # response should be closed even if the body isn't read - assert stream.response.is_closed - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_deprecated_model_warning_stream(self, respx_mock: MockRouter) -> None: - for deprecated_model in DEPRECATED_MODELS: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("basic_response.txt"))) - ) - - with pytest.warns(DeprecationWarning, match=f"The model '{deprecated_model}' is deprecated"): - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Hello"}], - model=deprecated_model, - ) as stream: - # Consume the stream to ensure the warning is triggered - await stream.get_final_message() - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("tool_use_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Say hello there!", - } - ], - model="claude-sonnet-4-5", - ) as stream: - assert not isinstance(cast(Any, stream), AsyncStream) - - assert_tool_use_response([event async for event in stream], await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_server_tool_use(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("server_tool_use_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_server_tool_use_response([event async for event in stream], await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_tool_use_invalid_json(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("tool_use_invalid_json_response.txt"))) - ) - - with pytest.raises(ValueError, match=INVALID_TOOL_JSON_ERROR): - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What's the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - await stream.until_done() - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_tool_use_caller_omitted(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_tool_use_response_without_caller())) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "What is the weather in Paris?"}], - model="claude-sonnet-4-5", - ) as stream: - assert_tool_use_caller_unset(await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("refusal_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", - ) as stream: - assert_refusal_response(await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("message_delta_fields_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_fields_response(await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - async def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: - """Async version: test that streaming works when message_start omits usage.""" - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(get_response("missing_usage_response.txt"))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "hi"}], - model="claude-test", - ) as stream: - message = await stream.get_final_message() - assert message.usage is not None - assert message.usage.output_tokens == 1 - assert message.stop_reason == "end_turn" - assert len(message.content) == 1 - assert message.content[0].type == "text" - assert message.content[0].text == "hi" - - -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -def test_stream_method_definition_in_sync(sync: bool) -> None: - client: Anthropic | AsyncAnthropic = sync_client if sync else async_client - assert_signatures_in_sync( - client.messages.create, - client.messages.stream, - exclude_params={"stream"}, - ) - - -# go through all the ContentBlock types to make sure the type alias is up to date -# with any type that has an input property of type object -@pytest.mark.skipif(PYDANTIC_V1, reason="only applicable in pydantic v2") -def test_tracks_tool_input_type_alias_is_up_to_date() -> None: - from typing import get_args - - from pydantic import BaseModel - - from anthropic.types.content_block import ContentBlock - - # Get the content block union type - content_block_union = get_args(ContentBlock)[0] - - # Get all types from ContentBlock union - content_block_types = get_args(content_block_union) - - # Types that should have an input property - types_with_input: Set[Any] = set() - - # Check each type to see if it has an input property in its model_fields - for block_type in content_block_types: - if issubclass(block_type, BaseModel) and "input" in block_type.model_fields: - types_with_input.add(block_type) - - # Get the types included in TRACKS_TOOL_INPUT - tracked_types = TRACKS_TOOL_INPUT - - # Make sure all types with input are tracked - for block_type in types_with_input: - assert block_type in tracked_types, ( - f"ContentBlock type {block_type.__name__} has an input property, " - f"but is not included in TRACKS_TOOL_INPUT. You probably need to update the TRACKS_TOOL_INPUT type alias." - ) - - - async def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response( - 200, content=to_async_iter(get_response("message_delta_omitted_usage_response.txt")) - ) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-sonnet-4-5", - ) as stream: - assert_message_delta_omitted_usage_response(await stream.get_final_message()) - - @pytest.mark.asyncio - @pytest.mark.respx(base_url=base_url) - @pytest.mark.filterwarnings("error") - async def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: - # trailing blank line terminates the final `message_stop` SSE so it is dispatched - respx_mock.post("/v1/messages").mock( - return_value=httpx.Response(200, content=to_async_iter(iter([*get_response("basic_response.txt"), b"\n"]))) - ) - - async with async_client.messages.stream( - max_tokens=1024, - messages=[{"role": "user", "content": "Say hello there!"}], - model="claude-opus-4-7", - ) as stream: - stop_event = [event async for event in stream][-1] - - assert stop_event.type == "message_stop" - assert stop_event.message.content[0].type == "text" - # must not emit `PydanticSerializationUnexpectedValue` warnings - stop_event.model_dump() - stop_event.model_dump_json() - - -@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -def test_stream_method_definition_in_sync(sync: bool) -> None: - client: Anthropic | AsyncAnthropic = sync_client if sync else async_client - assert_signatures_in_sync( - client.messages.create, - client.messages.stream, - exclude_params={"stream"}, - ) - - -# go through all the ContentBlock types to make sure the type alias is up to date -# with any type that has an input property of type object -@pytest.mark.skipif(PYDANTIC_V1, reason="only applicable in pydantic v2") -def test_tracks_tool_input_type_alias_is_up_to_date() -> None: - from typing import get_args - - from pydantic import BaseModel - - from anthropic.types.content_block import ContentBlock - - # Get the content block union type - content_block_union = get_args(ContentBlock)[0] - - # Get all types from ContentBlock union - content_block_types = get_args(content_block_union) - - # Types that should have an input property - types_with_input: Set[Any] = set() - - # Check each type to see if it has an input property in its model_fields - for block_type in content_block_types: - if issubclass(block_type, BaseModel) and "input" in block_type.model_fields: - types_with_input.add(block_type) - - # Get the types included in TRACKS_TOOL_INPUT - tracked_types = TRACKS_TOOL_INPUT - - # Make sure all types with input are tracked - for block_type in types_with_input: - assert block_type in tracked_types, ( - f"ContentBlock type {block_type.__name__} has an input property, " - f"but is not included in TRACKS_TOOL_INPUT. You probably need to update the TRACKS_TOOL_INPUT type alias." - ) +from __future__ import annotations + +import os +from typing import Any, Set, TypeVar, Iterator, cast + +import httpx2 as httpx +import pytest +from respx import MockRouter + +from anthropic import Stream, Anthropic, AsyncStream, AsyncAnthropic +from anthropic._utils import assert_signatures_in_sync +from anthropic._compat import PYDANTIC_V1 +from anthropic.lib.streaming import InputJsonEvent, ParsedMessageStreamEvent +from anthropic.types.message import Message +from anthropic.resources.messages import DEPRECATED_MODELS +from anthropic.lib.streaming._messages import TRACKS_TOOL_INPUT + +from .helpers import get_response, to_async_iter + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "my-anthropic-api-key" + +sync_client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) +async_client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) + +_T = TypeVar("_T") + +# the accumulator must wrap the raw parser error with context and echo the offending JSON +INVALID_TOOL_JSON_ERROR = ( + r"^Unable to parse tool parameter JSON from model\. Please retry your request or adjust your prompt\. " + r'Error: .+\. JSON: \{"location": "Paris", "unit": celsius\}$' +) + + +def assert_basic_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: + assert message.id == "msg_4QpJur2dWWDjF6C758FbBw5vm12BaVipnK" + assert message.model == "claude-3-opus-latest" + assert message.role == "assistant" + assert message.stop_reason == "end_turn" + assert message.stop_sequence is None + assert message.type == "message" + assert len(message.content) == 1 + + content = message.content[0] + assert content.type == "text" + assert content.text == "Hello there!" + # accumulated blocks must serialize like a non-streaming response: keys the API didn't send stay unset + assert content.to_dict() == {"type": "text", "text": "Hello there!"} + + assert [e.type for e in events] == [ + "message_start", + "content_block_start", + "content_block_delta", + "text", + "content_block_delta", + "text", + "content_block_delta", + "text", + "content_block_stop", + "message_delta", + ] + + +def assert_tool_use_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: + assert message.id == "msg_019Q1hrJbZG26Fb9BQhrkHEr" + assert message.model == "claude-sonnet-4-20250514" + assert message.role == "assistant" + assert message.stop_reason == "tool_use" + assert message.stop_sequence is None + assert message.type == "message" + assert len(message.content) == 2 + + content = message.content[0] + assert content.type == "text" + assert content.text == "I'll check the current weather in Paris for you." + + tool_use = message.content[1] + assert tool_use.type == "tool_use" + assert tool_use.id == "toolu_01NRLabsLyVHZPKxbKvkfSMn" + assert tool_use.name == "get_weather" + assert tool_use.input == { + "location": "Paris", + } + + assert message.usage.input_tokens == 377 + assert message.usage.output_tokens == 65 + assert message.usage.cache_creation_input_tokens == 0 + assert message.usage.cache_read_input_tokens == 0 + assert message.usage.service_tier == "standard" + assert message.usage.server_tool_use == None + + assert [e.type for e in events] == [ + "message_start", + "content_block_start", + "content_block_delta", + "text", + "content_block_delta", + "text", + "content_block_stop", + "content_block_start", + "content_block_delta", + "input_json", + "content_block_delta", + "input_json", + "content_block_delta", + "input_json", + "content_block_delta", + "input_json", + "content_block_delta", + "input_json", + "content_block_stop", + "message_delta", + ] + + +def assert_server_tool_use_response(events: list[ParsedMessageStreamEvent[None]], message: Message) -> None: + assert [e.type for e in events] == [ + "message_start", + "content_block_start", + *["content_block_delta", "input_json"] * 6, + "content_block_stop", + "content_block_start", + "content_block_stop", + "content_block_start", + "content_block_delta", + "citation", + "content_block_delta", + "text", + "content_block_delta", + "text", + "content_block_stop", + "message_delta", + ] + + server_tool_use = message.content[0] + assert server_tool_use.type == "server_tool_use" + assert server_tool_use.input == {"query": "anthropic claude release notes"} + + # input_json events must fire for server_tool_use blocks, not just client tool_use + input_json_events = [e for e in events if isinstance(e, InputJsonEvent)] + assert [e.partial_json for e in input_json_events] == [ + "", + '{"query": "', + "anthropic cl", + "aude re", + "lease notes", + '"}', + ] + assert input_json_events[-1].snapshot == {"query": "anthropic claude release notes"} + + +def get_tool_use_response_without_caller() -> Iterator[bytes]: + return (line.replace(b'"caller":{"type":"direct"},', b"") for line in get_response("tool_use_response.txt")) + + +def assert_tool_use_caller_unset(message: Message) -> None: + tool_use = message.content[1] + assert tool_use.type == "tool_use" + assert tool_use.input == {"location": "Paris"} + assert tool_use.caller is None + # an omitted `caller` must stay unset so it doesn't round-trip as `"caller": null`, which the API rejects + assert "caller" not in tool_use.model_fields_set + assert "caller" not in tool_use.to_dict() + + +def assert_refusal_response(message: Message) -> None: + assert message.stop_reason == "refusal" + assert message.stop_details is not None + assert message.stop_details.type == "refusal" + assert message.stop_details.category == "cyber" + assert message.stop_details.explanation == "This request was refused due to policy." + + +def assert_message_delta_fields_response(message: Message) -> None: + # every field the final `message_delta` carried must land on the accumulated message + assert message.container is not None + assert message.container.id == "container_01AbCdEfGh" + assert message.usage.output_tokens == 8 + assert message.usage.input_tokens == 40 + assert message.usage.cache_creation_input_tokens == 12 + assert message.usage.cache_read_input_tokens == 7 + assert message.usage.output_tokens_details is not None + assert message.usage.output_tokens_details.thinking_tokens == 3 + assert message.usage.server_tool_use is not None + assert message.usage.server_tool_use.web_search_requests == 1 + # never re-sent on `message_delta`, so these must survive from `message_start` + assert message.usage.service_tier == "standard" + assert message.usage.cache_creation is not None + assert message.usage.cache_creation.ephemeral_5m_input_tokens == 10 + + +def assert_message_delta_omitted_usage_response(message: Message) -> None: + # the delta omitted every optional usage key, so the `message_start` values stand + assert message.usage.output_tokens == 8 + assert message.usage.input_tokens == 25 + assert message.usage.cache_creation_input_tokens == 10 + assert message.usage.cache_read_input_tokens == 5 + assert message.usage.service_tier == "priority" + assert message.usage.cache_creation is not None + assert message.usage.cache_creation.ephemeral_5m_input_tokens == 10 + assert message.container is None + + +class TestSyncMessages: + @pytest.mark.respx(base_url=base_url) + def test_basic_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("basic_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Say hello there!", + } + ], + model="claude-3-opus-latest", + ) as stream: + assert not isinstance(cast(Any, stream), Stream) + + assert_basic_response([event for event in stream], stream.get_final_message()) + + @pytest.mark.respx(base_url=base_url) + def test_context_manager(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response( + 200, + headers={"request-id": "my-req-id", "anthropic-workspace-id": "wrkspc_123"}, + content=get_response("basic_response.txt"), + ) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Say hello there!", + } + ], + model="claude-3-opus-latest", + ) as stream: + assert not stream.response.is_closed + assert stream.request_id == "my-req-id" + assert stream.workspace_id == "wrkspc_123" + + # response should be closed even if the body isn't read + assert stream.response.is_closed + + @pytest.mark.respx(base_url=base_url) + def test_deprecated_model_warning_stream(self, respx_mock: MockRouter) -> None: + for deprecated_model in DEPRECATED_MODELS: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("basic_response.txt")) + ) + + with pytest.warns(DeprecationWarning, match=f"The model '{deprecated_model}' is deprecated"): + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Hello"}], + model=deprecated_model, + ) as stream: + # Consume the stream to ensure the warning is triggered + stream.until_done() + + @pytest.mark.respx(base_url=base_url) + def test_tool_use(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("tool_use_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Say hello there!", + } + ], + model="claude-sonnet-4-5", + ) as stream: + assert not isinstance(cast(Any, stream), Stream) + + assert_tool_use_response([event for event in stream], stream.get_final_message()) + + @pytest.mark.respx(base_url=base_url) + def test_server_tool_use(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("server_tool_use_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-sonnet-4-5", + ) as stream: + assert_server_tool_use_response([event for event in stream], stream.get_final_message()) + + @pytest.mark.respx(base_url=base_url) + def test_tool_use_invalid_json(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("tool_use_invalid_json_response.txt")) + ) + + with pytest.raises(ValueError, match=INVALID_TOOL_JSON_ERROR): + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "What's the weather in Paris?"}], + model="claude-sonnet-4-5", + ) as stream: + stream.until_done() + + @pytest.mark.respx(base_url=base_url) + def test_tool_use_caller_omitted(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_tool_use_response_without_caller()) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "What is the weather in Paris?"}], + model="claude-sonnet-4-5", + ) as stream: + assert_tool_use_caller_unset(stream.get_final_message()) + + @pytest.mark.respx(base_url=base_url) + def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("refusal_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-opus-4-7", + ) as stream: + assert_refusal_response(stream.get_final_message()) + + @pytest.mark.respx(base_url=base_url) + def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("message_delta_fields_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-sonnet-4-5", + ) as stream: + assert_message_delta_fields_response(stream.get_final_message()) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.respx(base_url=base_url) + def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Test that streaming works when message_start omits usage. + + Reproduces https://github.com/anthropics/anthropic-sdk-python/issues/1806 + Per Anthropic's streaming docs, message_start can omit usage (e.g. thinking streams). + The accumulator should not crash and should initialize usage from message_delta. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + assert len(message.content) == 1 + assert message.content[0].type == "text" + assert message.content[0].text == "hi" + + + def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("message_delta_omitted_usage_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-sonnet-4-5", + ) as stream: + assert_message_delta_omitted_usage_response(stream.get_final_message()) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.filterwarnings("error") + def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: + # trailing blank line terminates the final `message_stop` SSE so it is dispatched + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=iter([*get_response("basic_response.txt"), b"\n"])) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-opus-4-7", + ) as stream: + stop_event = [event for event in stream][-1] + + assert stop_event.type == "message_stop" + assert stop_event.message.content[0].type == "text" + # must not emit `PydanticSerializationUnexpectedValue` warnings + stop_event.model_dump() + stop_event.model_dump_json() + + +class TestAsyncMessages: + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_basic_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("basic_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Say hello there!", + } + ], + model="claude-3-opus-latest", + ) as stream: + assert not isinstance(cast(Any, stream), AsyncStream) + + assert_basic_response([event async for event in stream], await stream.get_final_message()) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_context_manager(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response( + 200, + headers={"request-id": "my-req-id", "anthropic-workspace-id": "wrkspc_123"}, + content=to_async_iter(get_response("basic_response.txt")), + ) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Say hello there!", + } + ], + model="claude-3-opus-latest", + ) as stream: + assert not stream.response.is_closed + assert stream.request_id == "my-req-id" + assert stream.workspace_id == "wrkspc_123" + + # response should be closed even if the body isn't read + assert stream.response.is_closed + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_deprecated_model_warning_stream(self, respx_mock: MockRouter) -> None: + for deprecated_model in DEPRECATED_MODELS: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("basic_response.txt"))) + ) + + with pytest.warns(DeprecationWarning, match=f"The model '{deprecated_model}' is deprecated"): + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Hello"}], + model=deprecated_model, + ) as stream: + # Consume the stream to ensure the warning is triggered + await stream.get_final_message() + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_tool_use(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("tool_use_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Say hello there!", + } + ], + model="claude-sonnet-4-5", + ) as stream: + assert not isinstance(cast(Any, stream), AsyncStream) + + assert_tool_use_response([event async for event in stream], await stream.get_final_message()) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_server_tool_use(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("server_tool_use_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-sonnet-4-5", + ) as stream: + assert_server_tool_use_response([event async for event in stream], await stream.get_final_message()) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_tool_use_invalid_json(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("tool_use_invalid_json_response.txt"))) + ) + + with pytest.raises(ValueError, match=INVALID_TOOL_JSON_ERROR): + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "What's the weather in Paris?"}], + model="claude-sonnet-4-5", + ) as stream: + await stream.until_done() + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_tool_use_caller_omitted(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_tool_use_response_without_caller())) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "What is the weather in Paris?"}], + model="claude-sonnet-4-5", + ) as stream: + assert_tool_use_caller_unset(await stream.get_final_message()) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_refusal_stop_details_propagated(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("refusal_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-opus-4-7", + ) as stream: + assert_refusal_response(await stream.get_final_message()) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("message_delta_fields_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-sonnet-4-5", + ) as stream: + assert_message_delta_fields_response(await stream.get_final_message()) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Async version: test that streaming works when message_start omits usage.""" + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("missing_usage_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = await stream.get_final_message() + assert message.usage is not None + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + assert len(message.content) == 1 + assert message.content[0].type == "text" + assert message.content[0].text == "hi" + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +def test_stream_method_definition_in_sync(sync: bool) -> None: + client: Anthropic | AsyncAnthropic = sync_client if sync else async_client + assert_signatures_in_sync( + client.messages.create, + client.messages.stream, + exclude_params={"stream"}, + ) + + +# go through all the ContentBlock types to make sure the type alias is up to date +# with any type that has an input property of type object +@pytest.mark.skipif(PYDANTIC_V1, reason="only applicable in pydantic v2") +def test_tracks_tool_input_type_alias_is_up_to_date() -> None: + from typing import get_args + + from pydantic import BaseModel + + from anthropic.types.content_block import ContentBlock + + # Get the content block union type + content_block_union = get_args(ContentBlock)[0] + + # Get all types from ContentBlock union + content_block_types = get_args(content_block_union) + + # Types that should have an input property + types_with_input: Set[Any] = set() + + # Check each type to see if it has an input property in its model_fields + for block_type in content_block_types: + if issubclass(block_type, BaseModel) and "input" in block_type.model_fields: + types_with_input.add(block_type) + + # Get the types included in TRACKS_TOOL_INPUT + tracked_types = TRACKS_TOOL_INPUT + + # Make sure all types with input are tracked + for block_type in types_with_input: + assert block_type in tracked_types, ( + f"ContentBlock type {block_type.__name__} has an input property, " + f"but is not included in TRACKS_TOOL_INPUT. You probably need to update the TRACKS_TOOL_INPUT type alias." + ) + + + async def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response( + 200, content=to_async_iter(get_response("message_delta_omitted_usage_response.txt")) + ) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-sonnet-4-5", + ) as stream: + assert_message_delta_omitted_usage_response(await stream.get_final_message()) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + @pytest.mark.filterwarnings("error") + async def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: + # trailing blank line terminates the final `message_stop` SSE so it is dispatched + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(iter([*get_response("basic_response.txt"), b"\n"]))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-opus-4-7", + ) as stream: + stop_event = [event async for event in stream][-1] + + assert stop_event.type == "message_stop" + assert stop_event.message.content[0].type == "text" + # must not emit `PydanticSerializationUnexpectedValue` warnings + stop_event.model_dump() + stop_event.model_dump_json() + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +def test_stream_method_definition_in_sync(sync: bool) -> None: + client: Anthropic | AsyncAnthropic = sync_client if sync else async_client + assert_signatures_in_sync( + client.messages.create, + client.messages.stream, + exclude_params={"stream"}, + ) + + +# go through all the ContentBlock types to make sure the type alias is up to date +# with any type that has an input property of type object +@pytest.mark.skipif(PYDANTIC_V1, reason="only applicable in pydantic v2") +def test_tracks_tool_input_type_alias_is_up_to_date() -> None: + from typing import get_args + + from pydantic import BaseModel + + from anthropic.types.content_block import ContentBlock + + # Get the content block union type + content_block_union = get_args(ContentBlock)[0] + + # Get all types from ContentBlock union + content_block_types = get_args(content_block_union) + + # Types that should have an input property + types_with_input: Set[Any] = set() + + # Check each type to see if it has an input property in its model_fields + for block_type in content_block_types: + if issubclass(block_type, BaseModel) and "input" in block_type.model_fields: + types_with_input.add(block_type) + + # Get the types included in TRACKS_TOOL_INPUT + tracked_types = TRACKS_TOOL_INPUT + + # Make sure all types with input are tracked + for block_type in types_with_input: + assert block_type in tracked_types, ( + f"ContentBlock type {block_type.__name__} has an input property, " + f"but is not included in TRACKS_TOOL_INPUT. You probably need to update the TRACKS_TOOL_INPUT type alias." + ) From 105afab15b934d9173d6efbdb6568f2b4c901544 Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:16:41 +0800 Subject: [PATCH 3/6] test(streaming): assert cache tokens when message_start omits usage Extend the #1806 regression tests to assert the cache usage fields (cache_creation_input_tokens / cache_read_input_tokens) alongside input/output tokens. The message_delta fixture now carries the full usage surface, so the test guards against silently dropping cache accounting when the accumulator initializes usage from message_delta. --- tests/lib/streaming/fixtures/missing_usage_response.txt | 2 +- tests/lib/streaming/test_messages.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/lib/streaming/fixtures/missing_usage_response.txt b/tests/lib/streaming/fixtures/missing_usage_response.txt index 95a60cf81..ab3a9a3e4 100644 --- a/tests/lib/streaming/fixtures/missing_usage_response.txt +++ b/tests/lib/streaming/fixtures/missing_usage_response.txt @@ -11,7 +11,7 @@ event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}} +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":11,"output_tokens":1,"cache_creation_input_tokens":3,"cache_read_input_tokens":5}} event: message_stop data: {"type":"message_stop"} diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index 3844a7029..f44e91abb 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -371,7 +371,10 @@ def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: ) as stream: message = stream.get_final_message() assert message.usage is not None + assert message.usage.input_tokens == 11 assert message.usage.output_tokens == 1 + assert message.usage.cache_creation_input_tokens == 3 + assert message.usage.cache_read_input_tokens == 5 assert message.stop_reason == "end_turn" assert len(message.content) == 1 assert message.content[0].type == "text" @@ -588,7 +591,10 @@ async def test_message_start_without_usage(self, respx_mock: MockRouter) -> None ) as stream: message = await stream.get_final_message() assert message.usage is not None + assert message.usage.input_tokens == 11 assert message.usage.output_tokens == 1 + assert message.usage.cache_creation_input_tokens == 3 + assert message.usage.cache_read_input_tokens == 5 assert message.stop_reason == "end_turn" assert len(message.content) == 1 assert message.content[0].type == "text" From 012a370d03f07c8130b1234f3a673822905b591a Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:28:55 +0800 Subject: [PATCH 4/6] fix(streaming): coerce missing input_tokens when message_start omits usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from Mycroft's re-measurement: on the exact stream reported in #1806 (message_delta carries only `output_tokens`), the accumulator built the snapshot with `Usage.construct(**event.usage.model_dump())`, which bypasses validation and leaves the required `input_tokens` field as None — trading the original AttributeError for a TypeError one frame further from the cause. - _messages.py / _beta_messages.py: coerce a missing `input_tokens` to 0 when constructing usage from the first message_delta - Fixture restored to #1806's reported shape (`{"output_tokens": 1}`) - Tests now use a non-strict client (strict validation rejects a usage-less message_start before the accumulator sees it) and assert `isinstance(message.usage.input_tokens, int)` — the discriminating assertion that fails on the previous implementation Refs #1806 --- src/anthropic/lib/streaming/_beta_messages.py | 9 +++++- src/anthropic/lib/streaming/_messages.py | 9 +++++- .../fixtures/missing_usage_response.txt | 2 +- tests/lib/streaming/test_messages.py | 28 +++++++++++++------ 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/anthropic/lib/streaming/_beta_messages.py b/src/anthropic/lib/streaming/_beta_messages.py index 2cf5e389c..f940dc55c 100644 --- a/src/anthropic/lib/streaming/_beta_messages.py +++ b/src/anthropic/lib/streaming/_beta_messages.py @@ -560,7 +560,14 @@ def accumulate_event( # message_delta carries the first full usage object, so construct it # before updating. if current_snapshot.usage is None: - current_snapshot.usage = BetaUsage.construct(**event.usage.model_dump()) + _usage_data = event.usage.model_dump() + # `BetaUsage.input_tokens` is a required int. When the delta omits it + # (e.g. `{"output_tokens": 1}` per #1806), `construct` would leave it + # as None on a non-optional field and break downstream arithmetic. + # Coerce a missing input_tokens to 0 so the snapshot stays valid. + if _usage_data.get("input_tokens") is None: + _usage_data["input_tokens"] = 0 + current_snapshot.usage = BetaUsage.construct(**_usage_data) else: current_snapshot.usage.output_tokens = event.usage.output_tokens diff --git a/src/anthropic/lib/streaming/_messages.py b/src/anthropic/lib/streaming/_messages.py index 932191021..587f42b17 100644 --- a/src/anthropic/lib/streaming/_messages.py +++ b/src/anthropic/lib/streaming/_messages.py @@ -525,7 +525,14 @@ def accumulate_event( # Usage may be absent when message_start omitted it (#1806); the message_delta # carries the first full usage object, so construct it before updating. if current_snapshot.usage is None: - current_snapshot.usage = Usage.construct(**event.usage.model_dump()) + _usage_data = event.usage.model_dump() + # `Usage.input_tokens` is a required int. When the delta omits it + # (e.g. `{"output_tokens": 1}` per #1806), `construct` would leave it + # as None on a non-optional field and break downstream arithmetic. + # Coerce a missing input_tokens to 0 so the snapshot stays valid. + if _usage_data.get("input_tokens") is None: + _usage_data["input_tokens"] = 0 + current_snapshot.usage = Usage.construct(**_usage_data) else: current_snapshot.usage.output_tokens = event.usage.output_tokens diff --git a/tests/lib/streaming/fixtures/missing_usage_response.txt b/tests/lib/streaming/fixtures/missing_usage_response.txt index ab3a9a3e4..95a60cf81 100644 --- a/tests/lib/streaming/fixtures/missing_usage_response.txt +++ b/tests/lib/streaming/fixtures/missing_usage_response.txt @@ -11,7 +11,7 @@ event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":11,"output_tokens":1,"cache_creation_input_tokens":3,"cache_read_input_tokens":5}} +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}} event: message_stop data: {"type":"message_stop"} diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index f44e91abb..974f14b4e 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -364,17 +364,24 @@ def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: return_value=httpx.Response(200, content=get_response("missing_usage_response.txt")) ) - with sync_client.messages.stream( + # The module-level `sync_client` is built with `_strict_response_validation=True`, + # which rejects a `message_start` without `usage` before the accumulator ever + # sees it. Use a non-strict client so the stream reaches the code under test — + # mirroring how the issue's repro drives the raw event sequence (#1806). + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.messages.stream( max_tokens=1024, messages=[{"role": "user", "content": "hi"}], model="claude-test", ) as stream: message = stream.get_final_message() assert message.usage is not None - assert message.usage.input_tokens == 11 + # The delta carries only `output_tokens` (#1806's reported shape); + # `input_tokens` is a required int, so the accumulator must coerce it + # (to 0) rather than leave it None and break downstream arithmetic. + assert isinstance(message.usage.input_tokens, int) assert message.usage.output_tokens == 1 - assert message.usage.cache_creation_input_tokens == 3 - assert message.usage.cache_read_input_tokens == 5 assert message.stop_reason == "end_turn" assert len(message.content) == 1 assert message.content[0].type == "text" @@ -584,17 +591,22 @@ async def test_message_start_without_usage(self, respx_mock: MockRouter) -> None return_value=httpx.Response(200, content=to_async_iter(get_response("missing_usage_response.txt"))) ) - async with async_client.messages.stream( + # See the sync test: use a non-strict client so the fixture reaches the + # accumulator instead of being rejected by response validation (#1806). + client = AsyncAnthropic(base_url=base_url, api_key=api_key) + + async with client.messages.stream( max_tokens=1024, messages=[{"role": "user", "content": "hi"}], model="claude-test", ) as stream: message = await stream.get_final_message() assert message.usage is not None - assert message.usage.input_tokens == 11 + # `input_tokens` is a required int; on #1806's reported shape (delta + # carries only `output_tokens`) the accumulator must coerce it to 0 + # rather than leave it None and break downstream arithmetic. + assert isinstance(message.usage.input_tokens, int) assert message.usage.output_tokens == 1 - assert message.usage.cache_creation_input_tokens == 3 - assert message.usage.cache_read_input_tokens == 5 assert message.stop_reason == "end_turn" assert len(message.content) == 1 assert message.content[0].type == "text" From 157f94aeb1b6a3e4cc495e64474d527d29836b89 Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:59:36 +0800 Subject: [PATCH 5/6] test(streaming): cover delta optional-usage fields, beta path, stop_details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the coercion fix, closing the coverage gaps measured on the previous head: 1. Optional-usage preservation is now pinned. Restoring the fixture to #1806's literal shape (`{"output_tokens": 1}`) removed the four assertions that pinned the delta's cache fields surviving into the snapshot, and a mutant that builds the snapshot from the two counters alone survived the whole suite. Keep both shapes apart rather than swapping them: `missing_usage_response.txt` stays as the reported stream with the `isinstance` assertion, and a new `missing_usage_rich_delta_response.txt` carries a full delta with the cache fields (plus a nested `server_tool_use`). 2. The beta accumulator is patched but was tested nowhere. Adds a sync test driving the same stream through `client.beta.messages.stream`. 3. `stop_details` is assigned under an `is not None` guard, making it sticky across a delta that clears it. That was an unreported behaviour change riding in this PR untested. It is kept deliberately — a `null` on the delta should not erase the value message_start carried — and now pinned by `test_stop_details_from_message_start_survives_null_delta`. Refs #1806 --- .../missing_usage_rich_delta_response.txt | 18 ++++++ .../fixtures/stop_details_response.txt | 18 ++++++ tests/lib/streaming/test_beta_messages.py | 28 +++++++++ tests/lib/streaming/test_messages.py | 62 +++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 tests/lib/streaming/fixtures/missing_usage_rich_delta_response.txt create mode 100644 tests/lib/streaming/fixtures/stop_details_response.txt diff --git a/tests/lib/streaming/fixtures/missing_usage_rich_delta_response.txt b/tests/lib/streaming/fixtures/missing_usage_rich_delta_response.txt new file mode 100644 index 000000000..2f19aeb00 --- /dev/null +++ b/tests/lib/streaming/fixtures/missing_usage_rich_delta_response.txt @@ -0,0 +1,18 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":11,"output_tokens":1,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"server_tool_use":{"web_search_requests":2},"cache_creation":{"ephemeral_5m_input_tokens":3}}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/lib/streaming/fixtures/stop_details_response.txt b/tests/lib/streaming/fixtures/stop_details_response.txt new file mode 100644 index 000000000..4c858db79 --- /dev/null +++ b/tests/lib/streaming/fixtures/stop_details_response.txt @@ -0,0 +1,18 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null,"stop_details":{"type":"refusal"},"usage":{"input_tokens":11,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/lib/streaming/test_beta_messages.py b/tests/lib/streaming/test_beta_messages.py index c80bff506..34a1cf561 100644 --- a/tests/lib/streaming/test_beta_messages.py +++ b/tests/lib/streaming/test_beta_messages.py @@ -357,6 +357,34 @@ def test_basic_response(self, respx_mock: MockRouter) -> None: assert_basic_response([event for event in stream], stream.get_final_message()) + @pytest.mark.respx(base_url=base_url) + def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Beta accumulator: message_start omitting usage must not crash the stream. + + The beta path carries its own copy of the accumulator, so the sync path's + coverage does not exercise it. Same contract as the non-beta test: usage is + built from the first message_delta, and a delta carrying only `output_tokens` + (#1806's reported shape) must leave `input_tokens` an int, coerced, not None. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_response.txt")) + ) + + # Non-strict client: strict validation rejects a usage-less message_start + # before the accumulator sees it. + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.beta.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + assert isinstance(message.usage.input_tokens, int) + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + @pytest.mark.respx(base_url=base_url) def test_tool_use(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index 974f14b4e..7b20b1f78 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -387,6 +387,68 @@ def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: assert message.content[0].type == "text" assert message.content[0].text == "hi" + def test_message_start_without_usage_preserves_delta_optional_usage_fields( + self, respx_mock: MockRouter + ) -> None: + """When message_start omits usage, every field the delta carries must survive. + + Complements `test_message_start_without_usage`, which covers #1806's literal + stream (a delta carrying only `output_tokens`). That shape cannot pin the + rest of the snapshot, so the two are deliberately kept apart rather than + merged: a rich delta here, the reported shape there. + + Killing test: building the snapshot from the two counters alone (dropping + cache fields, `server_tool_use` and anything else on the delta) passes the + reported-shape suite but fails this one. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_rich_delta_response.txt")) + ) + + # Non-strict client: strict validation rejects a usage-less message_start + # before the accumulator sees it (see test_message_start_without_usage). + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + assert message.usage.input_tokens == 11 + assert message.usage.output_tokens == 1 + assert message.usage.cache_creation_input_tokens == 3 + assert message.usage.cache_read_input_tokens == 5 + # nested objects on the delta survive as models, not raw dicts + assert message.usage.server_tool_use is not None + assert message.usage.server_tool_use.web_search_requests == 2 + + def test_stop_details_from_message_start_survives_null_delta( + self, respx_mock: MockRouter + ) -> None: + """A `stop_details: null` on message_delta must not erase the start's value. + + The accumulator assigns `stop_details` under an `is not None` guard, so a + value carried by message_start is sticky across a delta that clears it. Pins + that behaviour: present-then-null keeps it. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("stop_details_response.txt")) + ) + + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.stop_details is not None + assert message.stop_details.type == "refusal" + assert message.stop_reason == "end_turn" + def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( From b2bd35e2015095aeda357b7cddb1e0d8bf6b02b7 Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:08:47 +0800 Subject: [PATCH 6/6] test(streaming): pin optional-usage and stop_details on the beta accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The std path was pinned on the previous head — both mutants die on their intended test there — but the beta accumulator carries its own copy of the patched code and was left unguarded: the same two mutants applied to `_beta_messages.py` survive with the whole suite green. Two tests, no new fixtures — both fixtures already run through the beta manager unchanged: - `test_message_start_without_usage_preserves_delta_optional_usage_fields` drives the rich delta through `client.beta.messages.stream`. On the beta path a snapshot built from the two counters alone still satisfies the coercion assertions, because the beta accumulator re-heals the four enumerated fields outside the missing-usage branch; `cache_creation` is not among them, so it is what this asserts on. - `test_stop_details_from_message_start_survives_null_delta` mirrors the std test of the same name. The beta file has its own `is not None` guard, so reverting it there alone changes observable output with nothing failing. Refs #1806 --- tests/lib/streaming/test_beta_messages.py | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/lib/streaming/test_beta_messages.py b/tests/lib/streaming/test_beta_messages.py index 34a1cf561..07270d18d 100644 --- a/tests/lib/streaming/test_beta_messages.py +++ b/tests/lib/streaming/test_beta_messages.py @@ -385,6 +385,72 @@ def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: assert message.usage.output_tokens == 1 assert message.stop_reason == "end_turn" + @pytest.mark.respx(base_url=base_url) + def test_message_start_without_usage_preserves_delta_optional_usage_fields( + self, respx_mock: MockRouter + ) -> None: + """The beta accumulator must preserve the delta's optional fields too. + + `test_message_start_without_usage` covers the beta path's coercion, but the + coercion is only half of what the accumulator does: the same missing-usage + branch also has to carry every remaining field of the delta into the + snapshot. Driving the rich fixture through the beta manager pins that here + as it is pinned on the sync path. + + Killing test: on the beta copy, building the missing-usage snapshot from the + two counters alone still satisfies the coercion assertions — the beta + accumulator re-heals the four enumerated fields outside the branch — but it + drops `cache_creation`, which is what this asserts on. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_rich_delta_response.txt")) + ) + + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.beta.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + assert message.usage.input_tokens == 11 + assert message.usage.output_tokens == 1 + assert message.usage.cache_creation_input_tokens == 3 + assert message.usage.cache_read_input_tokens == 5 + # nested objects on the delta survive as models, not raw dicts + assert message.usage.server_tool_use is not None + assert message.usage.server_tool_use.web_search_requests == 2 + assert message.usage.cache_creation is not None + assert message.usage.cache_creation.ephemeral_5m_input_tokens == 3 + + @pytest.mark.respx(base_url=base_url) + def test_stop_details_from_message_start_survives_null_delta( + self, respx_mock: MockRouter + ) -> None: + """Beta: a `stop_details: null` on message_delta must not erase the start's value. + + Mirrors the sync-path test of the same name. The beta accumulator carries its + own copy of the `is not None` guard, so the sync test does not pin this one; + reverting the guard on the beta file alone leaves the whole suite green. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("stop_details_response.txt")) + ) + + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.beta.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.stop_details is not None + assert message.stop_details.type == "refusal" + assert message.stop_reason == "end_turn" + @pytest.mark.respx(base_url=base_url) def test_tool_use(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock(