fix(anthropic): let batch-result downloads mint from deployment params and accept host:port allowlist entries

The files handler enabled workload identity on batch-result downloads but never received the
deployment's litellm_params, so a deployment authenticating through a named credential could only
mint from process-wide env vars. It now threads litellm_params through to the auth header the way
the batch retrieve path already does.

LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS entries written as host:port were read by urlsplit as a scheme,
so the allowlist kept the raw entry while the exchange compared bare hostnames and refused the
gateway. Entries are now parsed as network locations whether or not they carry a scheme.
This commit is contained in:
mateo-berri 2026-09-05 13:02:50 -07:00
parent cc5c6c059a
commit b738e1363d
4 changed files with 95 additions and 10 deletions

View file

@ -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,
)
)

View file

@ -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:

View file

@ -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"""

View file

@ -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: