fix(anthropic): end workload identity federation errors without a period so the router suffix reads cleanly

This commit is contained in:
mateo-berri 2026-09-05 14:00:07 -07:00
parent fd63ff1fa1
commit 7930c8de77
2 changed files with 33 additions and 29 deletions

View file

@ -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)

View file

@ -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: