Skip to content

feat(anthropic): workload identity federation and pluggable identity sources - #39935

Open
mateo-berri wants to merge 24 commits into
mainfrom
litellm_anthropic_wif_backend
Open

mateo-berri wants to merge 24 commits into
mainfrom
litellm_anthropic_wif_backend

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Backend half of #38818 (itself the internal copy of the fork PR #38013 by @derhornspieler), rebuilt as one commit on top of litellm_internal_staging. Everything under litellm/, tests/ and terraform/ from that PR is here; the dashboard (ui/), the credential form variants and the POST /provider/models/discover endpoint are left out and follow in a separate PR, so this PR ships the config.yaml, env var and POST /credentials setup paths only

TLDR

Problem this solves:

  • Anthropic-direct auth requires a long-lived static sk-ant key
  • Anthropic shipped Workload Identity Federation, but LiteLLM had no support
  • Vertex and Azure already federate here, Anthropic could not

How it solves it:

  • Shared RFC 7523 JWT-bearer token exchange engine, Anthropic is its first consumer
  • Short-lived sk-ant-oat01 tokens minted from an OIDC assertion
  • Four ways to obtain that assertion: token file, env token, LiteLLM-signed issuer, Keycloak
  • Set up through config.yaml, env vars, or a named credential; no dashboard changes
  • GET /credentials/{name}/jwks exports the public key set of a LiteLLM-signed credential for the Console
  • Every Anthropic surface uses it: chat, /v1/messages, files, batches, skills, passthrough, token counting
  • Federation config is server-owned: refused in a request body, pinned to api.anthropic.com unless an operator allowlists a gateway, and only a proxy admin can put a deployment into federation or change its credentials
  • /health never displays the federation token, the secret references, or the token file paths
  • The team admin gate decrypts deployments stored in the database, so it covers those too
  • Refusals on the credential endpoints keep the proxy's usual error shape
  • OpenAI federation from feat(proxy): configure OpenAI workload identity federation from the Add Model and LLM Credentials forms #39613 (LIT-6869) rides along on the backend side: the identity trio is read per deployment or credential before the env vars, and chat, streaming, Responses and embeddings use the exchanged token; the trio is server-owned the same way

User Flow

Before: a platform team with a no-static-secrets policy cannot point LiteLLM at an Anthropic federation rule

  1. They declare model: anthropic/claude-haiku-4-5 in config.yaml with no api_key, and set the federation ids on the proxy's environment
  2. They send POST http://localhost:4000/v1/chat/completions with {"model": "claude-haiku-4-5-wif", "messages": [...]}
  3. The proxy answers 401 Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params
  4. Because there is no federation setting to guard, any team admin who can manage a team-scoped deployment can already set every field the deployment does have, and a caller can put any Anthropic field they like in a request body

After: the same team configures the provider once in config.yaml and every deployment behind it authenticates without a static key

  1. In the Claude Console they create a federation issuer, a service account, and a rule, which yields an fdrl_..., an org id, and an svac_...
  2. They add a credential_list entry with custom_llm_provider: anthropic and the identity source's fields, secrets as os.environ/... references, plus the three Console ids, and point deployments at it through litellm_credential_name (or set ANTHROPIC_IDENTITY_SOURCE and the ANTHROPIC_* ids in the environment with no credential at all)
  3. For the LiteLLM-signed issuer they send GET http://localhost:4000/credentials/anthropic-wif-issuer/jwks with the admin key and get 200 with the public key set, which they paste into the Console's issuer definition
  4. They send the same POST http://localhost:4000/v1/chat/completions and get 200 with the model's reply, minted from a short-lived token rather than a static key; the same holds for POST /v1/messages and POST /v1/responses
  5. If the credential has the issuer fields but not the Console ids yet, and the proxy environment carries no ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, the same POST answers 401 naming anthropic_organization_id and anthropic_federation_rule_id instead of silently trying a static key; with either of those set the static key answers instead, since it outranks federation
  6. Anyone, team admin or proxy admin alike, who puts a federation field straight into a POST http://localhost:4000/v1/chat/completions or POST http://localhost:4000/model/new body is refused with 401 naming the field, because these fields choose which server secret is read and where it is sent; a team admin cannot reach POST http://localhost:4000/credentials at all (proxy admin only)

Relevant issues

Fixes #28607

Docs: BerriAI/litellm-docs#1000 (written against the dashboard flow, needs trimming to the config flow before this merges)

Linear ticket

Resolves LIT-6107

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Config-only rig, no dashboard. Both proxies boot from the same wif_config.yaml (litellm/proxy/dev_config.yaml plus the block below) with 2 uvicorn workers each, in their own worktree and venv: Before at the merge base on port 33608, After at this branch's tip on port 51022. anthropic-wif-issuer carries placeholder Console ids (no federation rule exists in this org, so the exchange gets exactly as far as Anthropic's token endpoint), anthropic-wif-noids carries none. The third deployment points at a local nginx on 127.0.0.1:53602 that forwards to api.anthropic.com, standing in for a customer's egress gateway, and the exchange host is allowlisted as host:port. No ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN in either environment; ISSUER_SIGNING_KEY_PEM holds a P-256 private key

The last three cases need the database-backed team flow, so they run on a second proxy pair booted the same way (2 workers each, Before on port 16670, After on port 16671) from rig_config.yaml, which adds the wif-inline-token-cfg deployment below, sets litellm_key_header_name: X-Team-Key, and runs with STORE_MODEL_IN_DB=True against a Postgres per side. On each side the team admin key comes from POST /team/new {"team_alias":"rig-team"}, then POST /team/member_add with {"team_id":"<team>","member":{"user_id":"rig-team-admin","role":"admin"}}, then POST /key/generate {"user_id":"rig-team-admin","team_id":"<team>","key_alias":"rig-team-admin"}; $TEAM_ADMIN_KEY below is that key. The blocked case repeats those three calls for a second team so the aliases stay unique, and $TEAM2_ADMIN_KEY is that team's admin key

