From ff59884bc1507a5a3a2e6e643aaa5782ee6fa078 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:17:01 -0700 Subject: [PATCH] fix(anthropic_wif): let deployment token refs beat the env identity source kind and surface signing ImportError detail --- litellm/llms/anthropic/wif.py | 22 +++++++-- litellm/llms/base_llm/auth/token_exchange.py | 2 +- .../llms/anthropic/test_anthropic_wif.py | 49 +++++++++++++++++++ .../llms/base_llm/auth/test_token_exchange.py | 13 +++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/litellm/llms/anthropic/wif.py b/litellm/llms/anthropic/wif.py index f490ad4938f..fda41e0bf7d 100644 --- a/litellm/llms/anthropic/wif.py +++ b/litellm/llms/anthropic/wif.py @@ -54,6 +54,8 @@ _DEFAULT_TRUSTED_EXCHANGE_HOST: Final = "api.anthropic.com" _REJECTED_REF_PREFIX: Final = "oidc/env_path/" _IDENTITY_SOURCE_PARAM: Final = "anthropic_identity_source" _IDENTITY_SOURCE_ENV: Final = "ANTHROPIC_IDENTITY_SOURCE" +_IDENTITY_TOKEN_FILE_PARAM: Final = "anthropic_identity_token_file" +_IDENTITY_TOKEN_PARAM: Final = "anthropic_identity_token" # litellm_params key -> InternalIssuerSource/KeycloakSource field name. Every key here must # also be listed in ANTHROPIC_WIF_KWARGS_KEYS (get_litellm_params.py), which is what makes it @@ -136,8 +138,10 @@ def _resolve_identity_source( frozen config, hashes it into the ``oidc//`` cache-key ref (``identity_source_ref``), and closes the source's fetch/mint function over it. An unset-but-invalid config (unknown kind, a missing required field, or a field from the other variant) fails closed here rather - than silently falling back to token_file.""" - source_kind: Final = _config_value(litellm_params, _IDENTITY_SOURCE_PARAM, _IDENTITY_SOURCE_ENV) + than silently falling back to token_file. A deployment whose params carry a legacy token or + token_file ref stays on legacy resolution even when ``ANTHROPIC_IDENTITY_SOURCE`` names a + fleet-wide kind: the env kind only governs deployments that set no identity params of their own.""" + source_kind: Final = _resolve_source_kind(litellm_params) if source_kind is None: legacy_ref: Final = _resolve_assertion_ref(litellm_params) return (legacy_ref, None) if legacy_ref is not None else None @@ -166,6 +170,16 @@ def _resolve_identity_source( ) +def _resolve_source_kind(litellm_params: Mapping[str, object] | None) -> str | None: + param_kind: Final = _param_str(litellm_params, _IDENTITY_SOURCE_PARAM) + if param_kind is not None: + return param_kind + has_param_legacy_ref: Final = any( + _param_str(litellm_params, key) is not None for key in (_IDENTITY_TOKEN_FILE_PARAM, _IDENTITY_TOKEN_PARAM) + ) + return None if has_param_legacy_ref else _env_str(_IDENTITY_SOURCE_ENV) + + def _reject_foreign_variant_fields( litellm_params: Mapping[str, object], foreign_field_map: Mapping[str, str], chosen_kind: str ) -> None: @@ -360,10 +374,10 @@ def _env_str(name: str) -> str | None: def _resolve_assertion_ref(litellm_params: Mapping[str, object] | None) -> str | None: - file_param: Final = _param_str(litellm_params, "anthropic_identity_token_file") + file_param: Final = _param_str(litellm_params, _IDENTITY_TOKEN_FILE_PARAM) if file_param is not None: return f"oidc/file/{file_param}" - inline_param: Final = _param_str(litellm_params, "anthropic_identity_token") + inline_param: Final = _param_str(litellm_params, _IDENTITY_TOKEN_PARAM) if inline_param is not None: return _validated_inline_ref(inline_param) file_env: Final = _env_str("ANTHROPIC_IDENTITY_TOKEN_FILE") diff --git a/litellm/llms/base_llm/auth/token_exchange.py b/litellm/llms/base_llm/auth/token_exchange.py index 96e2e1a62af..03bf77ac312 100644 --- a/litellm/llms/base_llm/auth/token_exchange.py +++ b/litellm/llms/base_llm/auth/token_exchange.py @@ -305,7 +305,7 @@ def _read_assertion(fetch: AssertionSource, ref: str) -> SecretStr | AssertionSo raw: Final = fetch() except OidcPathNotAllowedError: return AssertionSourceError(kind="disallowed_path", source_ref=ref) - except ValueError as e: + except (ValueError, ImportError) as e: return AssertionSourceError(kind="unreadable", source_ref=ref, detail=str(e)[:_REDACTION_CAP]) except Exception: # noqa: BLE001 # injected readers (secret managers) raise arbitrarily; all failures become values return AssertionSourceError(kind="unreadable", source_ref=ref) diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py index db9fc50c09f..485c353d5da 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py @@ -436,6 +436,55 @@ class TestResolutionMatrix: assert params is not None assert params.assertion_ref == "oidc/file//var/run/secrets/env-tok" + def test_param_token_ref_beats_env_identity_source(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_IDENTITY_SOURCE", "internal_issuer") + params = resolve_anthropic_wif_params( + { + "anthropic_federation_rule_id": "fdrl_1", + "anthropic_organization_id": "org-1", + "anthropic_identity_token_file": "/var/run/secrets/dep-tok", + } + ) + assert params is not None + assert params.assertion_ref == "oidc/file//var/run/secrets/dep-tok" + assert params.assertion_source is None + + def test_param_inline_token_beats_env_identity_source(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_IDENTITY_SOURCE", "internal_issuer") + params = resolve_anthropic_wif_params( + { + "anthropic_federation_rule_id": "fdrl_1", + "anthropic_organization_id": "org-1", + "anthropic_identity_token": "oidc/env/OTHER", + } + ) + assert params is not None + assert params.assertion_ref == "oidc/env/OTHER" + assert params.assertion_source is None + + def test_env_identity_source_beats_env_token_refs(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_IDENTITY_SOURCE", "internal_issuer") + monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN_FILE", "/var/run/secrets/env-tok") + with pytest.raises(litellm.AuthenticationError): + resolve_anthropic_wif_params( + {"anthropic_federation_rule_id": "fdrl_1", "anthropic_organization_id": "org-1"} + ) + + def test_env_identity_source_dispatches_param_issuer_fields(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_IDENTITY_SOURCE", "internal_issuer") + params = resolve_anthropic_wif_params( + { + "anthropic_federation_rule_id": "fdrl_1", + "anthropic_organization_id": "org-1", + "anthropic_issuer_url": "https://issuer.internal.example", + "anthropic_issuer_subject": "workload-a", + "anthropic_issuer_signing_key_ref": ISSUER_SIGNING_KEY_REF, + } + ) + assert params is not None + assert params.assertion_ref.startswith("oidc/internal_issuer/") + assert params.assertion_source is not None + def test_empty_workspace_env_coerced_to_none(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("ANTHROPIC_WORKSPACE_ID", "") params = resolve_anthropic_wif_params( diff --git a/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py b/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py index 4cfc090a6eb..129eec88c2b 100644 --- a/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py +++ b/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py @@ -840,6 +840,7 @@ class TestAssertionGuards: [ (OidcPathNotAllowedError("path outside allowed credential directories"), "disallowed_path"), (ValueError("Environment variable ANTHROPIC_IDENTITY_TOKEN not found"), "unreadable"), + (ImportError("needs PyJWT and cryptography: pip install 'litellm[proxy]'"), "unreadable"), (OSError("permission denied"), "unreadable"), ], ) @@ -866,6 +867,18 @@ class TestAssertionGuards: assert isinstance(result, AssertionSourceError) assert result.detail == "Keycloak token endpoint returned invalid_client" + def test_import_error_message_is_captured_as_detail(self): + poster = ScriptedPoster([token_response()]) + + def reader(ref: str) -> str | None: + raise ImportError("the internal_issuer identity source needs PyJWT and cryptography: pip install 'litellm[proxy]'") + + result = make_engine(poster, reader=reader).get_token(make_spec()) + + assert isinstance(result, AssertionSourceError) + assert result.detail is not None + assert "litellm[proxy]" in result.detail + @pytest.mark.parametrize( "raised", [OidcPathNotAllowedError("path outside allowed credential directories"), OSError("permission denied")],