fix(models): report a missing Anthropic credential when the client is built

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 960406138
This commit is contained in:
George Weale
2026-08-06 11:14:21 -07:00
committed by Copybara-Service
parent 6ec23ccbb8
commit 42f220a61b
7 changed files with 10070 additions and 3 deletions
+1951
View File
File diff suppressed because it is too large Load Diff
+2243
View File
File diff suppressed because it is too large Load Diff
+1919
View File
File diff suppressed because it is too large Load Diff
+1899
View File
File diff suppressed because it is too large Load Diff
+1899
View File
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -78,6 +78,14 @@ _MessageBlockParam: TypeAlias = Union[
anthropic_types.ToolResultBlockParam,
]
# Attributes an Anthropic client exposes once it has resolved a credential,
# whichever source it came from: a static API key, a static bearer token, or a
# credential provider discovered from the environment or from the on-disk
# Anthropic configuration. Only these three carry a credential - the client's
# own "could not resolve authentication method" error names the same three.
# `credentials` is absent on older supported SDK versions, so the lookup below
# tolerates a missing attribute.
_ANTHROPIC_CREDENTIAL_ATTRS = ("api_key", "auth_token", "credentials")
_RATE_LIMIT_POSSIBLE_FIX_MESSAGE = (
"On how to mitigate this issue, please refer to:\n\n"
@@ -1045,7 +1053,21 @@ class AnthropicLlm(BaseLlm):
@cached_property
def _anthropic_client(self) -> AsyncAnthropic | AsyncAnthropicVertex:
return AsyncAnthropic()
client = AsyncAnthropic()
# Let the SDK run its own credential resolution first, then ask the client
# what it found. Enumerating credential sources here would reject setups
# the SDK handles perfectly well, such as a signed-in on-disk profile with
# no credential environment variable set at all.
if not any(
getattr(client, attr, None) for attr in _ANTHROPIC_CREDENTIAL_ATTRS
):
raise ValueError(
"No Anthropic credential was found for calling Claude through the"
" Anthropic API. Set ANTHROPIC_API_KEY to a key from the Anthropic"
" Console, e.g. `export ANTHROPIC_API_KEY=<your-key>`, or configure"
" any other credential the Anthropic SDK can discover."
)
return client
class Claude(AnthropicLlm):
@@ -1084,8 +1106,11 @@ class Claude(AnthropicLlm):
if not project_id or not location:
raise ValueError(
"GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION must be set for using"
" Anthropic on Vertex."
f"Model {self.model!r} resolves to Claude served from Vertex AI, so"
" GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION must be set to the"
" project and region serving the model. To call the Anthropic API"
" directly with an ANTHROPIC_API_KEY instead, pass a model instance"
" configured for the Anthropic API rather than a bare model name."
)
return AsyncAnthropicVertex(
@@ -45,6 +45,17 @@ import httpx
import pytest
@pytest.fixture(autouse=True)
def placeholder_anthropic_api_key(monkeypatch):
"""Keeps client construction off whatever credential this machine has.
Patching `_anthropic_client` evaluates the cached property, which builds a
real client, so the tests below need some credential resolvable - and it
must be this placeholder rather than a developer's own key.
"""
monkeypatch.setenv("ANTHROPIC_API_KEY", "placeholder-not-a-real-key")
@pytest.fixture
def generate_content_response():
return anthropic_types.Message(
@@ -3091,3 +3102,123 @@ async def test_streaming_wraps_anthropic_rate_limit_error():
assert "docs.anthropic.com/en/api/errors#http-errors" in str(excinfo.value)
assert "rate limited" in str(excinfo.value)
@pytest.fixture
def no_anthropic_credentials(
placeholder_anthropic_api_key, monkeypatch, tmp_path
):
"""An environment where the Anthropic SDK can resolve no credential at all.
Clears every credential environment variable the SDK reads and points the
home directory at an empty one, so a developer who happens to be signed in
on this machine does not make these tests pass or fail by accident. Takes
the placeholder-key fixture as an argument only to run after it, undoing it.
"""
del placeholder_anthropic_api_key
for name in (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_PROFILE",
"ANTHROPIC_CONFIG_DIR",
"ANTHROPIC_IDENTITY_TOKEN",
"ANTHROPIC_IDENTITY_TOKEN_FILE",
"ANTHROPIC_FEDERATION_RULE_ID",
"ANTHROPIC_ORGANIZATION_ID",
):
monkeypatch.delenv(name, raising=False)
for name in ("HOME", "USERPROFILE", "APPDATA"):
monkeypatch.setenv(name, str(tmp_path))
def test_anthropic_client_raises_when_sdk_resolves_no_credential(
no_anthropic_credentials,
):
"""A missing credential names the variable instead of failing mid-request."""
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
with pytest.raises(ValueError) as exc_info:
_ = llm._anthropic_client
message = str(exc_info.value)
assert "ANTHROPIC_API_KEY" in message
assert "export ANTHROPIC_API_KEY=" in message
def test_anthropic_client_created_from_api_key_env_var(
no_anthropic_credentials, monkeypatch
):
monkeypatch.setenv("ANTHROPIC_API_KEY", "placeholder-not-a-real-key")
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
assert llm._anthropic_client.api_key
def test_anthropic_client_created_from_auth_token_env_var(
no_anthropic_credentials, monkeypatch
):
"""The SDK also authenticates from a bearer token; do not reject it."""
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "placeholder-not-a-real-token")
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
assert llm._anthropic_client.auth_token
def test_anthropic_client_created_from_sdk_credential_provider(
no_anthropic_credentials, monkeypatch
):
"""A provider-backed credential counts even with no API key or token.
Workload identity is used here because it needs nothing on disk, but the
same path is what a developer signed in through the Anthropic command line
gets: the SDK hands back a credential provider, not an API key.
"""
monkeypatch.setenv("ANTHROPIC_FEDERATION_RULE_ID", "placeholder-rule")
monkeypatch.setenv("ANTHROPIC_ORGANIZATION_ID", "placeholder-org")
monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN", "placeholder-not-a-real-token")
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
client = llm._anthropic_client
assert client.api_key is None
assert client.auth_token is None
assert client.credentials is not None
def test_anthropic_client_accepts_credential_resolved_without_env_vars(
no_anthropic_credentials,
):
"""Nothing in the environment, yet the SDK resolved a credential anyway.
This is the on-disk profile case: the client is authenticated, so building
it must succeed rather than report a missing key.
"""
resolved_client = mock.Mock(
api_key=None, auth_token=None, credentials=mock.Mock()
)
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
with mock.patch.object(
anthropic_llm, "AsyncAnthropic", return_value=resolved_client
):
assert llm._anthropic_client is resolved_client
def test_claude_vertex_error_explains_direct_anthropic_alternative(monkeypatch):
"""The Vertex error says it resolved to Vertex and what to do instead."""
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
model = Claude(model="claude-3-5-sonnet-v2@20241022")
with pytest.raises(ValueError) as exc_info:
_ = model._anthropic_client
message = str(exc_info.value)
assert "claude-3-5-sonnet-v2@20241022" in message
assert "Vertex AI" in message
assert "GOOGLE_CLOUD_PROJECT" in message
assert "GOOGLE_CLOUD_LOCATION" in message
assert "ANTHROPIC_API_KEY" in message
# The hint must not send a reader at a symbol the models package does not
# export.
assert "AnthropicLlm" not in message
assert "anthropic_llm" not in message