credential_list:
  - credential_name: anthropic-wif-issuer
    credential_info: {custom_llm_provider: anthropic}
    credential_values:
      anthropic_identity_source: internal_issuer
      anthropic_issuer_url: https://litellm.local/qa
      anthropic_issuer_subject: litellm-wif-demo
      anthropic_issuer_audience: https://api.anthropic.com
      anthropic_issuer_signing_key_ref: os.environ/ISSUER_SIGNING_KEY_PEM
      anthropic_organization_id: 00000000-0000-4000-8000-000000000000
      anthropic_federation_rule_id: fdrl_qa_fake
      anthropic_service_account_id: svac_qa_fake
  - credential_name: anthropic-wif-noids
    credential_info: {custom_llm_provider: anthropic}
    credential_values:
      anthropic_identity_source: internal_issuer
      anthropic_issuer_url: https://litellm.local/qa
      anthropic_issuer_subject: litellm-wif-demo
      anthropic_issuer_audience: https://api.anthropic.com
      anthropic_issuer_signing_key_ref: os.environ/ISSUER_SIGNING_KEY_PEM
model_list:
  - model_name: claude-haiku-4-5-wif
    litellm_params: {model: anthropic/claude-haiku-4-5, litellm_credential_name: anthropic-wif-issuer}
  - model_name: claude-haiku-4-5-wif-noids
    litellm_params: {model: anthropic/claude-haiku-4-5, litellm_credential_name: anthropic-wif-noids}
  - model_name: claude-haiku-4-5-wif-gateway
    litellm_params: {model: anthropic/claude-haiku-4-5, litellm_credential_name: anthropic-wif-issuer, api_base: http://localhost:53602}
server {
  listen 127.0.0.1:53602;
  location / {
    proxy_pass https://api.anthropic.com;
    proxy_set_header Host api.anthropic.com;
    proxy_ssl_server_name on;
    proxy_ssl_name api.anthropic.com;
    proxy_http_version 1.1;
  }
}
nginx -c "$PWD/nginx.conf"
export LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS=localhost:53602
# Before, merge base 02522a5441 checked out in its own worktree
python litellm/proxy/proxy_cli.py --config wif_config.yaml --detailed_debug --use_v2_migration_resolver --num_workers 2 --port 33608
# After, this branch at a7e3b4c114
python litellm/proxy/proxy_cli.py --config wif_config.yaml --detailed_debug --use_v2_migration_resolver --num_workers 2 --port 51022
# Second pair for the last three cases, same flags, STORE_MODEL_IN_DB=True and a Postgres per side
python litellm/proxy/proxy_cli.py --config rig_config.yaml --detailed_debug --use_v2_migration_resolver --num_workers 2 --port 16670
python litellm/proxy/proxy_cli.py --config rig_config.yaml --detailed_debug --use_v2_migration_resolver --num_workers 2 --port 16671
# rig_config.yaml, the deployment the /health case reads
- model_name: wif-inline-token-cfg
  litellm_params:
    model: anthropic/claude-haiku-4-5
    anthropic_identity_token: eyJfake.fake.fake
    anthropic_organization_id: 00000000-0000-4000-8000-000000000000
    anthropic_federation_rule_id: fdrl_qa_fake
    anthropic_service_account_id: svac_qa_fake

Before (02522a5)

JWKS export for a LiteLLM-signed credential

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/credentials/anthropic-wif-issuer/jwks -H "Authorization: Bearer sk-1234"
  2. Observed

    {"detail":"Method Not Allowed"}
    HTTP 405
    

/v1/chat/completions on a federated deployment

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars. Received Model Group=claude-haiku-4-5-wif\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

/v1/messages on a federated deployment

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/v1/messages -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","max_tokens":32,"messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"type":"error","error":{"type":"authentication_error","message":"litellm.AuthenticationError: Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars. Received Model Group=claude-haiku-4-5-wif\nAvailable Model Group Fallbacks=None"}}
    HTTP 401
    

/v1/responses on a federated deployment

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/v1/responses -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","input":"Say hi in three words."}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars. Received Model Group=claude-haiku-4-5-wif\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

/v1/chat/completions through an egress gateway allowlisted as host:port

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif-gateway","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars. Received Model Group=claude-haiku-4-5-wif-gateway\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

Federated credential missing the Console ids

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif-noids","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars. Received Model Group=claude-haiku-4-5-wif-noids\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

Federation field typed into a chat body

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","anthropic_federation_rule_id":"fdrl_attacker","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars. Received Model Group=claude-haiku-4-5-wif\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

Federation fields typed into POST /model/new, next to the sanctioned credential

  1. Run (a federation field straight in the deployment's litellm_params)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/model/new -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model_name":"wif-inline-probe-before-a7e3b","litellm_params":{"model":"anthropic/claude-haiku-4-5","anthropic_federation_rule_id":"fdrl_attacker"}}'
  2. Observed

    {"model_id":"ab93c181-6d2b-4944-84b2-e5e1a9ed43c3","model_name":"wif-inline-probe-before-a7e3b","litellm_params":{"model":"zNmb-k1N7vy4uvITGH-ozBhJXvxTXgCCqrjudN6QaO7PSrS3baPygbFX-XI3fw-GFtZ4zoxwRIZxq23bkh5ZJDai","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"anthropic_federation_rule_id":"mdN7dToIntoWswNJQ6rl9tXqG6-Xt4DnJnOImMkjXXC43BTQqAKm5N6sFgyOodB163DhiSg=","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"ab93c181-6d2b-4944-84b2-e5e1a9ed43c3","db_model":false},"blocked":false,"created_at":"2026-09-06T10:26:11.188000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:26:11.188000Z","updated_by":"default_user_id"}
    HTTP 200
    
  3. Run (the same proxy admin attaches the stored federated credential instead)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:33608/model/new -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model_name":"wif-cred-probe-before-a7e3b","litellm_params":{"model":"anthropic/claude-haiku-4-5","litellm_credential_name":"anthropic-wif-issuer"}}'
  4. Observed

    {"model_id":"f3c646ec-4297-4029-bdfb-26a98885c243","model_name":"wif-cred-probe-before-a7e3b","litellm_params":{"model":"FDpz_NQP79AMaxnfif2m8iJalvGGXU0iYsLVmlVEfxPuJQknzLK_WxzdM50yY2XxuXmC4zuPfrd5I3JW4s1P7cNo","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"litellm_credential_name":"cSKrOOuOhxhLlsYKvxh_OWgW4EpO_AJAbwfEQ13RTqG2HwEhGtc0wK-RRspiWRkkEerQryGPcB5OSjVZ","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"f3c646ec-4297-4029-bdfb-26a98885c243","db_model":false},"blocked":false,"created_at":"2026-09-06T10:26:12.101000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:26:12.101000Z","updated_by":"default_user_id"}
    HTTP 200
    
  5. Run (which of the two the proxy now serves)

    curl -s http://localhost:33608/model/info -H "Authorization: Bearer sk-1234" | grep -oE 'wif-(inline|cred)-probe-before-a7e3b' | sort -u
  6. Observed

    wif-cred-probe-before-a7e3b
    wif-inline-probe-before-a7e3b
    

GET /health with a team admin key on a deployment carrying an inline identity token

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' 'http://localhost:16670/health?model=wif-inline-token-cfg' -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY"
  2. Observed (the raw identity token and the Console ids come back to a team admin; the error field's stack trace and the raw request dict are trimmed)

    {"healthy_endpoints":[],"unhealthy_endpoints":[{"allow_client_keepalive_override":false,"use_in_pass_through":false,"use_litellm_proxy":false,"use_xai_oauth":false,"merge_reasoning_content_in_choices":false,"model":"anthropic/claude-haiku-4-5","anthropic_identity_token":"eyJfake.fake.fake","anthropic_organization_id":"00000000-0000-4000-8000-000000000000","anthropic_federation_rule_id":"fdrl_qa_fake","anthropic_service_account_id":"svac_qa_fake","max_tokens":16,"cache":{"no-cache":true},"error":"litellm.AuthenticationError: Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars","model_id":"f6ff3ac6583d4b6052506bb46d2c6f16c1958a7ee350afb70c63152f1fc481ea","exception_status":401}]}
    HTTP 503
    

Team admin edits on a federated team deployment

  1. Run (the proxy admin creates the team-scoped federated deployment)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16670/model/new -H 'X-Team-Key: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"model_name":"team-wif-p2","litellm_params":{"model":"anthropic/claude-haiku-4-5","litellm_credential_name":"anthropic-wif-issuer"},"model_info":{"team_id":"21d7db27-51e1-41c3-b535-4b8f6c37c55a"}}'
  2. Observed

    {"model_id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f","model_name":"model_name_21d7db27-51e1-41c3-b535-4b8f6c37c55a_5d95800a-995a-4f9c-ab50-79c6c39ce7ea","litellm_params":{"model":"ItJMf36TBvjYHdSYjywkPyKxZI88RZdu8GWELSQL2ksruMp2-oSvP8chQHfupAQehfM6C14nARb8D_V8MZ2fb9XO","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"litellm_credential_name":"DSIwX6q32rXRLadX8YqXA43zAjzLXgNPIcaMJeE9_trBJbq4xESqj386k7tm0I0Wa0DAAN--bdLS1hSv","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f","team_id":"21d7db27-51e1-41c3-b535-4b8f6c37c55a","db_model":false,"team_public_model_name":"team-wif-p2"},"blocked":false,"created_at":"2026-09-06T10:22:10.088000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:22:10.088000Z","updated_by":"default_user_id"}
    HTTP 200
    
  3. Run (the team admin sets rpm on it)

    curl -s -w '\nHTTP %{http_code}\n' -X PATCH http://localhost:16670/model/22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f/update -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"litellm_params":{"rpm":5}}'
  4. Observed (the edit lands, it sets no federation field)

    {"model_id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f","model_name":"model_name_21d7db27-51e1-41c3-b535-4b8f6c37c55a_5d95800a-995a-4f9c-ab50-79c6c39ce7ea","litellm_params":{"rpm":5,"model":"ItJMf36TBvjYHdSYjywkPyKxZI88RZdu8GWELSQL2ksruMp2-oSvP8chQHfupAQehfM6C14nARb8D_V8MZ2fb9XO","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"litellm_credential_name":"DSIwX6q32rXRLadX8YqXA43zAjzLXgNPIcaMJeE9_trBJbq4xESqj386k7tm0I0Wa0DAAN--bdLS1hSv","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f","team_id":"21d7db27-51e1-41c3-b535-4b8f6c37c55a","db_model":false,"team_public_model_name":"team-wif-p2"},"blocked":false,"created_at":"2026-09-06T10:22:10.088000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:22:16.006000Z","updated_by":"rig-team-admin"}
    HTTP 200
    
  5. Run (the same team admin points it at a static key instead)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16670/model/update -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"model_name":"team-wif-p2","litellm_params":{"model":"anthropic/claude-haiku-4-5","api_key":"sk-ant-bogus"},"model_info":{"id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f","team_id":"21d7db27-51e1-41c3-b535-4b8f6c37c55a"}}'
  6. Observed (the credential swap lands)

    {"model_id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f","model_name":"model_name_21d7db27-51e1-41c3-b535-4b8f6c37c55a_5d95800a-995a-4f9c-ab50-79c6c39ce7ea","litellm_params":{"rpm":5,"model":"BqiTnOWUapY0yBxKoP58R1-WtR8O-i5HTH7Ii1Oa6vYr5wbr9_hDiKEtnibq8KZPu_Ceu-rjRT7Timx4dJJ8qGsR","api_key":"O466FL01Z22jHEXjM-g1indRjxhfdNvKSpIz3Db8AleqrLWKQJSpJYgTXwfzq89MZMmcLA==","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"litellm_credential_name":"DSIwX6q32rXRLadX8YqXA43zAjzLXgNPIcaMJeE9_trBJbq4xESqj386k7tm0I0Wa0DAAN--bdLS1hSv","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f","team_id":"21d7db27-51e1-41c3-b535-4b8f6c37c55a","db_model":false,"team_public_model_name":"team-wif-p2"},"blocked":false,"created_at":"2026-09-06T10:22:10.088000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:22:16.268000Z","updated_by":"rig-team-admin"}
    HTTP 200
    
  7. Run (cleanup, the team admin deletes it)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16670/model/delete -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"id":"22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f"}'
  8. Observed

    {"message":"Model: 22a6bf7d-2f95-4dd0-a4b2-6b6ee1bf665f deleted successfully"}
    HTTP 200
    

Setting blocked on a new team deployment

  1. Run (the team admin creates a deployment with blocked: true)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16670/model/new -H "X-Team-Key: Bearer $TEAM2_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"model_name":"blocked-probe-base","litellm_params":{"model":"anthropic/claude-sonnet-4-5","api_key":"sk-ant-probe"},"model_info":{"team_id":"6c0aa7c0-ec3e-4074-8761-bbf58ae84b1f"},"blocked":true}'
  2. Observed (the flag is dropped and the deployment lands unblocked)

    {"model_id":"018687db-42f1-457a-bb4e-04baad0e9bc6","model_name":"model_name_6c0aa7c0-ec3e-4074-8761-bbf58ae84b1f_905eb2a9-25d6-485d-9aff-7893fbb75b94","litellm_params":{"model":"ogDg0Fe5NNdk8KVY7R5RZabk-oMnvdPNewAgAV237U9h9ydahdIR10-7sNXq0aM7PmXVAS-vYELzRLLhqRif9XXFDg==","api_key":"MAoS2uGpFvee48biAyl2LaqmwX-WM_x4UCoYtSEOV2WMVg6myzeTcEzgHlSuybJvZ3ijGw==","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"018687db-42f1-457a-bb4e-04baad0e9bc6","team_id":"6c0aa7c0-ec3e-4074-8761-bbf58ae84b1f","db_model":false,"team_public_model_name":"blocked-probe-base"},"blocked":false,"created_at":"2026-09-06T10:22:55.882000Z","created_by":"89005c5d-817d-4da4-ad4f-38e4b06e3eaa","updated_at":"2026-09-06T10:22:55.882000Z","updated_by":"89005c5d-817d-4da4-ad4f-38e4b06e3eaa"}
    HTTP 200
    
  3. Run (the proxy admin sends the same flag)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16670/model/new -H 'X-Team-Key: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"model_name":"admin-blocked-probe-base","litellm_params":{"model":"anthropic/claude-sonnet-4-5","api_key":"sk-ant-probe"},"blocked":true}'
  4. Observed (the flag is dropped for the proxy admin too)

    {"model_id":"df67ac7d-85a7-47bf-b510-e03f07cfea1d","model_name":"admin-blocked-probe-base","litellm_params":{"model":"KMN5Nj1ytkYF0v8qYO4fxlGoO-xAUsLGqcT0ABJ6x9bJqFIKKuT2pCeWYY7BnkPxpghdmsKzjrSijMklCmnvt7LpHQ==","api_key":"XqMJ3lADA12GO95YX4PsZI2v-Fq-TkKylz0I9zh8f1VgGyTjrp9v6XhpV9b4I5mw_XtWGw==","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"df67ac7d-85a7-47bf-b510-e03f07cfea1d","db_model":false},"blocked":false,"created_at":"2026-09-06T10:22:56.076000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:22:56.076000Z","updated_by":"default_user_id"}
    HTTP 200
    

After (a7e3b4c)

JWKS export for a LiteLLM-signed credential

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/credentials/anthropic-wif-issuer/jwks -H "Authorization: Bearer sk-1234"
  2. Observed

    {
      "keys": [
        {
          "crv": "P-256",
          "kty": "EC",
          "x": "a2PX31t4sihbUJFZ8JfOHNC1ZnYuLJGTdmxpAd8ZmHk",
          "y": "pw9zJYR7Xdy2b9mE5WNRg2qXiGAJDiIaHeOLh-nPS2Y",
          "use": "sig",
          "alg": "ES256",
          "kid": "037howaQkrCmUZx5eQxj1DqcICUncPOUp-h-wboaoXQ"
        }
      ]
    }
    HTTP 200
    

/v1/chat/completions on a federated deployment

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Anthropic workload identity federation failed. The token endpoint returned HTTP 400: error: invalid_request_error - federation_rule_id is not a well-formed fdrl_ tagged ID. Received Model Group=claude-haiku-4-5-wif\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

/v1/messages on a federated deployment

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/v1/messages -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","max_tokens":32,"messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"type":"error","error":{"type":"authentication_error","message":"litellm.AuthenticationError: Anthropic workload identity federation failed. The token endpoint returned HTTP 400: error: invalid_request_error - federation_rule_id is not a well-formed fdrl_ tagged ID. Received Model Group=claude-haiku-4-5-wif\nAvailable Model Group Fallbacks=None"}}
    HTTP 401
    

/v1/responses on a federated deployment

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/v1/responses -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","input":"Say hi in three words."}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Anthropic workload identity federation failed. The token endpoint returned HTTP 400: error: invalid_request_error - federation_rule_id is not a well-formed fdrl_ tagged ID. Received Model Group=claude-haiku-4-5-wif\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

/v1/chat/completions through an egress gateway allowlisted as host:port

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif-gateway","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: Anthropic workload identity federation failed. The token endpoint returned HTTP 400: error: invalid_request_error - federation_rule_id is not a well-formed fdrl_ tagged ID. Received Model Group=claude-haiku-4-5-wif-gateway\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

Federated credential missing the Console ids

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif-noids","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"litellm.AuthenticationError: anthropic_identity_source is 'internal_issuer', but anthropic_federation_rule_id and anthropic_organization_id are not set. Copy them from the federation rule's detail page under Settings > Workload identity in the Claude Console, or set ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID. Received Model Group=claude-haiku-4-5-wif-noids\nAvailable Model Group Fallbacks=None","type":"authentication_error","param":null,"code":"401"}}
    HTTP 401
    

Federation field typed into a chat body

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"claude-haiku-4-5-wif","anthropic_federation_rule_id":"fdrl_attacker","messages":[{"role":"user","content":"Say hi in three words."}]}'
  2. Observed

    {"error":{"message":"Authentication Error, Rejected Request: anthropic_federation_rule_id is a server-owned workload identity federation parameter and cannot be set in a request body. A proxy admin configures it on the deployment or on a stored credential.","type":"auth_error","param":"None","code":"401"}}
    HTTP 401
    

Federation fields typed into POST /model/new, next to the sanctioned credential

  1. Run (a federation field straight in the deployment's litellm_params)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/model/new -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model_name":"wif-inline-probe-after-a7e3b","litellm_params":{"model":"anthropic/claude-haiku-4-5","anthropic_federation_rule_id":"fdrl_attacker"}}'
  2. Observed

    {"error":{"message":"Authentication Error, Rejected Request: anthropic_federation_rule_id is a server-owned workload identity federation parameter and cannot be set in a request body. A proxy admin configures it on the deployment or on a stored credential.","type":"auth_error","param":"None","code":"401"}}
    HTTP 401
    
  3. Run (the same proxy admin attaches the stored federated credential instead)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:51022/model/new -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model_name":"wif-cred-probe-after-a7e3b","litellm_params":{"model":"anthropic/claude-haiku-4-5","litellm_credential_name":"anthropic-wif-issuer"}}'
  4. Observed

    {"model_id":"532bf435-6f6c-4118-8be5-ee96a34b18b5","model_name":"wif-cred-probe-after-a7e3b","litellm_params":{"model":"fSis-xLaROinyoY4u1eM3FdyaaUP95H7YbSTQaFL2UASjkeZmahxlRnKEzd_pX3O-4kmWBmF-XNTJmb_PsS8Jkkd","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"litellm_credential_name":"qDu102W8VNnxymSmThgfHiAPXwToAt6UWg3f-2NvKL67W-EB5CVucmnbttt-COE_0HbMQmptbGY0SUgx","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"532bf435-6f6c-4118-8be5-ee96a34b18b5","db_model":false},"blocked":false,"created_at":"2026-09-06T10:27:10.089000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:27:10.089000Z","updated_by":"default_user_id"}
    HTTP 200
    
  5. Run (which of the two the proxy now serves)

    curl -s http://localhost:51022/model/info -H "Authorization: Bearer sk-1234" | grep -oE 'wif-(inline|cred)-probe-after-a7e3b' | sort -u
  6. Observed

    wif-cred-probe-after-a7e3b
    

GET /health with a team admin key on a deployment carrying an inline identity token

  1. Run

    curl -s -w '\nHTTP %{http_code}\n' 'http://localhost:16671/health?model=wif-inline-token-cfg' -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY"
  2. Observed (the token and the Console ids are gone and the error says raw tokens must be oidc/ references; the error field's stack trace and the raw request dict are trimmed)

    {"healthy_endpoints":[],"unhealthy_endpoints":[{"allow_client_keepalive_override":false,"use_in_pass_through":false,"use_litellm_proxy":false,"use_xai_oauth":false,"merge_reasoning_content_in_choices":false,"model":"anthropic/claude-haiku-4-5","max_tokens":16,"cache":{"no-cache":true},"error":"litellm.AuthenticationError: anthropic_identity_token must be an oidc/ secret reference such as oidc/env/VAR_NAME, oidc/file//absolute/path, oidc/github/<audience>, or oidc/google/<audience>. Raw identity tokens and oidc/env_path/ references are not accepted; to pass a token directly, export it and reference it as oidc/env/VAR_NAME","model_id":"f6ff3ac6583d4b6052506bb46d2c6f16c1958a7ee350afb70c63152f1fc481ea","exception_status":401}]}
    HTTP 503
    

Team admin edits on a federated team deployment

  1. Run (the proxy admin creates the team-scoped federated deployment)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16671/model/new -H 'X-Team-Key: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"model_name":"team-wif-p2","litellm_params":{"model":"anthropic/claude-haiku-4-5","litellm_credential_name":"anthropic-wif-issuer"},"model_info":{"team_id":"92766e22-87db-49ef-9249-1476a3f9743d"}}'
  2. Observed

    {"model_id":"72ddd18a-fdfa-4d5d-82bd-0582929d036a","model_name":"model_name_92766e22-87db-49ef-9249-1476a3f9743d_e5c56be0-4128-4e71-adab-e8e35ca14ae7","litellm_params":{"model":"v0CSDjy_CuABqPzCBAGdCRMKmzzz905QsYOKj8g4h4LRiXyF_BUmwt5ei93PNJv_tHCCZ8g-KMFPv7KF9MrTrfdN","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"litellm_credential_name":"kDOeOiwWO3zp7JEgBOdfSLB10567nPTwKWosFHeZZx1l-6DOuqwzC-YHLrCiF3j0cPcLL1wWBPDHkySa","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"72ddd18a-fdfa-4d5d-82bd-0582929d036a","team_id":"92766e22-87db-49ef-9249-1476a3f9743d","db_model":false,"team_public_model_name":"team-wif-p2"},"blocked":false,"created_at":"2026-09-06T10:22:20.131000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:22:20.131000Z","updated_by":"default_user_id"}
    HTTP 200
    
  3. Run (the team admin sets rpm on it)

    curl -s -w '\nHTTP %{http_code}\n' -X PATCH http://localhost:16671/model/72ddd18a-fdfa-4d5d-82bd-0582929d036a/update -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"litellm_params":{"rpm":5}}'
  4. Observed (the edit lands, it sets no federation field)

    {"model_id":"72ddd18a-fdfa-4d5d-82bd-0582929d036a","model_name":"model_name_92766e22-87db-49ef-9249-1476a3f9743d_e5c56be0-4128-4e71-adab-e8e35ca14ae7","litellm_params":{"rpm":5,"model":"v0CSDjy_CuABqPzCBAGdCRMKmzzz905QsYOKj8g4h4LRiXyF_BUmwt5ei93PNJv_tHCCZ8g-KMFPv7KF9MrTrfdN","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"litellm_credential_name":"kDOeOiwWO3zp7JEgBOdfSLB10567nPTwKWosFHeZZx1l-6DOuqwzC-YHLrCiF3j0cPcLL1wWBPDHkySa","allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"72ddd18a-fdfa-4d5d-82bd-0582929d036a","team_id":"92766e22-87db-49ef-9249-1476a3f9743d","db_model":false,"team_public_model_name":"team-wif-p2"},"blocked":false,"created_at":"2026-09-06T10:22:20.131000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:22:30.884000Z","updated_by":"rig-team-admin"}
    HTTP 200
    
  5. Run (the same team admin points it at a static key instead)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16671/model/update -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"model_name":"team-wif-p2","litellm_params":{"model":"anthropic/claude-haiku-4-5","api_key":"sk-ant-bogus"},"model_info":{"id":"72ddd18a-fdfa-4d5d-82bd-0582929d036a","team_id":"92766e22-87db-49ef-9249-1476a3f9743d"}}'
  6. Observed (the credential swap is refused)

    {"error":{"message":"Only proxy admins can change the credentials of a deployment configured for workload identity federation ('anthropic_federation_rule_id').","type":"auth_error","param":"anthropic_federation_rule_id","code":"403"}}
    HTTP 403
    
  7. Run (cleanup, the team admin deletes it)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16671/model/delete -H "X-Team-Key: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"id":"72ddd18a-fdfa-4d5d-82bd-0582929d036a"}'
  8. Observed

    {"message":"Model: 72ddd18a-fdfa-4d5d-82bd-0582929d036a deleted successfully"}
    HTTP 200
    

Setting blocked on a new team deployment

  1. Run (the team admin creates a deployment with blocked: true)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16671/model/new -H "X-Team-Key: Bearer $TEAM2_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"model_name":"blocked-probe-head","litellm_params":{"model":"anthropic/claude-sonnet-4-5","api_key":"sk-ant-probe"},"model_info":{"team_id":"fc6cc17b-50e2-4abd-a40f-2eb5d7e47467"},"blocked":true}'
  2. Observed (the team admin is refused)

    {"error":{"message":"Only proxy admins can set a model's blocked flag.","type":"auth_error","param":"blocked","code":"403"}}
    HTTP 403
    
  3. Run (the proxy admin sends the same flag)

    curl -s -w '\nHTTP %{http_code}\n' http://localhost:16671/model/new -H 'X-Team-Key: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"model_name":"admin-blocked-probe-head","litellm_params":{"model":"anthropic/claude-sonnet-4-5","api_key":"sk-ant-probe"},"blocked":true}'
  4. Observed (the proxy admin's flag sticks)

    {"model_id":"1e4ea016-9788-4db7-a19c-f988494dcfcc","model_name":"admin-blocked-probe-head","litellm_params":{"model":"eOkDXZqgRH7deIuO_B45M0D-NK4hPRk8j0k7yhe1D4aw7Eo70GH1iHHqcoD31tvZreO4Xd_P4FsO-1-f6V81dgCEfg==","api_key":"5vp6AR6zyhWavMeY8uwZ9FfK_ZYY37KjL9uqoxQckChcmMiFBQZyqIoKG2lDX8dJLvx-BA==","use_xai_oauth":false,"use_litellm_proxy":false,"use_in_pass_through":false,"allow_client_keepalive_override":false,"merge_reasoning_content_in_choices":false},"model_info":{"id":"1e4ea016-9788-4db7-a19c-f988494dcfcc","db_model":false},"blocked":true,"created_at":"2026-09-06T10:22:56.764000Z","created_by":"default_user_id","updated_at":"2026-09-06T10:22:56.764000Z","updated_by":"default_user_id"}
    HTTP 200
    

Surprises from the run:

  • JWKS route answers 405 on base, not 404; this PR leaves it alone
  • A federation 401 cools the deployment down 5 s (pre-existing)
  • /model/new on base echoes encrypted litellm_params; this PR leaves it alone

Type

New Feature

Caveats (if any)

Severe

  • A static Anthropic key in the proxy environment silently outranks every federated deployment
    • The order is ANTHROPIC_API_KEY, then ANTHROPIC_AUTH_TOKEN, then federation, and it holds for chat, /v1/messages, /v1/responses, files, batches and model discovery alike. It is the order the Anthropic SDK itself uses, and inverting it would break every deployment that sets both, so it stays
    • Reduced rather than removed: a blank key or auth token now counts as unset instead of winning, and the proxy logs a warning naming the shadowed model the first time federation is skipped for it. proxy_cli.py calls load_dotenv(), so a .env in the proxy's working directory puts the key back even after the shell unset it, and a fleet that wants no static secrets has to clean that file too
  • Minted org-scoped tokens are written to disk by default
    • The shared store is what keeps several uvicorn workers reading one projected token file from each spending its single-use jti, so dropping it would break the multi-worker case this PR exists for. Entries are 0600 in a 0700 uid-owned directory, a staging file a failed write leaves behind is now unlinked so no live token survives it, and LITELLM_TOKEN_EXCHANGE_CACHE_DIR set to an empty string turns the store off
    • An expired-entry sweep was weighed and left out: an expired token is not a usable credential, and the sweep would add a background scan of a directory the engine already overwrites per key

High

Medium

  • No dashboard in this PR
  • A freshly added federated credential can answer the generic Missing Anthropic API Key 401 for up to one credential refresh interval on a multi-worker proxy without Redis
    • Pre-existing for every litellm_credential_name deployment (LIT-6901); config.yaml credentials are loaded by every worker at boot and do not hit it, POST /credentials ones do
  • Anthropic accepts each identity assertion once, so a static one is refused at its first refresh
    • An oidc/env/ value or a token file that rotates slower than the rule's token lifetime (60 s to 24 h, wizard default 10 min) gets jti_reused on the re-mint until it rotates, and the 401 hint says so; the LiteLLM-signed internal issuer and Keycloak mint a fresh assertion every time
  • Federation on eu.api.openai.com and us.api.openai.com is exercised only up to the upstream geography refusal, no geography-restricted project was available to see a 200 on a regional host

Low

  • Only Organization ID and Federation Rule ID are required to attempt an exchange: Anthropic's reference lists the service account id as required but a live exchange minted without it when the rule targets one service account, and the workspace id is required only when the rule is enabled in more than one workspace
  • Federation reads ANTHROPIC_FEDERATION_WORKSPACE_ID, not the shorter ANTHROPIC_WORKSPACE_ID the Console reference uses, because the Bedrock Claude platform provider already reads that shorter name on a released path; the 401 hint says so when the workspace is ambiguous
  • Identity token files must sit under LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS, and the token exchange only talks to api.anthropic.com unless the operator adds hosts to LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS, so a gateway or test double needs that set. Entries are a host or a host:port: naming a port trusts that port alone, leaving it off trusts every port on the host. The list is server-owned, read from the environment only, and cannot be set through the model or credential APIs
  • A freshly registered JWKS takes about a minute to become usable in the Console; a data plane 401 does not invalidate the cached token, which self-heals; minted tokens are cached on disk per host (LITELLM_TOKEN_EXCHANGE_CACHE_DIR, default $TMPDIR/litellm-token-exchange-<uid>), so workers on one host share one exchange and each host mints its own
  • Federation fields are refused inline on POST /model/new for the proxy admin too, not only for team admins: the sanctioned paths are a named credential (POST /credentials then litellm_credential_name) or config.yaml. Changing the credentials of a federated deployment, meaning its federation fields, its api_base or the credential attached to it, stays proxy-admin only; the team admin who owns it can still edit everything else on it, delete it, and press Test Connection
  • A request body that names a stored credential carrying federation fields is refused with 400, on /v1/chat/completions and every other LLM route, so a caller cannot pick which federated identity the proxy mints as
  • /anthropic/* passthrough no longer forwards the caller's own credential headers upstream once the deployment has a server-side Anthropic credential: authorization, x-api-key and the rest of the headers the proxy accepts a LiteLLM key in are stripped, plus general_settings.litellm_key_header_name when one is set. Base forwarded them all, so a client that relied on its own header reaching Anthropic through the relay has to stop sending the server credential instead. With no server credential nothing is stripped and BYOK is unchanged
  • The OpenAPI schema now lists CredentialItem-Input and CredentialItem-Output instead of one CredentialItem, because credential_values_to_delete is excluded from serialization. The wire shape is unchanged; a generated client pinned to the old schema name regenerates. ui/litellm-dashboard/src/lib/http/schema.d.ts is regenerated for the same reason and is the only file under ui/ this PR touches
  • A clientside-credential request that overrides api_base gets a different synthesized per-request model id than before, since the federation disable flag now rides those params. Cosmetic, the id is not persisted
  • A team admin setting blocked to true on POST /model/new for a team deployment gets 403 where base answered 200 and silently dropped the flag; blocked: false still answers 200, since that is the state every create lands in anyway, and a proxy admin still gets 200 with the flag honored
  • A team admin naming a credential that does not exist on POST /model/new gets 400 where base answered 403
  • The tag management writer that appends a tag to a deployment's params is not behind the federation admin gate; it only touches tags, so it cannot change a federation field
  • Deployments without an api_base now count tokens through ANTHROPIC_API_BASE / ANTHROPIC_BASE_URL the way chat does; a gateway there that serves /v1/messages but not /v1/messages/count_tokens gets the logged local-tokenizer fallback where the base counted through Anthropic's host directly
  • Count-tokens still cannot authenticate with ANTHROPIC_AUTH_TOKEN: with only that set it logs the local-tokenizer fallback, exactly as the base branch did
  • Deleting a credential that exists only in config.yaml answers 404 and leaves it serving, staging's fix(proxy): 404 a credential delete that matched nothing, and raise instead of return #36260 rule carried through the federation admin gate
  • Reading a credential back masks the token file path, and a client that PATCHes the masked object back stores the mask: pre-existing for every masked credential field (LIT-6900)
  • Image, audio, transcription, moderation, files, batches, fine-tuning, and assistants stay env-only for OpenAI federation: they build their OpenAI client elsewhere, LIT-6898 tracks them
  • The new tests reach for monkeypatch.setattr 68 times rather than injecting a dependency, which the repo prefers. Fixing it is net-negative: most of those calls patch module-level singletons (litellm.api_key, litellm.credential_list, litellm.module_level_client) that have no constructor to inject through, so removing them means reshaping settled provider modules well outside this PR. The token exchange engine, the one piece here that does have a seam, is tested through it
  • osv-scan was red on the pre-existing gitpython advisory in uv.lock on feat(anthropic): workload identity federation, pluggable identity sources, and provider-level setup (internal copy of #38013) #38818; this PR does not touch dependencies

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

High Risk
This changes authentication and token exchange across many Anthropic (and related OpenAI WIF) code paths, with new caching, host trust, and credential handling—mistakes could leak credentials, send requests to untrusted hosts, or break batch billing and multi-worker deployments.

Overview
Adds Anthropic workload identity federation (WIF): deployments without a static api_key can exchange an OIDC workload assertion for a short-lived sk-ant-oat01 token via a new shared RFC 7523 JWT-bearer engine (litellm/llms/base_llm/auth/), with identity sources for mounted/env tokens, a LiteLLM-signed internal issuer, and Keycloak client_credentials.

Anthropic auth is threaded through chat, /v1/messages passthrough, files, batches (including retrieve + batch output fetch for billing), skills, count-tokens, and model discovery. litellm_params now carry WIF settings end-to-end; async paths offload token exchange so the event loop is not blocked. Minted federation credentials strip caller-supplied auth headers so a proxy Bearer does not ride alongside deployment credentials. Exchange hosts are pinned to api.anthropic.com unless LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS is set; tokens can be shared across workers via a file-backed cache.

FORWARDED_KWARGS_KEYS / optional params also include Anthropic and OpenAI WIF kwargs so federation config flows like other deployment credentials. Batch cost aggregation copies Anthropic WIF keys so finished-batch fetches can authenticate without an api_key.

Reviewed by Cursor Bugbot for commit 31f88a3. Bugbot is set up for automated code reviews on this repo. Configure here.

…sources

Backend half of #38818 (internal copy of the fork PR #38013), rebuilt as one
commit on top of litellm_internal_staging without the dashboard changes.

Deployments on anthropic/ without a static api_key can exchange an OIDC
workload assertion for a short-lived sk-ant-oat01 token through a shared
RFC 7523 JWT-bearer engine. The assertion comes from a mounted token file,
an env token, a LiteLLM-signed issuer, or Keycloak, chosen per deployment,
per named credential, or through ANTHROPIC_IDENTITY_SOURCE. The federation
fields are server-owned: refused inline in request bodies and on
POST /model/new, proxy-admin only on credentials, and the token exchange
is pinned to api.anthropic.com unless LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS
adds a host. GET /credentials/{name}/jwks exports the public key set of a
LiteLLM-signed credential for the Claude Console.

The OpenAI federation trio from #39613 rides along on the backend side with
the same server-owned handling.

Fixes #28607
Resolves LIT-6107

Co-authored-by: derhornspieler <15236687+derhornspieler@users.noreply.github.com>
@mateo-berri
mateo-berri marked this pull request as ready for review September 5, 2026 19:14
@mateo-berri
mateo-berri requested a review from a team September 5, 2026 19:14
@codspeed

codspeed Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_anthropic_wif_backend (a7e3b4c) with litellm_internal_staging (02522a5)

Open in CodSpeed

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds workload identity federation for Anthropic and extends the shared authentication infrastructure used by OpenAI federation.

  • Adds RFC 7523 token exchange, pluggable identity sources, secure shared token caching, and LiteLLM-signed JWKS export.
  • Threads federated credentials through Anthropic chat, Messages, Responses, files, batches, skills, passthrough, model discovery, and token counting.
  • Enforces server ownership of federation settings, trusted exchange destinations, credential-management authorization, and health-output redaction.
  • Recent changes permit federated credential attachment only on deployment-management routes, centralize provider-neutral WIF sensitivity metadata, and preserve federation credentials during batch result retrieval.

Confidence Score: 5/5

The PR appears safe to merge; no outstanding correctness, security, or repository-rule issue remains.

The recent changes close the prior health-policy boundary concern through a provider-neutral sensitivity contract, and the deployment-route exemption remains protected by the downstream model-management authorization gate. No accepted new findings or outstanding previous findings remain.

Important Files Changed

Filename Overview
litellm/llms/anthropic/wif.py Resolves Anthropic federation configuration, constrains exchange destinations, and constructs token-exchange specifications.
litellm/llms/base_llm/auth/token_exchange.py Implements the shared RFC 7523 exchange engine with endpoint validation, caching, and synchronized refresh behavior.
litellm/proxy/auth/auth_utils.py Allows federated credential references on deployment-management routes while retaining request-time rejection elsewhere.
litellm/proxy/common_utils/credential_hydration.py Computes effective federation state so management authorization covers stored and named credentials.
litellm/proxy/health_check.py Uses provider-neutral WIF sensitivity contracts to redact secrets and restrict identity metadata visibility.
litellm/types/workload_identity.py Centralizes workload-identity parameter sets and the provider-neutral secret-bearing field contract.
litellm/llms/anthropic/batches/handler.py Authenticates batch retrieval with deployment WIF parameters without blocking the asynchronous event loop.
litellm/batches/batch_utils.py Preserves Anthropic federation configuration when completed batch output is fetched for accounting.

Reviews (14): Last reviewed commit: "refactor(proxy): derive health display p..." | Re-trigger Greptile

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Files handler drops credential WIF params
    • Added a litellm_params argument to AnthropicFilesHandler.afile_content and file_content and forwarded it to aget_auth_header so credential-backed workload identity federation can mint on the batch-result download path.
  • ✅ Fixed: Allowlist misparses host:port entries
    • Extracted a _normalize_trusted_host helper that prefixes schemeless entries with // before urlsplit and strips any trailing port, so host:port allowlist entries reduce to the bare hostname the exchange comparison uses.

Create PR

Or push these changes by commenting:

@cursor push d3a1256a18
Preview (d3a1256a18)
diff --git a/litellm/llms/anthropic/files/handler.py b/litellm/llms/anthropic/files/handler.py
--- a/litellm/llms/anthropic/files/handler.py
+++ b/litellm/llms/anthropic/files/handler.py
@@ -43,6 +43,7 @@
         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 @@
             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 @@
         # 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 @@
         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 @@
             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 @@
                 api_base=api_base,
                 api_key=api_key,
                 max_retries=max_retries,
+                litellm_params=litellm_params,
             )
         else:
             return asyncio.run(
@@ -151,6 +160,7 @@
                     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
--- a/litellm/llms/anthropic/wif.py
+++ b/litellm/llms/anthropic/wif.py
@@ -345,17 +345,29 @@
 
 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``

You can send follow-ups to the cloud agent here.

Comment thread litellm/llms/anthropic/files/handler.py
Comment thread litellm/llms/anthropic/wif.py Outdated
…s 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.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/llms/anthropic/wif.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/batches/batch_utils.py
Comment thread litellm/proxy/health_check.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

…itellm_anthropic_wif_backend

# Conflicts:
#	tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 31f88a3. Configure here.

An empty or whitespace-only ANTHROPIC_API_KEY counted as set, so a federated
deployment sent an empty x-api-key on every call instead of minting a token.
Blank values now read as unset, and a real static key on a federated deployment
logs once that it outranks federation and nothing is being federated.

The exchange-host allowlist matched hostnames only, so a second process on
another port of an allowed host was trusted with the workload's identity token.
An entry that names a port now trusts that port alone, while a bare host still
trusts every port.

The shared token store exists so the workers reading one projected token file do
not each spend its single-use jti. A source that mints its own assertion per
exchange shares nothing with another worker, so it no longer writes a live token
to disk for a lookup that can never hit.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/credential_endpoints/endpoints.py Outdated
The 401 denial hint now also says federation ignores ANTHROPIC_WORKSPACE_ID, which the Bedrock Claude platform provider already reads.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/llms/base_llm/auth/shared_token_store.py Outdated
A buffered write only reaches the disk when the handle closes, so a full disk surfaces at close and left the staging file behind holding a usable token.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/llms/base_llm/auth/shared_token_store.py Outdated
Comment thread litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

The admin gate read the stored deployment, so a team admin lost edit, delete
and Test Connection on any deployment carrying federation params. It now
returns early unless the submitted fields touch the federation surface, and a
Test Connection probe that points the deployment at its own api_base is still
refused, with the 403 no longer wrapped into a 500

The rest of the same review pass: POST /model/new refuses only a blocking
value of `blocked`, so a client that always sends `blocked: false` is not
turned away; a request body can no longer pick which federated identity to
mint as by naming a stored credential; an advisory refresh the executor
refuses disarms the entry instead of wedging the identity until the follower
timeout; the static-key shadow warning resolves its env fallback inside the
cache instead of once per request; credential writes drop nulls before
storing them; the token exchange validates the endpoint URL before reading an
assertion and keeps refusing redirects across a client heal; /health hides
every server-owned federation field from non-admins; and the async create_file
and create_batch paths say which setting is missing when the provider resolves
no URL
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment on lines +66 to +70
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS: Final = (
"api_base",
"api_version",
*(name for name in server_owned_wif_litellm_params if name not in ILLEGAL_DISPLAY_PARAMS),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Provider Boundary Violation

This shared proxy health policy now directly consumes provider-specific workload-identity parameters. The repository requires provider-specific logic to remain under llms/, so this requirement must be satisfied before merging. Please expose a provider-neutral sensitivity or display contract instead of making the health layer depend on WIF fields.

Rule Used: What: Avoid writing provider-specific code outside... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

reject_federated_credential_reference runs from is_request_body_safe, which
pre_db_read_auth_checks calls on every route, so it also fired on POST
/model/new, /model/update, /model/{id}/update and /health/test_connection. A
proxy admin could no longer attach a federated credential to a deployment over
the API or the Admin UI, leaving a static config.yaml entry as the only way to
configure the feature the rejection told the caller to go configure, and
_reject_non_admin_wif_write never got to make the call it exists to make.

is_request_body_safe now takes the route and skips only the credential-reference
check on the routes that reach can_user_make_model_call. Federation fields typed
inline into a body stay refused everywhere, and a call naming a federated
credential still cannot pick the identity it mints as.
… sets

The health check module hand-copied the five workload identity fields whose
value is a credential, so a shared proxy surface named provider-specific
parameters and a newly added secret-bearing field would have gone on being
displayed until someone remembered both places

WIF_SECRET_BEARING_KEYS now sits beside the key sets it splits out of,
types/utils derives secret_bearing_wif_litellm_params from it, and the health
layer splats that tuple the same way it already splats the admin-only one
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@yuneng-berri
yuneng-berri deleted the branch main September 13, 2026 04:49
@mateo-berri mateo-berri reopened this Sep 13, 2026
@mateo-berri
mateo-berri changed the base branch from litellm_internal_staging to main September 13, 2026 05:13
@greptile-apps

greptile-apps Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (118 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Support Anthropic Workload Identity Federation (OIDC JWT-bearer token exchange)

2 participants