mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(anthropic): end workload identity federation errors without a period so the router suffix reads cleanly
This commit is contained in:
parent
fd63ff1fa1
commit
7930c8de77
2 changed files with 33 additions and 29 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue