fix(anthropic): rename the federation workspace param so Bedrock's anthropic_workspace_id keeps working

The Bedrock Claude Platform route already reads anthropic_workspace_id from
optional_params, so banning that spelling as a server-owned federation
parameter broke a pre-existing client capability. The federation field is now
anthropic_federation_workspace_id (env ANTHROPIC_FEDERATION_WORKSPACE_ID),
which restores the base branch's behavior for Bedrock callers, drops the
Bedrock-specific hint from the refusal message, and deletes the unconditional
ban constant that no longer had a reader
This commit is contained in:
mateo-berri 2026-09-05 16:46:29 -07:00
parent e0ee4b6016
commit 7a53a2bc1d
10 changed files with 50 additions and 81 deletions

View file

@ -85,8 +85,8 @@ _DENIAL_HINT: Final = (
" 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_federation_workspace_id"
" (or ANTHROPIC_FEDERATION_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"
@ -137,7 +137,9 @@ def resolve_anthropic_wif_params(litellm_params: Mapping[str, object] | None) ->
service_account_id=_config_value(
litellm_params, "anthropic_service_account_id", "ANTHROPIC_SERVICE_ACCOUNT_ID"
),
workspace_id=_config_value(litellm_params, "anthropic_workspace_id", "ANTHROPIC_WORKSPACE_ID"),
workspace_id=_config_value(
litellm_params, "anthropic_federation_workspace_id", "ANTHROPIC_FEDERATION_WORKSPACE_ID"
),
assertion_ref=assertion_ref,
assertion_source=assertion_source,
)

View file

