From 7a53a2bc1d05b08e477481dbeaf50e704fbdfe8e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:46:29 -0700 Subject: [PATCH] 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 --- litellm/llms/anthropic/wif.py | 8 ++-- litellm/proxy/auth/auth_utils.py | 7 +-- litellm/types/router.py | 6 +-- litellm/types/workload_identity.py | 2 +- .../test_get_litellm_params.py | 2 +- .../anthropic/test_anthropic_common_utils.py | 43 ++++--------------- .../llms/anthropic/test_anthropic_wif.py | 28 ++++++------ .../proxy/auth/test_auth_utils.py | 25 ++++++----- .../test_fallback_event_handlers.py | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 ++-- 10 files changed, 50 insertions(+), 81 deletions(-) diff --git a/litellm/llms/anthropic/wif.py b/litellm/llms/anthropic/wif.py index 1116751fee4..b920a6c12fe 100644 --- a/litellm/llms/anthropic/wif.py +++ b/litellm/llms/anthropic/wif.py @@ -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, ) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index accfd6a5f0d..e02fc8ec193 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -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 diff --git a/litellm/types/router.py b/litellm/types/router.py index fb55a5f35b7..57e7cb8d783 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -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 "") ) diff --git a/litellm/types/workload_identity.py b/litellm/types/workload_identity.py index 26362a7e442..264bdc2a7c3 100644 --- a/litellm/types/workload_identity.py +++ b/litellm/types/workload_identity.py @@ -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", diff --git a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py index 0ad3e3afa24..f1c01c90008 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py +++ b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py @@ -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", } diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index c2dfb91591b..8780ee6c6db 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -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"}, diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py index a6769d10976..6fd8c88586a 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py @@ -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(".") diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 87e4bcc8191..61571858314 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -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: diff --git a/tests/test_litellm/router_utils/test_fallback_event_handlers.py b/tests/test_litellm/router_utils/test_fallback_event_handlers.py index bff1be3ff1d..f0ebbb18b85 100644 --- a/tests/test_litellm/router_utils/test_fallback_event_handlers.py +++ b/tests/test_litellm/router_utils/test_fallback_event_handlers.py @@ -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, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d3b583daec4..8b86bc80227 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */