fix(anthropic): thread WIF params through files handler and normalize host:port allowlist entries

- AnthropicFilesHandler.afile_content/file_content now accept
  litellm_params and forward them to aget_auth_header. Without this a
  credential-backed federated deployment (no static api_key) has no
  federation fields on the token exchange, so batch-result downloads
  fail with 'Missing Anthropic API Key'.
- LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS entries written as bare
  'host:port' now reduce to just their hostname. urlsplit was parsing
  'gateway.example.com:8443' as a scheme with hostname=None, so the
  fallback stored 'host:port' while the exchange comparison uses the
  bare hostname and the entry never matched.
This commit is contained in:
Cursor Agent 2026-09-05 19:42:34 +00:00
parent 6e44b5b626
commit d3a1256a18
No known key found for this signature in database
2 changed files with 25 additions and 3 deletions

View file

@ -43,6 +43,7 @@ class AnthropicFilesHandler:
api_key: str | None = None,
timeout: float | httpx.Timeout = 600.0,
max_retries: int | None = None,
litellm_params: dict | None = None, # mutable-ok: handed straight to aget_auth_header
) -> HttpxBinaryResponseContent:
"""
Async: Retrieve file content from Anthropic.
@ -56,6 +57,10 @@ class AnthropicFilesHandler:
api_key: Anthropic API key
timeout: Request timeout
max_retries: Max retry attempts (unused for now)
litellm_params: Optional deployment/credential params carrying the
workload-identity federation fields (rule id, org id, identity
token file, etc.). Without these a credential-backed federated
deployment has no static api_key and no way to mint one.
Returns:
HttpxBinaryResponseContent: Binary content wrapped in compatible response format
@ -74,7 +79,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 +123,7 @@ class AnthropicFilesHandler:
api_key: str | None = None,
timeout: float | httpx.Timeout = 600.0,
max_retries: int | None = None,
litellm_params: dict | None = None, # mutable-ok: handed straight to aget_auth_header
) -> HttpxBinaryResponseContent | Coroutine[object, object, HttpxBinaryResponseContent]:
"""
Retrieve file content from Anthropic.
@ -132,6 +138,8 @@ class AnthropicFilesHandler:
api_key: Anthropic API key
timeout: Request timeout
max_retries: Max retry attempts (unused for now)
litellm_params: Optional deployment/credential params carrying the
workload-identity federation fields, forwarded to aget_auth_header.
Returns:
HttpxBinaryResponseContent or Coroutine: Binary content wrapped in compatible response format
@ -142,6 +150,7 @@ class AnthropicFilesHandler:
api_base=api_base,
api_key=api_key,
max_retries=max_retries,
litellm_params=litellm_params,
)
else:
return asyncio.run(
@ -151,6 +160,7 @@ class AnthropicFilesHandler:
api_key=api_key,
timeout=timeout,
max_retries=max_retries,
litellm_params=litellm_params,
)
)

View file

@ -345,17 +345,29 @@ def resolve_anthropic_base(api_base: str | None) -> str:
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, each entry reduced to its bare hostname whether
it was written as a URL, a plain host, or a ``host:port`` (a bare ``host:port`` would otherwise
parse as scheme-only and match nothing, since the compared exchange base carries no port)."""
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),
(_normalize_trusted_host(entry) for entry in extra),
)
)
def _normalize_trusted_host(entry: str) -> str:
"""Reduce one allowlist entry to its lowercased hostname. A missing scheme is added as ``//`` so
``host:port`` is parsed as an authority rather than as a scheme."""
to_parse: Final = entry if "://" in entry else f"//{entry}"
host: Final = urlsplit(to_parse).hostname
if host is not None:
return host.lower()
return entry.split("/", 1)[0].split(":", 1)[0].lower()
def _raise_if_exchange_host_untrusted(exchange_base: str, model: str) -> None:
"""The federated exchange refuses any host the operator has not vouched for, whatever wrote the
deployment's api_base. Exact hostname match, never a substring: ``api.anthropic.com.evil.test``