@ -34,7 +34,7 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
)
from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS
from litellm.types.router import reject_server_owned_wif_params as _reject_server_owned_wif_params
from litellm.types.utils import CustomPricingLiteLLMParams, server_owned_wif_litellm_params
from litellm.types.utils import CustomPricingLiteLLMParams
def is_invalid_virtual_key_error(exception: BaseException | None) -> bool:
@ -227,11 +227,6 @@ def _allow_model_level_clientside_configurable_parameters(
# ``extra_body.aws_web_identity_token``) without re-validating, so the
# banned-key check has to descend into it the same way it descends into
# ``litellm_embedding_config``.
_SERVER_OWNED_WIF_UNCONDITIONAL_BANNED: Final[tuple[str, ...]] = server_owned_wif_litellm_params
# The Bedrock Claude Platform route reads a workspace from workspace_id or aws_workspace_id as
# well, and neither is a federation parameter, so say so rather than leaving that caller stuck.
# Re-exported from litellm.types.router, where it lives so the router can call it on a
# post-authentication merge without core importing from the proxy package.
reject_server_owned_wif_params = _reject_server_owned_wif_params

View file

@ -308,7 +308,7 @@ class CredentialLiteLLMParams(BaseModel):
anthropic_federation_rule_id: str | None = None
anthropic_organization_id: str | None = None
anthropic_service_account_id: str | None = None
anthropic_workspace_id: str | None = None
anthropic_federation_workspace_id: str | None = None
anthropic_identity_token_file: str | None = None
anthropic_identity_token: str | None = None
anthropic_identity_source: str | None = None
@ -1140,9 +1140,6 @@ class AdaptiveRouterPreferences(BaseModel):
strengths: list[RequestType] = Field(default_factory=list)
_BEDROCK_WORKSPACE_HINT: Final = " On the Bedrock Claude Platform route, pass workspace_id or aws_workspace_id instead."
def reject_server_owned_wif_params(body: Mapping[str, object]) -> None:
"""Raise ``ValueError`` if a mapping that did not come from deployment config carries a
server-owned workload identity federation field.
@ -1156,5 +1153,4 @@ def reject_server_owned_wif_params(body: Mapping[str, object]) -> None:
raise ValueError(
f"Rejected Request: {param} is a server-owned workload identity federation parameter "
"and cannot be set in a request body; configure it on the deployment instead."
+ (_BEDROCK_WORKSPACE_HINT if param == "anthropic_workspace_id" else "")
)

View file

@ -16,7 +16,7 @@ ANTHROPIC_WIF_KWARGS_KEYS: Final = frozenset(
"anthropic_federation_rule_id",
"anthropic_organization_id",
"anthropic_service_account_id",
"anthropic_workspace_id",
"anthropic_federation_workspace_id",
"anthropic_identity_token_file",
"anthropic_identity_token",
"anthropic_identity_source",

View file

@ -256,7 +256,7 @@ class TestAnthropicWifKeys:
"anthropic_federation_rule_id": "fdrl_1",
"anthropic_organization_id": "org-1",
"anthropic_service_account_id": "svcacct_1",
"anthropic_workspace_id": "wrkspc_1",
"anthropic_federation_workspace_id": "wrkspc_1",
"anthropic_identity_token_file": "/var/run/secrets/tok",
"anthropic_identity_token": "oidc/env/TOK",
}

View file

@ -2204,7 +2204,7 @@ ANTHROPIC_ENV_VARS = (
"ANTHROPIC_FEDERATION_RULE_ID",
"ANTHROPIC_ORGANIZATION_ID",
"ANTHROPIC_SERVICE_ACCOUNT_ID",
"ANTHROPIC_WORKSPACE_ID",
"ANTHROPIC_FEDERATION_WORKSPACE_ID",
"ANTHROPIC_IDENTITY_TOKEN_FILE",
"ANTHROPIC_IDENTITY_TOKEN",
"LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS",
@ -3025,7 +3025,7 @@ class TestWifRespxEndToEnd:
"anthropic_federation_rule_id",
"anthropic_organization_id",
"anthropic_service_account_id",
"anthropic_workspace_id",
"anthropic_federation_workspace_id",
"anthropic_identity_token_file",
"anthropic_identity_token",
)
@ -3055,7 +3055,7 @@ class TestWifRespxEndToEnd:
anthropic_federation_rule_id="fdrl_e2e",
anthropic_organization_id="org-e2e",
anthropic_service_account_id="svcacct_e2e",
anthropic_workspace_id="wrkspc_e2e",
anthropic_federation_workspace_id="wrkspc_e2e",
anthropic_identity_token_file=str(token_file),
anthropic_identity_token="oidc/env/UNUSED_FALLBACK",
)
@ -3503,20 +3503,6 @@ class TestWifExchangeTransportHardening:
assert handler.client.follow_redirects is False
class TestWifParamsAreNotClientSettable:
def test_every_minting_param_is_server_owned(self):
"""Each of these selects which server-side secret is read, or the scope it is minted for.
The workspace id was once carved out here as inert; it is not. It is the scope of the
minted org credential, and the router merges request kwargs over deployment params, so a
caller who set it picked the scope instead of the administrator."""
from litellm.proxy.auth.auth_utils import _SERVER_OWNED_WIF_UNCONDITIONAL_BANNED
from litellm.types.utils import anthropic_wif_litellm_params, openai_wif_litellm_params
assert set(_SERVER_OWNED_WIF_UNCONDITIONAL_BANNED) == set(anthropic_wif_litellm_params) | set(
openai_wif_litellm_params
)
class TestWifServerOwnedParamsAreUnconditional:
"""The minting fields choose which server-side secret is read and, with api_base, where it goes,
so no client-side credential opt-in may re-enable them."""
@ -3580,32 +3566,19 @@ class TestWifServerOwnedParamsAreUnconditional:
with pytest.raises(Exception, match="server-owned workload identity federation parameter"):
is_request_body_safe(
request_body={"model": "claude-sonnet-5", "anthropic_workspace_id": "wrkspc_abc"},
general_settings={},
llm_router=None,
model="claude-sonnet-5",
)
def test_refusal_points_bedrock_callers_at_their_own_spelling(self):
"""Banning this spelling must not read as "no workspace selection anywhere": the Bedrock
Claude Platform route takes workspace_id/aws_workspace_id, neither of which is a
federation parameter, so the error names them."""
from litellm.proxy.auth.auth_utils import is_request_body_safe
with pytest.raises(Exception, match="workspace_id or aws_workspace_id"):
is_request_body_safe(
request_body={"model": "claude-sonnet-5", "anthropic_workspace_id": "wrkspc_abc"},
request_body={"model": "claude-sonnet-5", "anthropic_federation_workspace_id": "wrkspc_abc"},
general_settings={},
llm_router=None,
model="claude-sonnet-5",
)
def test_bedrock_workspace_spellings_are_untouched(self):
"""The Bedrock route's own spellings stay settable, which is what keeps this ban from
removing a pre-existing capability."""
"""The Bedrock Claude Platform route reads its per-request workspace from these three
spellings, anthropic_workspace_id included, none of which is a federation parameter; the
federation field carries its own name so this pre-existing capability survives the ban."""
from litellm.proxy.auth.auth_utils import is_request_body_safe
for spelling in ("workspace_id", "aws_workspace_id"):
for spelling in ("workspace_id", "aws_workspace_id", "anthropic_workspace_id"):
assert (
is_request_body_safe(
request_body={"model": "claude-sonnet-5", spelling: "wrkspc_abc"},

View file

@ -39,7 +39,7 @@ WIF_ENV_VARS: Final = (
"ANTHROPIC_FEDERATION_RULE_ID",
"ANTHROPIC_ORGANIZATION_ID",
"ANTHROPIC_SERVICE_ACCOUNT_ID",
"ANTHROPIC_WORKSPACE_ID",
"ANTHROPIC_FEDERATION_WORKSPACE_ID",
"ANTHROPIC_IDENTITY_TOKEN_FILE",
"ANTHROPIC_IDENTITY_TOKEN",
"ANTHROPIC_IDENTITY_SOURCE",
@ -169,7 +169,7 @@ class TestWireProtocolExact:
"anthropic_federation_rule_id": "fdrl_abc123",
"anthropic_organization_id": "org-uuid-1",
"anthropic_service_account_id": "svcacct_1",
"anthropic_workspace_id": "wrkspc_1",
"anthropic_federation_workspace_id": "wrkspc_1",
"anthropic_identity_token_file": str(token_file),
},
"https://api.anthropic.com",
@ -385,7 +385,7 @@ class TestResolutionMatrix:
monkeypatch.setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_env")
monkeypatch.setenv("ANTHROPIC_ORGANIZATION_ID", "org-env")
monkeypatch.setenv("ANTHROPIC_SERVICE_ACCOUNT_ID", "svc-env")
monkeypatch.setenv("ANTHROPIC_WORKSPACE_ID", "wrkspc_env")
monkeypatch.setenv("ANTHROPIC_FEDERATION_WORKSPACE_ID", "wrkspc_env")
monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN_FILE", "/var/run/secrets/env-token")
params = resolve_anthropic_wif_params(
@ -393,7 +393,7 @@ class TestResolutionMatrix:
"anthropic_federation_rule_id": "fdrl_param",
"anthropic_organization_id": "org-param",
"anthropic_service_account_id": "svc-param",
"anthropic_workspace_id": "wrkspc_param",
"anthropic_federation_workspace_id": "wrkspc_param",
"anthropic_identity_token_file": "/var/run/secrets/param-token",
}
)
@ -501,7 +501,7 @@ class TestResolutionMatrix:
assert params.assertion_source is not None
def test_empty_workspace_env_coerced_to_none(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("ANTHROPIC_WORKSPACE_ID", "")
monkeypatch.setenv("ANTHROPIC_FEDERATION_WORKSPACE_ID", "")
params = resolve_anthropic_wif_params(
{
"anthropic_federation_rule_id": "fdrl_1",
@ -733,7 +733,7 @@ class TestErrorMappingExhaustive:
{
"anthropic_federation_rule_id": "fdrl_1",
"anthropic_organization_id": "org-1",
"anthropic_workspace_id": "wrkspc_1",
"anthropic_federation_workspace_id": "wrkspc_1",
},
500,
{"error": "server_error."},
@ -776,35 +776,35 @@ class TestDenialHints:
def test_500_carries_no_denial_hints(self, monkeypatch: pytest.MonkeyPatch):
message = self._raise(self.BASE_PARAMS, 500, monkeypatch)
assert "authentication history" not in message
assert "ANTHROPIC_WORKSPACE_ID" not in message
assert "ANTHROPIC_FEDERATION_WORKSPACE_ID" not in message
assert "ANTHROPIC_SERVICE_ACCOUNT_ID" not in message
def test_hints_name_both_ids_when_both_unset(self, monkeypatch: pytest.MonkeyPatch):
message = self._raise(self.BASE_PARAMS, 401, monkeypatch)
assert "anthropic_workspace_id" in message
assert "ANTHROPIC_WORKSPACE_ID" in message
assert "anthropic_federation_workspace_id" in message
assert "ANTHROPIC_FEDERATION_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)
assert "ANTHROPIC_WORKSPACE_ID" not in message
message = self._raise({**self.BASE_PARAMS, "anthropic_federation_workspace_id": "wrkspc_1"}, 401, monkeypatch)
assert "ANTHROPIC_FEDERATION_WORKSPACE_ID" not in message
assert "ANTHROPIC_SERVICE_ACCOUNT_ID" in message
def test_no_service_account_hint_when_service_account_set(self, monkeypatch: pytest.MonkeyPatch):
message = self._raise({**self.BASE_PARAMS, "anthropic_service_account_id": "svac_1"}, 401, monkeypatch)
assert "ANTHROPIC_SERVICE_ACCOUNT_ID" not in message
assert "ANTHROPIC_WORKSPACE_ID" in message
assert "ANTHROPIC_FEDERATION_WORKSPACE_ID" in message
def test_only_console_pointer_when_both_set(self, monkeypatch: pytest.MonkeyPatch):
message = self._raise(
{**self.BASE_PARAMS, "anthropic_workspace_id": "wrkspc_1", "anthropic_service_account_id": "svac_1"},
{**self.BASE_PARAMS, "anthropic_federation_workspace_id": "wrkspc_1", "anthropic_service_account_id": "svac_1"},
401,
monkeypatch,
)
assert "authentication history" in message
assert "ANTHROPIC_WORKSPACE_ID" not in message
assert "ANTHROPIC_FEDERATION_WORKSPACE_ID" not in message
assert "ANTHROPIC_SERVICE_ACCOUNT_ID" not in message
assert ".." not in message
assert not message.endswith(".")

View file

@ -27,19 +27,22 @@ from litellm.proxy.auth.auth_utils import (
get_request_route_template,
is_request_body_safe,
)
from litellm.types.workload_identity import ANTHROPIC_WIF_KWARGS_KEYS, OPENAI_WIF_KWARGS_KEYS
def test_every_server_owned_wif_kwarg_key_is_request_banned():
"""server_owned_wif_litellm_params (types/utils.py) is derived from ANTHROPIC_WIF_KWARGS_KEYS
and OPENAI_WIF_KWARGS_KEYS (types/workload_identity.py) precisely so a new WIF field can never be
added to the kwargs funnel
without automatically joining the request-body ban list; this guards that invariant itself,
independent of today's field count, so it fails if the derivation is ever reverted to a
hand-typed list that drifts."""
from litellm.proxy.auth.auth_utils import _SERVER_OWNED_WIF_UNCONDITIONAL_BANNED
from litellm.types.workload_identity import ANTHROPIC_WIF_KWARGS_KEYS, OPENAI_WIF_KWARGS_KEYS
assert ANTHROPIC_WIF_KWARGS_KEYS | OPENAI_WIF_KWARGS_KEYS == set(_SERVER_OWNED_WIF_UNCONDITIONAL_BANNED)
@pytest.mark.parametrize("param", sorted(ANTHROPIC_WIF_KWARGS_KEYS | OPENAI_WIF_KWARGS_KEYS))
def test_every_wif_kwarg_key_is_refused_from_a_request_body(param: str):
"""Every key the kwargs funnel carries into litellm_params selects a server-side secret or the
scope a token is minted for, so each one must be refused from a request body even with the
proxy-wide client-credential opt-in; a key added to the funnel without joining the ban shows up
here as a body the proxy accepted."""
with pytest.raises(ValueError, match="server-owned workload identity federation parameter"):
is_request_body_safe(
request_body={"model": "claude-sonnet-5", param: "attacker-chosen"},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="claude-sonnet-5",
)
class TestCustomAuthCommonChecksWarning:

View file

@ -1081,7 +1081,7 @@ async def test_a_stored_fallback_target_cannot_carry_a_federation_field():
with pytest.raises(ValueError, match="server-owned workload identity federation parameter"):
await run_async_fallback(
litellm_router=FakeRouter(),
fallback_model_group=[{"model": "anthropic-backup", "anthropic_workspace_id": "wrkspc_other"}],
fallback_model_group=[{"model": "anthropic-backup", "anthropic_federation_workspace_id": "wrkspc_other"}],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,

View file

@ -29308,6 +29308,8 @@ export interface components {
anthropic_disable_workload_identity_federation?: boolean | null;
/** Anthropic Federation Rule Id */
anthropic_federation_rule_id?: string | null;
/** Anthropic Federation Workspace Id */
anthropic_federation_workspace_id?: string | null;
/** Anthropic Identity Source */
anthropic_identity_source?: string | null;
/** Anthropic Identity Token */
@ -29338,8 +29340,6 @@ export interface components {
anthropic_organization_id?: string | null;
/** Anthropic Service Account Id */
anthropic_service_account_id?: string | null;
/** Anthropic Workspace Id */
anthropic_workspace_id?: string | null;
/** Api Base */
api_base?: string | null;
/** Api Key */
@ -39484,6 +39484,8 @@ export interface components {
anthropic_disable_workload_identity_federation?: boolean | null;
/** Anthropic Federation Rule Id */
anthropic_federation_rule_id?: string | null;
/** Anthropic Federation Workspace Id */
anthropic_federation_workspace_id?: string | null;
/** Anthropic Identity Source */
anthropic_identity_source?: string | null;
/** Anthropic Identity Token */
@ -39514,8 +39516,6 @@ export interface components {
anthropic_organization_id?: string | null;
/** Anthropic Service Account Id */
anthropic_service_account_id?: string | null;
/** Anthropic Workspace Id */
anthropic_workspace_id?: string | null;
/** Api Base */
api_base?: string | null;
/** Api Key */