From 7930c8de776e7bc198374da83a8669bfa483c3d6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:00:07 -0700 Subject: [PATCH] fix(anthropic): end workload identity federation errors without a period so the router suffix reads cleanly --- litellm/llms/anthropic/wif.py | 51 +++++++++---------- .../llms/anthropic/test_anthropic_wif.py | 11 ++-- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/litellm/llms/anthropic/wif.py b/litellm/llms/anthropic/wif.py index e9ef47da59c..1116751fee4 100644 --- a/litellm/llms/anthropic/wif.py +++ b/litellm/llms/anthropic/wif.py @@ -80,26 +80,26 @@ _KEYCLOAK_FIELD_MAP: Final[Mapping[str, str]] = MappingProxyType( } ) _DENIAL_HINT: Final = ( - " Anthropic answers every denied exchange with the same 401; the reason (for example" + "Anthropic answers every denied exchange with the same 401; the reason (for example" " workspace_id_required or jti_reused) is only shown in the Claude Console under" - " Settings > Workload identity, in the rule's authentication history." + " Settings > Workload identity, in the rule's authentication history" ) _WORKSPACE_HINT: Final = ( - " If the federation rule is enabled in more than one workspace, set anthropic_workspace_id" - " (or ANTHROPIC_WORKSPACE_ID) to the wrkspc_ id of the workspace to mint tokens for, or to 'default'." + "If the federation rule is enabled in more than one workspace, set anthropic_workspace_id" + " (or ANTHROPIC_WORKSPACE_ID) to the wrkspc_ id of the workspace to mint tokens for, or to 'default'" ) _SERVICE_ACCOUNT_HINT: Final = ( - " Anthropic's reference lists service_account_id as required: set anthropic_service_account_id" - " (or ANTHROPIC_SERVICE_ACCOUNT_ID) to the svac_ id the federation rule targets." + "Anthropic's reference lists service_account_id as required: set anthropic_service_account_id" + " (or ANTHROPIC_SERVICE_ACCOUNT_ID) to the svac_ id the federation rule targets" ) _MISSING_IDS_HINT: Final = ( - " Copy them from the federation rule's detail page under Settings > Workload identity in the" - " Claude Console, or set ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID." + "Copy them from the federation rule's detail page under Settings > Workload identity in the" + " Claude Console, or set ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID" ) _ALLOWLIST_HINT: Final = ( - " Identity token files must sit under an allowed credential directory" + "Identity token files must sit under an allowed credential directory" " (/var/run/secrets or /run/secrets by default);" - " set LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS to extend the allowlist." + " set LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS to extend the allowlist" ) _EMPTY_PARAMS: Final[Mapping[str, object]] = MappingProxyType({}) @@ -181,7 +181,7 @@ def _raise_unknown_source_kind(source_kind: str) -> NoReturn: raise litellm.AuthenticationError( message=( f"{_IDENTITY_SOURCE_PARAM} must be one of " - f"{', '.join(kind.value for kind in AnthropicIdentitySourceKind)}; got {source_kind!r}." + f"{', '.join(kind.value for kind in AnthropicIdentitySourceKind)}; got {source_kind!r}" ), llm_provider="anthropic", model="", @@ -210,7 +210,7 @@ def _raise_if_identity_source_configured( raise litellm.AuthenticationError( message=( f"{_IDENTITY_SOURCE_PARAM} is {source_kind!r}, but {' and '.join(missing)} " - f"{'is' if len(missing) == 1 else 'are'} not set.{_MISSING_IDS_HINT}" + f"{'is' if len(missing) == 1 else 'are'} not set. {_MISSING_IDS_HINT}" ), llm_provider="anthropic", model="", @@ -235,7 +235,7 @@ def _reject_foreign_variant_fields( raise litellm.AuthenticationError( message=( f"{_IDENTITY_SOURCE_PARAM} is {chosen_kind!r}, but {', '.join(sorted(foreign_keys_present))} " - "belongs to a different identity source and cannot be set alongside it." + "belongs to a different identity source and cannot be set alongside it" ), llm_provider="anthropic", model="", @@ -371,7 +371,7 @@ def _raise_if_exchange_host_untrusted(exchange_base: str, model: str) -> None: f"use a private Anthropic-compatible gateway, add its hostname to the " f"{_TRUSTED_EXCHANGE_HOSTS_ENV} environment variable (comma separated); that is a " f"decision to trust it with org-scoped credentials, so it is deliberately server-owned " - f"and cannot be set through the model or credential APIs." + f"and cannot be set through the model or credential APIs" ), llm_provider="anthropic", model=model, @@ -470,32 +470,31 @@ def _raise_anthropic_wif_error( def _denial_hints(workspace_id_set: bool, service_account_id_set: bool) -> str: - return "".join( - ( - _DENIAL_HINT, - "" if workspace_id_set else _WORKSPACE_HINT, - "" if service_account_id_set else _SERVICE_ACCOUNT_HINT, - ) + hints: Final = ( + _DENIAL_HINT, + "" if workspace_id_set else _WORKSPACE_HINT, + "" if service_account_id_set else _SERVICE_ACCOUNT_HINT, ) + return " " + ". ".join(hint for hint in hints if hint) def _error_detail(error: ExchangeError, workspace_id_set: bool, service_account_id_set: bool) -> str: match error: case AssertionSourceError() if error.kind == "disallowed_path": - return f"Could not read the OIDC identity token from {error.source_ref}.{_ALLOWLIST_HINT}" + return f"Could not read the OIDC identity token from {error.source_ref}. {_ALLOWLIST_HINT}" case AssertionSourceError(): - base: Final = f"Could not obtain the OIDC identity token ({error.kind}) from {error.source_ref}." - return f"{base} {error.detail}" if error.detail else base + base: Final = f"Could not obtain the OIDC identity token ({error.kind}) from {error.source_ref}" + return f"{base}. {error.detail}" if error.detail else base case InsecureTokenUrl(): - return f"The token endpoint must use https; refusing to send the identity token to host {error.host!r}." + return f"The token endpoint must use https; refusing to send the identity token to host {error.host!r}" case TokenEndpointError() if error.status_code == 401: hints: Final = _denial_hints(workspace_id_set, service_account_id_set) return f"The token endpoint returned HTTP 401: {error.redacted_body}{hints}" case TokenEndpointError(): return f"The token endpoint returned HTTP {error.status_code}: {error.redacted_body}" case TokenTransportError(): - return f"Could not reach the token endpoint: {error.detail}." + return f"Could not reach the token endpoint: {error.detail}" case MalformedTokenResponse(): - return f"The token endpoint returned an unusable response: {error.detail}." + return f"The token endpoint returned an unusable response: {error.detail}" case _: assert_never(error) diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py index 515d0c5ede6..a6769d10976 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py @@ -254,6 +254,7 @@ class TestExchangeHostTrust: assert "LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS" in str(exc_info.value), ( "an operator running a private gateway has to be told how to allow it" ) + assert not exc_info.value.message.endswith(".") def test_a_lookalike_host_does_not_pass_on_a_substring(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS", raising=False) @@ -674,6 +675,7 @@ class TestErrorMappingExhaustive: assert exc_info.value.llm_provider == "anthropic" assert exc_info.value.model == "claude-sonnet-4-5" + assert not exc_info.value.message.endswith(".") def test_assertion_source_error_detail_is_rendered_when_present(self): with pytest.raises(litellm.AuthenticationError) as exc_info: @@ -687,8 +689,8 @@ class TestErrorMappingExhaustive: assert "invalid_client" in exc_info.value.message def test_assertion_source_error_without_detail_is_unchanged(self): - """Regression floor: the token_file/env path never populates detail, so its message must stay - byte-identical to before the field existed.""" + """Regression floor: the token_file/env path never populates detail, so nothing follows the + source ref and the message ends without a period for the router's suffix.""" with pytest.raises(litellm.AuthenticationError) as exc_info: _raise_anthropic_wif_error( AssertionSourceError(kind="unreadable", source_ref="oidc/env/ANTHROPIC_IDENTITY_TOKEN"), @@ -699,7 +701,7 @@ class TestErrorMappingExhaustive: assert exc_info.value.message == ( "litellm.AuthenticationError: Anthropic workload identity federation failed. Could not obtain " - "the OIDC identity token (unreadable) from oidc/env/ANTHROPIC_IDENTITY_TOKEN." + "the OIDC identity token (unreadable) from oidc/env/ANTHROPIC_IDENTITY_TOKEN" ) def test_endpoint_error_raised_through_facade(self, monkeypatch: pytest.MonkeyPatch): @@ -783,6 +785,7 @@ class TestDenialHints: assert "ANTHROPIC_WORKSPACE_ID" in message assert "anthropic_service_account_id" in message assert "ANTHROPIC_SERVICE_ACCOUNT_ID" in message + assert not message.endswith(".") def test_no_workspace_hint_when_workspace_set(self, monkeypatch: pytest.MonkeyPatch): message = self._raise({**self.BASE_PARAMS, "anthropic_workspace_id": "wrkspc_1"}, 401, monkeypatch) @@ -804,6 +807,7 @@ class TestDenialHints: assert "ANTHROPIC_WORKSPACE_ID" not in message assert "ANTHROPIC_SERVICE_ACCOUNT_ID" not in message assert ".." not in message + assert not message.endswith(".") class TestFileRereadOnRefresh: @@ -1132,6 +1136,7 @@ class TestMissingIdsFailClosedWhenIdentitySourceConfigured: assert "anthropic_federation_rule_id and anthropic_organization_id are not set" in message assert "Settings > Workload identity" in message assert "ANTHROPIC_FEDERATION_RULE_ID" in message + assert not message.endswith(".") def test_only_rule_id_missing_names_only_the_rule(self): with pytest.raises(litellm.AuthenticationError) as exc_info: