diff --git a/litellm/llms/anthropic/files/handler.py b/litellm/llms/anthropic/files/handler.py index 2c678310032..5e71a992cac 100644 --- a/litellm/llms/anthropic/files/handler.py +++ b/litellm/llms/anthropic/files/handler.py @@ -1,7 +1,7 @@ import asyncio import json import time -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from typing import Final import httpx @@ -43,6 +43,7 @@ class AnthropicFilesHandler: api_key: str | None = None, timeout: float | httpx.Timeout = 600.0, max_retries: int | None = None, + litellm_params: Mapping[str, object] | None = None, ) -> HttpxBinaryResponseContent: """ Async: Retrieve file content from Anthropic. @@ -56,6 +57,7 @@ class AnthropicFilesHandler: api_key: Anthropic API key timeout: Request timeout max_retries: Max retry attempts (unused for now) + litellm_params: Deployment params, so a named credential's federation settings reach the mint Returns: HttpxBinaryResponseContent: Binary content wrapped in compatible response format @@ -74,7 +76,7 @@ class AnthropicFilesHandler: # Get Anthropic API credentials api_base = self.anthropic_model_info.get_api_base(api_base) auth_header: Final = await self.anthropic_model_info.aget_auth_header( - api_key, api_base, allow_workload_identity=True + api_key, api_base, litellm_params=litellm_params, allow_workload_identity=True ) if auth_header is None: @@ -118,6 +120,7 @@ class AnthropicFilesHandler: api_key: str | None = None, timeout: float | httpx.Timeout = 600.0, max_retries: int | None = None, + litellm_params: Mapping[str, object] | None = None, ) -> HttpxBinaryResponseContent | Coroutine[object, object, HttpxBinaryResponseContent]: """ Retrieve file content from Anthropic. @@ -132,6 +135,7 @@ class AnthropicFilesHandler: api_key: Anthropic API key timeout: Request timeout max_retries: Max retry attempts (unused for now) + litellm_params: Deployment params, so a named credential's federation settings reach the mint Returns: HttpxBinaryResponseContent or Coroutine: Binary content wrapped in compatible response format @@ -141,7 +145,9 @@ class AnthropicFilesHandler: file_content_request=file_content_request, api_base=api_base, api_key=api_key, + timeout=timeout, max_retries=max_retries, + litellm_params=litellm_params, ) else: return asyncio.run( @@ -151,6 +157,7 @@ class AnthropicFilesHandler: api_key=api_key, timeout=timeout, max_retries=max_retries, + litellm_params=litellm_params, ) ) diff --git a/litellm/llms/anthropic/wif.py b/litellm/llms/anthropic/wif.py index 2c28aec0413..97dd5014caa 100644 --- a/litellm/llms/anthropic/wif.py +++ b/litellm/llms/anthropic/wif.py @@ -343,17 +343,17 @@ def resolve_anthropic_base(api_base: str | None) -> str: return anthropic_base_without_chat_suffix(api_base or _resolve_default_api_base()) +def _allowlisted_host(entry: str) -> str | None: + return urlsplit(entry if "://" in entry else f"//{entry}").hostname + + def _trusted_exchange_hosts() -> frozenset[str]: """Hostnames a federated exchange may reach: Anthropic's own, plus whatever the operator put in - the environment. Comma separated, case folded, entries given as a URL reduced to their host.""" + the environment. Comma separated, case folded, entries given as a URL or as ``host:port`` + reduced to their host.""" configured: Final = os.getenv(_TRUSTED_EXCHANGE_HOSTS_ENV) or "" - extra: Final = (entry.strip() for entry in configured.split(",") if entry.strip()) - return frozenset( - chain( - (_DEFAULT_TRUSTED_EXCHANGE_HOST,), - ((urlsplit(entry).hostname or entry.split("/")[0]).lower() for entry in extra), - ) - ) + hosts: Final = (_allowlisted_host(entry.strip()) for entry in configured.split(",") if entry.strip()) + return frozenset(chain((_DEFAULT_TRUSTED_EXCHANGE_HOST,), (host for host in hosts if host))) def _raise_if_exchange_host_untrusted(exchange_base: str, model: str) -> None: diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_files_and_batches.py b/tests/test_litellm/llms/anthropic/test_anthropic_files_and_batches.py index 948e30b2c2b..fe7ea23042f 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_files_and_batches.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_files_and_batches.py @@ -508,6 +508,80 @@ class TestAnthropicFilesHandler: assert sync_calls == [] assert thread_ids and thread_ids[0] != threading.get_ident() + @pytest.mark.asyncio + async def test_afile_content_mints_from_the_deployment_litellm_params( + self, handler, mock_anthropic_batch_results_succeeded, monkeypatch + ): + """Regression: a deployment that authenticates through a named credential carries its + federation settings in litellm_params, and afile_content dropped them, so only + process-wide env vars could ever mint on a batch-result download.""" + from litellm.llms.anthropic import common_utils as anthropic_common_utils + from litellm.llms.anthropic.wif import aget_anthropic_wif_token + from litellm.llms.base_llm.auth.token_exchange import JwtBearerTokenExchangeEngine + + for name in ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_BASE", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_FEDERATION_RULE_ID", + "ANTHROPIC_ORGANIZATION_ID", + "ANTHROPIC_IDENTITY_TOKEN", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("CREDENTIAL_IDENTITY_JWT", "credential-inline-jwt") + + minted = "sk-ant-oat01-credential-minted" + + class Poster: + def post(self, url, *, content, headers, timeout): + return httpx.Response( + 200, + json={"access_token": minted, "token_type": "Bearer", "expires_in": 3600}, + ) + + engine = JwtBearerTokenExchangeEngine(poster=Poster()) + + async def async_shim(litellm_params, api_base, model): + return await aget_anthropic_wif_token(litellm_params, api_base, model, engine) + + monkeypatch.setattr(anthropic_common_utils, "aget_anthropic_wif_token", async_shim) + + mock_response = httpx.Response( + status_code=200, + content=mock_anthropic_batch_results_succeeded, + headers={"content-type": "application/json"}, + request=httpx.Request( + method="GET", + url="https://api.anthropic.com/v1/messages/batches/batch_123/results", + ), + ) + + with patch( # test-quality-ok: the proxy wiring under test is what this patches + "litellm.llms.anthropic.files.handler.get_async_httpx_client" + ) as mock_get_client: # test-quality-ok: the proxy wiring under test is what this patches + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + await handler.afile_content( + file_content_request={ + "file_id": "batch_123", + "extra_headers": None, + "extra_body": None, + }, + api_key=None, + litellm_params={ + "anthropic_federation_rule_id": "fdrl_credential", + "anthropic_organization_id": "org-credential", + "anthropic_identity_token": "oidc/env/CREDENTIAL_IDENTITY_JWT", + }, + ) + + sent_headers = mock_client.get.call_args.kwargs["headers"] + + assert sent_headers["authorization"] == f"Bearer {minted}" + class TestAnthropicBatchesConfig: """Test Anthropic Batches Config for batch retrieval transformation""" diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py index 95b89e9cc71..1e1e1e5c5cc 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py @@ -274,6 +274,10 @@ class TestExchangeHostTrust: monkeypatch.setenv("LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS", "gateway.internal") assert self._mint("https://gateway.internal", monkeypatch) == "https://gateway.internal/v1/oauth/token" + def test_a_gateway_listed_with_its_port_is_trusted(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS", "gateway.internal:8443") + assert self._mint("https://gateway.internal:8443", monkeypatch) == "https://gateway.internal:8443/v1/oauth/token" + class TestBaseUrlDerivation: def _mint(self, api_base: str | None, monkeypatch: pytest.MonkeyPatch) -> str: