From 8e626ac7c88bab413bc1e2d83b7556aa4a44fb63 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 10:29:12 +0000 Subject: [PATCH 01/11] chore(internal): add helper method for constructing `BaseModel`s (#572) --- src/anthropic/_models.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/anthropic/_models.py b/src/anthropic/_models.py index 75c68cc7..5d95bb4b 100644 --- a/src/anthropic/_models.py +++ b/src/anthropic/_models.py @@ -10,6 +10,7 @@ ClassVar, Protocol, Required, + ParamSpec, TypedDict, TypeGuard, final, @@ -67,6 +68,9 @@ __all__ = ["BaseModel", "GenericModel"] _T = TypeVar("_T") +_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel") + +P = ParamSpec("P") @runtime_checkable @@ -379,6 +383,29 @@ def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericMo return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) +def build( + base_model_cls: Callable[P, _BaseModelT], + *args: P.args, + **kwargs: P.kwargs, +) -> _BaseModelT: + """Construct a BaseModel class without validation. + + This is useful for cases where you need to instantiate a `BaseModel` + from an API response as this provides type-safe params which isn't supported + by helpers like `construct_type()`. + + ```py + build(MyModel, my_field_a="foo", my_field_b=123) + ``` + """ + if args: + raise TypeError( + "Received positional arguments which are not supported; Keyword arguments must be used instead", + ) + + return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs)) + + def construct_type(*, value: object, type_: object) -> object: """Loose coercion to the expected type with construction of nested values. From 6051763d886aa7107389d8b8aeacf74d296eed3d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 19:12:27 +0000 Subject: [PATCH 02/11] fix(client): always respect content-type multipart/form-data if provided (#574) --- src/anthropic/_base_client.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index d9b6c23d..9e215f57 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -58,6 +58,7 @@ HttpxSendArgs, AsyncTransport, RequestOptions, + HttpxRequestFiles, ModelBuilderProtocol, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping @@ -460,6 +461,7 @@ def _build_request( headers = self._build_headers(options) params = _merge_mappings(self.default_query, options.params) content_type = headers.get("Content-Type") + files = options.files # If the given Content-Type header is multipart/form-data then it # has to be removed so that httpx can generate the header with @@ -473,7 +475,7 @@ def _build_request( headers.pop("Content-Type") # As we are now sending multipart/form-data instead of application/json - # we need to tell httpx to use it, https://www.python-httpx.org/advanced/#multipart-file-encoding + # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding if json_data: if not is_dict(json_data): raise TypeError( @@ -481,6 +483,15 @@ def _build_request( ) kwargs["data"] = self._serialize_multipartform(json_data) + # httpx determines whether or not to send a "multipart/form-data" + # request based on the truthiness of the "files" argument. + # This gets around that issue by generating a dict value that + # evaluates to true. + # + # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186 + if not files: + files = cast(HttpxRequestFiles, ForceMultipartDict()) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, @@ -493,7 +504,7 @@ def _build_request( # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, json=json_data, - files=options.files, + files=files, **kwargs, ) @@ -1890,6 +1901,11 @@ def make_request_options( return options +class ForceMultipartDict(Dict[str, None]): + def __bool__(self) -> bool: + return True + + class OtherPlatform: def __init__(self, name: str) -> None: self.name = name From 98e2075869d816cd85af1a0588bd27719eff02a4 Mon Sep 17 00:00:00 2001 From: Robert Craigie Date: Wed, 3 Jul 2024 17:45:34 +0100 Subject: [PATCH 03/11] fix(streaming/messages): more robust event type construction (#576) previously, the helpers could crash if the API responded with a field that wasn't in the types yet, or if the property types didn't match which is not ideal --- src/anthropic/lib/streaming/_messages.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/anthropic/lib/streaming/_messages.py b/src/anthropic/lib/streaming/_messages.py index f1073fea..7e06c127 100644 --- a/src/anthropic/lib/streaming/_messages.py +++ b/src/anthropic/lib/streaming/_messages.py @@ -15,7 +15,7 @@ ) from ...types import Message, ContentBlock, RawMessageStreamEvent from ..._utils import consume_sync_iterator, consume_async_iterator -from ..._models import construct_type +from ..._models import construct_type, build from ..._streaming import Stream, AsyncStream if TYPE_CHECKING: @@ -334,7 +334,7 @@ def build_events( elif event.type == "message_delta": events_to_fire.append(event) elif event.type == "message_stop": - events_to_fire.append(MessageStopEvent(type="message_stop", message=message_snapshot)) + events_to_fire.append(build(MessageStopEvent, type="message_stop", message=message_snapshot)) elif event.type == "content_block_start": events_to_fire.append(event) elif event.type == "content_block_delta": @@ -343,7 +343,8 @@ def build_events( content_block = message_snapshot.content[event.index] if event.delta.type == "text_delta" and content_block.type == "text": events_to_fire.append( - TextEvent( + build( + TextEvent, type="text", text=event.delta.text, snapshot=content_block.text, @@ -351,7 +352,8 @@ def build_events( ) elif event.delta.type == "input_json_delta" and content_block.type == "tool_use": events_to_fire.append( - InputJsonEvent( + build( + InputJsonEvent, type="input_json", partial_json=event.delta.partial_json, snapshot=content_block.input, @@ -361,7 +363,7 @@ def build_events( content_block = message_snapshot.content[event.index] events_to_fire.append( - ContentBlockStopEvent(type="content_block_stop", index=event.index, content_block=content_block), + build(ContentBlockStopEvent, type="content_block_stop", index=event.index, content_block=content_block), ) else: # we only want exhaustive checking for linters, not at runtime From e271d694babfb4bcb506064aa353ee29a8394c1d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Jul 2024 17:36:36 +0000 Subject: [PATCH 04/11] chore(ci): update rye to v0.35.0 (#577) --- .devcontainer/Dockerfile | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/create-releases.yml | 4 ++-- .github/workflows/publish-pypi.yml | 4 ++-- requirements-dev.lock | 1 + requirements.lock | 1 + 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 83bca8f7..ac9a2e75 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} USER vscode -RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.24.0" RYE_INSTALL_OPTION="--yes" bash +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.35.0" RYE_INSTALL_OPTION="--yes" bash ENV PATH=/home/vscode/.rye/shims:$PATH RUN echo "[[ -d .venv ]] && source .venv/bin/activate" >> /home/vscode/.bashrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44783705..eb542396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: 0.24.0 + RYE_VERSION: '0.35.0' RYE_INSTALL_OPTION: '--yes' - name: Install dependencies @@ -42,7 +42,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: 0.24.0 + RYE_VERSION: '0.35.0' RYE_INSTALL_OPTION: '--yes' - name: Bootstrap diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index 2b3c7a6d..3fbca5e4 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -28,8 +28,8 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: 0.24.0 - RYE_INSTALL_OPTION: "--yes" + RYE_VERSION: '0.35.0' + RYE_INSTALL_OPTION: '--yes' - name: Publish to PyPI if: ${{ steps.release.outputs.releases_created }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 1d554da1..5e2e9a9c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,8 +17,8 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: 0.24.0 - RYE_INSTALL_OPTION: "--yes" + RYE_VERSION: '0.35.0' + RYE_INSTALL_OPTION: '--yes' - name: Publish to PyPI run: | diff --git a/requirements-dev.lock b/requirements-dev.lock index 54d71989..f226bea6 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -6,6 +6,7 @@ # features: [] # all-features: true # with-sources: false +# generate-hashes: false -e file:. annotated-types==0.6.0 diff --git a/requirements.lock b/requirements.lock index 49c4e9bd..80a8d9d4 100644 --- a/requirements.lock +++ b/requirements.lock @@ -6,6 +6,7 @@ # features: [] # all-features: true # with-sources: false +# generate-hashes: false -e file:. annotated-types==0.6.0 From 37bd4337828f3efa14b194fa3025638229129416 Mon Sep 17 00:00:00 2001 From: Robert Craigie Date: Mon, 8 Jul 2024 09:16:04 +0100 Subject: [PATCH 05/11] fix(vertex): avoid credentials refresh on every request (#575) --- src/anthropic/lib/vertex/_client.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/anthropic/lib/vertex/_client.py b/src/anthropic/lib/vertex/_client.py index 9d695524..578cb559 100644 --- a/src/anthropic/lib/vertex/_client.py +++ b/src/anthropic/lib/vertex/_client.py @@ -168,13 +168,11 @@ def __init__( @override def _prepare_request(self, request: httpx.Request) -> None: - access_token = self._ensure_access_token() - if request.headers.get("Authorization"): # already authenticated, nothing for us to do return - request.headers["Authorization"] = f"Bearer {access_token}" + request.headers["Authorization"] = f"Bearer {self._ensure_access_token()}" def _ensure_access_token(self) -> str: if self.access_token is not None: @@ -184,7 +182,8 @@ def _ensure_access_token(self) -> str: self.credentials, project_id = load_auth(project_id=self.project_id) if not self.project_id: self.project_id = project_id - else: + + if self.credentials.expired: refresh_auth(self.credentials) if not self.credentials.token: @@ -256,13 +255,11 @@ def __init__( @override async def _prepare_request(self, request: httpx.Request) -> None: - access_token = await self._ensure_access_token() - if request.headers.get("Authorization"): # already authenticated, nothing for us to do return - request.headers["Authorization"] = f"Bearer {access_token}" + request.headers["Authorization"] = f"Bearer {await self._ensure_access_token()}" async def _ensure_access_token(self) -> str: if self.access_token is not None: @@ -272,11 +269,12 @@ async def _ensure_access_token(self) -> str: self.credentials, project_id = await asyncify(load_auth)(project_id=self.project_id) if not self.project_id: self.project_id = project_id - else: + + if self.credentials.expired: await asyncify(refresh_auth)(self.credentials) if not self.credentials.token: raise RuntimeError("Could not resolve API token from the environment") assert isinstance(self.credentials.token, str) - return self.credentials.token \ No newline at end of file + return self.credentials.token From a912917686d6e4a46d192abf002ac69357b1d955 Mon Sep 17 00:00:00 2001 From: Robert Craigie Date: Mon, 8 Jul 2024 09:20:25 +0100 Subject: [PATCH 06/11] chore(internal): fix formatting --- src/anthropic/lib/streaming/_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anthropic/lib/streaming/_messages.py b/src/anthropic/lib/streaming/_messages.py index 7e06c127..d39993a8 100644 --- a/src/anthropic/lib/streaming/_messages.py +++ b/src/anthropic/lib/streaming/_messages.py @@ -15,7 +15,7 @@ ) from ...types import Message, ContentBlock, RawMessageStreamEvent from ..._utils import consume_sync_iterator, consume_async_iterator -from ..._models import construct_type, build +from ..._models import build, construct_type from ..._streaming import Stream, AsyncStream if TYPE_CHECKING: From fcd425f724fee45195118aa218bd5c51fb9abed0 Mon Sep 17 00:00:00 2001 From: David Volquartz Lebech Date: Mon, 8 Jul 2024 10:35:37 +0200 Subject: [PATCH 07/11] feat(vertex): add copy and with_options (#578) * feat(vertex): add copy and with_options Closes #566 * move vertex client tests to a separate file * add missing `credentials` argument to `copy()` * minor cleanup --------- Co-authored-by: Robert Craigie --- src/anthropic/lib/vertex/_client.py | 166 +++++++++++++++++++++++++++- tests/lib/test_vertex.py | 160 +++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 tests/lib/test_vertex.py diff --git a/src/anthropic/lib/vertex/_client.py b/src/anthropic/lib/vertex/_client.py index 578cb559..a513ff5d 100644 --- a/src/anthropic/lib/vertex/_client.py +++ b/src/anthropic/lib/vertex/_client.py @@ -2,7 +2,7 @@ import os from typing import TYPE_CHECKING, Any, Union, Mapping, TypeVar -from typing_extensions import override +from typing_extensions import Self, override import httpx @@ -15,7 +15,15 @@ from ..._version import __version__ from ..._streaming import Stream, AsyncStream from ..._exceptions import APIStatusError -from ..._base_client import DEFAULT_MAX_RETRIES, BaseClient, SyncAPIClient, AsyncAPIClient +from ..._base_client import ( + DEFAULT_MAX_RETRIES, + DEFAULT_CONNECTION_LIMITS, + BaseClient, + SyncAPIClient, + AsyncAPIClient, + SyncHttpxClientWrapper, + AsyncHttpxClientWrapper, +) from ...resources.messages import Messages, AsyncMessages if TYPE_CHECKING: @@ -115,6 +123,7 @@ def __init__( region: str | NotGiven = NOT_GIVEN, project_id: str | NotGiven = NOT_GIVEN, access_token: str | None = None, + credentials: GoogleCredentials | None = None, base_url: str | httpx.URL | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, max_retries: int = DEFAULT_MAX_RETRIES, @@ -128,7 +137,6 @@ def __init__( proxies: ProxiesTypes | None = None, # See httpx documentation for [limits](https://www.python-httpx.org/advanced/#pool-limit-configuration) connection_pool_limits: httpx.Limits | None = None, - credentials: GoogleCredentials | None = None, _strict_response_validation: bool = False, ) -> None: if not is_given(region): @@ -192,6 +200,81 @@ def _ensure_access_token(self) -> str: assert isinstance(self.credentials.token, str) return self.credentials.token + def copy( + self, + *, + region: str | NotGiven = NOT_GIVEN, + project_id: str | NotGiven = NOT_GIVEN, + access_token: str | None = None, + credentials: GoogleCredentials | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.Client | None = None, + connection_pool_limits: httpx.Limits | None = None, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + if connection_pool_limits is not None: + if http_client is not None: + raise ValueError("The 'http_client' argument is mutually exclusive with 'connection_pool_limits'") + + if not isinstance(self._client, SyncHttpxClientWrapper): + raise ValueError( + "A custom HTTP client has been set and is mutually exclusive with the 'connection_pool_limits' argument" + ) + + http_client = None + else: + if self._limits is not DEFAULT_CONNECTION_LIMITS: + connection_pool_limits = self._limits + else: + connection_pool_limits = None + + http_client = http_client or self._client + + return self.__class__( + region=region if is_given(region) else self.region, + project_id=project_id if is_given(project_id) else self.project_id or NOT_GIVEN, + access_token=access_token or self.access_token, + credentials=credentials or self.credentials, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + class AsyncAnthropicVertex(BaseVertexClient[httpx.AsyncClient, AsyncStream[Any]], AsyncAPIClient): messages: AsyncMessages @@ -202,6 +285,7 @@ def __init__( region: str | NotGiven = NOT_GIVEN, project_id: str | NotGiven = NOT_GIVEN, access_token: str | None = None, + credentials: GoogleCredentials | None = None, base_url: str | httpx.URL | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, max_retries: int = DEFAULT_MAX_RETRIES, @@ -215,7 +299,6 @@ def __init__( proxies: ProxiesTypes | None = None, # See httpx documentation for [limits](https://www.python-httpx.org/advanced/#pool-limit-configuration) connection_pool_limits: httpx.Limits | None = None, - credentials: GoogleCredentials | None = None, _strict_response_validation: bool = False, ) -> None: if not is_given(region): @@ -278,3 +361,78 @@ async def _ensure_access_token(self) -> str: assert isinstance(self.credentials.token, str) return self.credentials.token + + def copy( + self, + *, + region: str | NotGiven = NOT_GIVEN, + project_id: str | NotGiven = NOT_GIVEN, + access_token: str | None = None, + credentials: GoogleCredentials | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.AsyncClient | None = None, + connection_pool_limits: httpx.Limits | None = None, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + if connection_pool_limits is not None: + if http_client is not None: + raise ValueError("The 'http_client' argument is mutually exclusive with 'connection_pool_limits'") + + if not isinstance(self._client, AsyncHttpxClientWrapper): + raise ValueError( + "A custom HTTP client has been set and is mutually exclusive with the 'connection_pool_limits' argument" + ) + + http_client = None + else: + if self._limits is not DEFAULT_CONNECTION_LIMITS: + connection_pool_limits = self._limits + else: + connection_pool_limits = None + + http_client = http_client or self._client + + return self.__class__( + region=region if is_given(region) else self.region, + project_id=project_id if is_given(project_id) else self.project_id or NOT_GIVEN, + access_token=access_token or self.access_token, + credentials=credentials or self.credentials, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy diff --git a/tests/lib/test_vertex.py b/tests/lib/test_vertex.py new file mode 100644 index 00000000..2f741c3f --- /dev/null +++ b/tests/lib/test_vertex.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import os + +import httpx +import pytest + +from anthropic import AnthropicVertex, AsyncAnthropicVertex + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAnthropicVertex: + client = AnthropicVertex(region="region", project_id="project") + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + copied = self.client.copy(region="another-region", project_id="another-project") + assert copied.region == "another-region" + assert self.client.region == "region" + assert copied.project_id == "another-project" + assert self.client.project_id == "project" + + def test_with_options(self) -> None: + copied = self.client.with_options(region="another-region", project_id="another-project") + assert copied.region == "another-region" + assert self.client.region == "region" + assert copied.project_id == "another-project" + assert self.client.project_id == "project" + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = AnthropicVertex( + base_url=base_url, + region="region", + project_id="project", + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + + +class TestAsyncAnthropicVertex: + client = AsyncAnthropicVertex(region="region", project_id="project") + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + copied = self.client.copy(region="another-region", project_id="another-project") + assert copied.region == "another-region" + assert self.client.region == "region" + assert copied.project_id == "another-project" + assert self.client.project_id == "project" + + def test_with_options(self) -> None: + copied = self.client.with_options(region="another-region", project_id="another-project") + assert copied.region == "another-region" + assert self.client.region == "region" + assert copied.project_id == "another-project" + assert self.client.project_id == "project" + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = AsyncAnthropicVertex( + base_url=base_url, + region="region", + project_id="project", + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) From d1dcf427ea78f57dd267d891c276b03d4010de78 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:52:36 +0000 Subject: [PATCH 08/11] chore(internal): minor request options handling changes (#580) --- src/anthropic/_base_client.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 9e215f57..46cf5e0b 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -956,6 +956,11 @@ def _request( stream: bool, stream_cls: type[_StreamT] | None, ) -> ResponseT | _StreamT: + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + cast_to = self._maybe_override_cast_to(cast_to, options) self._prepare_options(options) @@ -980,7 +985,7 @@ def _request( if retries > 0: return self._retry_request( - options, + input_options, cast_to, retries, stream=stream, @@ -995,7 +1000,7 @@ def _request( if retries > 0: return self._retry_request( - options, + input_options, cast_to, retries, stream=stream, @@ -1023,7 +1028,7 @@ def _request( if retries > 0 and self._should_retry(err.response): err.response.close() return self._retry_request( - options, + input_options, cast_to, retries, err.response.headers, @@ -1532,6 +1537,11 @@ async def _request( # execute it earlier while we are in an async context self._platform = await asyncify(get_platform)() + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + cast_to = self._maybe_override_cast_to(cast_to, options) await self._prepare_options(options) @@ -1554,7 +1564,7 @@ async def _request( if retries > 0: return await self._retry_request( - options, + input_options, cast_to, retries, stream=stream, @@ -1569,7 +1579,7 @@ async def _request( if retries > 0: return await self._retry_request( - options, + input_options, cast_to, retries, stream=stream, @@ -1592,7 +1602,7 @@ async def _request( if retries > 0 and self._should_retry(err.response): await err.response.aclose() return await self._retry_request( - options, + input_options, cast_to, retries, err.response.headers, From 130d470fc624a25defb9d8e787462b77bdc0aad5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 10:29:02 +0000 Subject: [PATCH 09/11] feat(client): make request-id header more accessible (#581) --- src/anthropic/_base_client.py | 1 + src/anthropic/_exceptions.py | 2 ++ src/anthropic/_legacy_response.py | 4 ++++ src/anthropic/_response.py | 8 ++++++++ 4 files changed, 15 insertions(+) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 46cf5e0b..39c311fd 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -1019,6 +1019,7 @@ def _request( response.reason_phrase, response.headers, ) + log.debug("request_id: %s", response.headers.get("request-id")) try: response.raise_for_status() diff --git a/src/anthropic/_exceptions.py b/src/anthropic/_exceptions.py index 28583feb..4d0a8087 100644 --- a/src/anthropic/_exceptions.py +++ b/src/anthropic/_exceptions.py @@ -59,11 +59,13 @@ class APIStatusError(APIError): response: httpx.Response status_code: int + request_id: str | None def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: super().__init__(message, response.request, body=body) self.response = response self.status_code = response.status_code + self.request_id = response.headers.get("request-id") class APIConnectionError(APIError): diff --git a/src/anthropic/_legacy_response.py b/src/anthropic/_legacy_response.py index 77e9c88e..d4a19722 100644 --- a/src/anthropic/_legacy_response.py +++ b/src/anthropic/_legacy_response.py @@ -71,6 +71,10 @@ def __init__( self._options = options self.http_response = raw + @property + def request_id(self) -> str | None: + return self.http_response.headers.get("request-id") # type: ignore[no-any-return] + @overload def parse(self, *, to: type[_T]) -> _T: ... diff --git a/src/anthropic/_response.py b/src/anthropic/_response.py index b0dc8bec..e57c9ad3 100644 --- a/src/anthropic/_response.py +++ b/src/anthropic/_response.py @@ -258,6 +258,10 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: class APIResponse(BaseAPIResponse[R]): + @property + def request_id(self) -> str | None: + return self.http_response.headers.get("request-id") # type: ignore[no-any-return] + @overload def parse(self, *, to: type[_T]) -> _T: ... @@ -362,6 +366,10 @@ def iter_lines(self) -> Iterator[str]: class AsyncAPIResponse(BaseAPIResponse[R]): + @property + def request_id(self) -> str | None: + return self.http_response.headers.get("request-id") # type: ignore[no-any-return] + @overload async def parse(self, *, to: type[_T]) -> _T: ... From ebd659014b63b51fa2f67fe88ef3fc9922be830d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:37:23 +0000 Subject: [PATCH 10/11] fix(types): allow arbitrary types in image block param (#582) --- src/anthropic/_models.py | 8 ++++++++ src/anthropic/types/image_block_param.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/src/anthropic/_models.py b/src/anthropic/_models.py index 5d95bb4b..eb7ce3bd 100644 --- a/src/anthropic/_models.py +++ b/src/anthropic/_models.py @@ -643,6 +643,14 @@ def validate_type(*, type_: type[_T], value: object) -> _T: return cast(_T, _validate_non_model_type(type_=type_, value=value)) +def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: + """Add a pydantic config for the given type. + + Note: this is a no-op on Pydantic v1. + """ + setattr(typ, "__pydantic_config__", config) # noqa: B010 + + # our use of subclasssing here causes weirdness for type checkers, # so we just pretend that we don't subclass if TYPE_CHECKING: diff --git a/src/anthropic/types/image_block_param.py b/src/anthropic/types/image_block_param.py index 45236832..d7f46fa9 100644 --- a/src/anthropic/types/image_block_param.py +++ b/src/anthropic/types/image_block_param.py @@ -7,6 +7,7 @@ from .._types import Base64FileInput from .._utils import PropertyInfo +from .._models import set_pydantic_config __all__ = ["ImageBlockParam", "Source"] @@ -19,6 +20,9 @@ class Source(TypedDict, total=False): type: Required[Literal["base64"]] +set_pydantic_config(Source, {"arbitrary_types_allowed": True}) + + class ImageBlockParam(TypedDict, total=False): source: Required[Source] From 69ff067171d90839181e747347797bb00c71a8df Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:37:44 +0000 Subject: [PATCH 11/11] release: 0.31.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 26 ++++++++++++++++++++++++++ pyproject.toml | 2 +- src/anthropic/_version.py | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 20e1f11f..f81bf992 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.30.1" + ".": "0.31.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c979562b..61edf009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.31.0 (2024-07-10) + +Full Changelog: [v0.30.1...v0.31.0](https://github.com/anthropics/anthropic-sdk-python/compare/v0.30.1...v0.31.0) + +### Features + +* **client:** make request-id header more accessible ([#581](https://github.com/anthropics/anthropic-sdk-python/issues/581)) ([130d470](https://github.com/anthropics/anthropic-sdk-python/commit/130d470fc624a25defb9d8e787462b77bdc0aad5)) +* **vertex:** add copy and with_options ([#578](https://github.com/anthropics/anthropic-sdk-python/issues/578)) ([fcd425f](https://github.com/anthropics/anthropic-sdk-python/commit/fcd425f724fee45195118aa218bd5c51fb9abed0)) + + +### Bug Fixes + +* **client:** always respect content-type multipart/form-data if provided ([#574](https://github.com/anthropics/anthropic-sdk-python/issues/574)) ([6051763](https://github.com/anthropics/anthropic-sdk-python/commit/6051763d886aa7107389d8b8aeacf74d296eed3d)) +* **streaming/messages:** more robust event type construction ([#576](https://github.com/anthropics/anthropic-sdk-python/issues/576)) ([98e2075](https://github.com/anthropics/anthropic-sdk-python/commit/98e2075869d816cd85af1a0588bd27719eff02a4)) +* **types:** allow arbitrary types in image block param ([#582](https://github.com/anthropics/anthropic-sdk-python/issues/582)) ([ebd6590](https://github.com/anthropics/anthropic-sdk-python/commit/ebd659014b63b51fa2f67fe88ef3fc9922be830d)) +* Updated doc typo ([17be06b](https://github.com/anthropics/anthropic-sdk-python/commit/17be06bf3e39eff9de588d99cd59fa509c5ee6a6)) +* **vertex:** avoid credentials refresh on every request ([#575](https://github.com/anthropics/anthropic-sdk-python/issues/575)) ([37bd433](https://github.com/anthropics/anthropic-sdk-python/commit/37bd4337828f3efa14b194fa3025638229129416)) + + +### Chores + +* **ci:** update rye to v0.35.0 ([#577](https://github.com/anthropics/anthropic-sdk-python/issues/577)) ([e271d69](https://github.com/anthropics/anthropic-sdk-python/commit/e271d694babfb4bcb506064aa353ee29a8394c1d)) +* **internal:** add helper method for constructing `BaseModel`s ([#572](https://github.com/anthropics/anthropic-sdk-python/issues/572)) ([8e626ac](https://github.com/anthropics/anthropic-sdk-python/commit/8e626ac7c88bab413bc1e2d83b7556aa4a44fb63)) +* **internal:** fix formatting ([a912917](https://github.com/anthropics/anthropic-sdk-python/commit/a912917686d6e4a46d192abf002ac69357b1d955)) +* **internal:** minor request options handling changes ([#580](https://github.com/anthropics/anthropic-sdk-python/issues/580)) ([d1dcf42](https://github.com/anthropics/anthropic-sdk-python/commit/d1dcf427ea78f57dd267d891c276b03d4010de78)) + ## 0.30.1 (2024-07-01) Full Changelog: [v0.30.0...v0.30.1](https://github.com/anthropics/anthropic-sdk-python/compare/v0.30.0...v0.30.1) diff --git a/pyproject.toml b/pyproject.toml index 3824bfca..491cbfc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anthropic" -version = "0.30.1" +version = "0.31.0" description = "The official Python library for the anthropic API" dynamic = ["readme"] license = "MIT" diff --git a/src/anthropic/_version.py b/src/anthropic/_version.py index 63778ab3..bb4a1435 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.30.1" # x-release-please-version +__version__ = "0.31.0" # x-release-please-version