Skip to content

ci: keyless OIDC release pipeline + SLSA L3 (staging twin of #28025) - #28038

Closed
lee-mcfaul2 wants to merge 17 commits into
BerriAI:shin_agent_oss_staging_05_16_2026from
lee-mcfaul2:lee/slsa-keyless-release-pipeline
Closed

ci: keyless OIDC release pipeline + SLSA L3 (staging twin of #28025)#28038
lee-mcfaul2 wants to merge 17 commits into
BerriAI:shin_agent_oss_staging_05_16_2026from
lee-mcfaul2:lee/slsa-keyless-release-pipeline

Conversation

@lee-mcfaul2

Copy link
Copy Markdown

Summary

This adds a fully public, keyless release pipeline for both distribution channels:

  • PyPI — OIDC Trusted Publisher upload (no API token), PEP 740 publish attestations, SLSA Build L3 provenance, and detached keyless cosign signatures on the sdist/wheel.
  • GHCR — keyless cosign signatures and SLSA Build L3 provenance for litellm, litellm-database, and litellm-non-root.
  • Consumer-style verification in CI — every release re-verifies its own signatures and attestations the way a downstream user would (gh attestation verify, cosign verify), so a broken or unsigned release fails loudly instead of shipping.
  • A regression test (tests/test_litellm/test_release_workflow_hardening.py) that locks the supply-chain invariants in place (SHA-pinned actions only, keyless only, OIDC only, tag-scoped identities, no static key).

Addresses #24524 (which requested cosign + SLSA for the GHCR images). This PR delivers that and extends the same guarantees to the PyPI side.

Note: This is the staging-branch counterpart of #28025 — same branch and commit, different base. See "Why there are two PRs" at the bottom.

Why this way?

This is meant to complement the post-incident CI/CD v2 hardening, not relitigate the March 2026 incident. Per the project's own security advisory and the incident timeline, the root cause was: a compromised CI dependency exfiltrated the publishing credential, which was then used to upload 1.82.7 / 1.82.8 to PyPI directly, bypassing the official workflow.

Two structural properties follow from that root cause, and this pipeline is built around both:

  1. There is no publishing credential to steal. PyPI upload uses an OIDC Trusted Publisher and GHCR uses the ephemeral, job-scoped GITHUB_TOKEN. There is no long-lived PYPI_PUBLISH token, API key, or password in repository secrets or the workflow environment. The exact asset that was exfiltrated in the incident does not exist in this design — auth is a short-lived OIDC token minted at runtime and scoped to the specific workflow run.

  2. An out-of-band artifact is detectable. Even setting credentials aside, every artifact carries SLSA provenance and a keyless signature that cryptographically bind it to a specific public workflow run, repository, and commit SHA. A package that was not built by the public pipeline fails gh attestation verify / cosign verify for downstream users who check.

    This is the same class of vector that enabled the xz-utils backdoor (CVE-2024-3094): a maintainer directly published a doctored release source tarball that did not correspond to the git tree — the obfuscated payload sat in test fixtures, and the trigger was an autotools macro (build-to-host.m4) carried only in the tarball, not in the repository, that extracted and linked it during ./configure. Source review of the repo could not catch it because the released artifact was never required to match a provenance-attested build of a specific public commit. Provenance over the build output — the property this PR adds — makes "a published artifact that doesn't match a public build from a known commit" a verification failure rather than something invisible until someone diffs the tarball by hand.

On the current signing approach: the post-incident control is a long-lived static cosign key (cosign.pub, introduced in 0112e53, documented as cosign verify --key …/cosign.pub). A static key proves only that a holder of the key signed the blob — it does not bind the artifact to a commit or a build, and a key holder is not necessarily the public CI. It is, in that sense, equivalent to a detached GPG signature. It also reintroduces the same category of liability as the incident: a long-lived secret that is valuable to exfiltrate, with no built-in revocation path. Keyless signing removes the key entirely — the signing identity is an ephemeral Fulcio certificate tied to the workflow's OIDC identity, logged in the public Rekor transparency log, and verifiable offline against the Sigstore TUF root. This PR therefore also removes cosign.pub and updates the README to the keyless verification commands.

Taken together, these are two independent exposures that the current posture leaves open at the same time: without artifact-to-build provenance, an xz-class substitution — a published artifact that never corresponded to a public build — is invisible to anyone verifying downstream; and a long-lived signing key alongside a long-lived publish token keeps the credential-theft path that caused the March incident available. Neither control substitutes for the other, which is why this is one cohesive change: OIDC removes the stealable credential, and keyless + provenance makes any out-of-band artifact fail verification. Closing only one of the two leaves the other fully open.

What this delivers

PyPI (publish_to_pypi.yml)

  • OIDC Trusted Publisher upload via pypa/gh-action-pypi-publish — no password:/token.
  • PEP 740 publish attestations (attestations: true), and the workflow asserts the attestation actually landed on PyPI.
  • SLSA Build L3 provenance via actions/attest-build-provenance.
  • Detached keyless cosign sigstore bundles on the wheel and sdist, attached to the GitHub release.
  • A verify-slsa job that re-verifies provenance + signatures consumer-style.

GHCR (release-docker.yml + reusable _publish-container.yml)

  • Builds, pushes, and keyless-signs the three images; SLSA L3 provenance attached as an OCI referrer (push-to-registry).
  • A verify-all matrix job that re-verifies each image (cosign verify + gh attestation verify oci://…).

Both

  • A preflight guard that fails the run unless it was dispatched from the release tag — the keyless certificate identity is tag-bound, so this prevents a branch dispatch from publishing artifacts whose signatures wouldn't match the verification commands shipped to consumers.
  • Every external action is SHA-pinned; every downloaded binary is checksum-verified; no curl | bash. (This is also the class of weakness — an unpinned CI tool — that was the incident's entry point.)
  • The invariants test fails CI if any of the above regresses.

Verify it yourself

During development this pipeline was run end-to-end on a public fork, using temporary repository-gated test scaffolding that has since been removed from the final workflows. Those runs, logs, and signed artifacts remain public and independently re-verifiable:

Anyone can independently verify a published fork image:

cosign verify \
  --certificate-identity-regexp='^https://github\.com/lee-mcfaul2/litellm/\.github/workflows/_publish-container\.yml@refs/tags/' \
  --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
  ghcr.io/lee-mcfaul2/litellm:<tag>

gh attestation verify oci://ghcr.io/lee-mcfaul2/litellm:<tag> --owner lee-mcfaul2

Integration notes

  • Re-introduces publish_to_pypi.yml, which was removed in 3f6c0090c0 ("remove unused GitHub Actions workflows"). The new file is a hardened replacement, not the old one — flagging explicitly that the modify/delete is intentional, not an accident.
  • Base branch: this PR targets shin_agent_oss_staging_05_16_2026 — the dated daily staging branch the auto-merge agent on ci: keyless OIDC release pipeline + SLSA L3 provenance (closes #24524) #28025 asked the branch to be opened against. These dated branches roll daily; this one reflects the agent's request at the time of opening. The branch has not been rebased onto the staging tip (per the note below, the choice of which base to land on is left to the maintainers), so GitHub may report divergence/conflicts against this moving branch — that is expected and not a content issue with the change itself.
  • PyPI publish targets are unchanged: environment pypi-publish, project litellm, default (real) PyPI, workflow filename publish_to_pypi.yml, and the existing litellm-proxy-extras pre-check. The Trusted Publisher binding is (owner, repo, workflow-filename, environment) — all four match the prior configuration, so this slots into the existing PyPI-side setup with no target changes. The only deltas are additive hardening.
  • Deployment prerequisites that the maintainers control (not code changes): the PyPI Trusted Publisher entry for litellm must still list workflow publish_to_pypi.yml + environment pypi-publish; the pypi-publish / docker-release GitHub environments must exist. GHCR works automatically via GITHUB_TOKEN.
  • The workflows contain no fork-specific scaffolding. The temporary repository-gated steps used to exercise the pipeline on a public fork during development have been removed, so what is in this PR is exactly what runs on BerriAI/litellm — nothing to strip, gate, or refactor on merge.
  • README.md is updated to the keyless verification commands. The user-facing docs site is maintained in the separate BerriAI/litellm-docs repository, so updating any keyful instructions there is a small follow-up doc PR against that repo rather than part of this change.

Scope (intentionally not included)

  • No change to release cadence, versioning, or create-release.yml's release-branch flow beyond updating the embedded verification instructions to match the as-built pipeline.
  • Docker Hub publishing is wired but gated off by default (enable-docker-hub: false); GHCR is the primary channel.
  • Existing CI/CD v2 isolation/gates are unaffected — this is the verifiability layer on top, not a replacement.

Why there are two PRs

This PR and #28025 are the same branch and the same commit — they differ only in base branch:

Rather than force-rebasing one branch onto a target that rolls every day, both bases are offered so a maintainer can simply merge whichever fits the current release flow and close the other. No preference on which — they carry identical changes. Happy to rebase, retarget, or close either on request.

I'm happy to discuss and clarify.

yuneng-berri and others added 17 commits May 14, 2026 13:28
pytest-cov runs with --cov=litellm, which makes coverage.xml store paths
relative to the package root (e.g. `proxy/proxy_server.py` instead of
`litellm/proxy/proxy_server.py`). Codecov auto-resolves these only when
the basename is unique in the repo. Files like proxy_server.py, router.py,
utils.py, main.py, and constants.py — which have duplicates under
enterprise/ or other subpackages — get silently dropped during ingest.

The `fixes: ["::litellm/"]` rule prepends `litellm/` to every uploaded
path so they resolve unambiguously. Confirmed against multiple recent
coverage.xml artifacts that no uploader currently emits paths already
prefixed with `litellm/`, so the rule is safe to apply universally.

This restores Codecov visibility for the highest-fix-rate hotspots:
proxy_server.py, router.py, proxy/utils.py, litellm_logging.py,
constants.py, key_management_endpoints.py, utils.py, main.py,
user_api_key_auth.py, team_endpoints.py, and litellm_pre_call_utils.py.
This reverts commit e25a988.

The `fixes: ["::litellm/"]` rule turned out to be applied *after* Codecov's
auto-resolution, not before. Files with unique basenames (which were
auto-resolving correctly to `litellm/<path>`) got an extra `litellm/`
prepended, producing `litellm/litellm/<path>` storage. Files with
ambiguous basenames (the actual target of the fix) continued to be
dropped because the auto-resolution still failed for them.

Net result on the verification run: 1375 files now stored under
unresolvable `litellm/litellm/...` paths, and the 11 originally-missing
hotspots are still missing. Reverting before piling on further changes.
…decov

pytest-cov treats --cov=<module-name> as a Python package and emits XML
paths relative to the package root, stripping the litellm/ prefix
(`proxy/proxy_server.py` instead of `litellm/proxy/proxy_server.py`).
Codecov's auto-prefix heuristic then drops every file whose basename is
ambiguous in the repo — `proxy_server.py` (3 copies under enterprise/),
`router.py` (2 copies), `utils.py` (20+), `main.py` (20+), `constants.py`
(2). The 11 highest-fix-rate hotspots have never appeared in Codecov.

Switching to --cov=./litellm treats the argument as a path, which makes
coverage.xml emit repo-relative paths (`litellm/proxy/proxy_server.py`).
Each path is unambiguous, so Codecov resolves all files correctly.

Verified locally: rerunning a single proxy_unit_tests test with
--cov=./litellm produced `filename="litellm/proxy/proxy_server.py"`,
`filename="litellm/router.py"`, and `filename="litellm/types/router.py"`
as distinct entries — exactly the disambiguation Codecov needs.

Touches every workflow that uploads coverage: the two reusable GHA
workflows (_test-unit-base.yml, _test-unit-services-base.yml),
test-mcp.yml, and all 14 invocations in .circleci/config.yml.
Remove available_on_public_internet gating from delegate-auth-to-upstream
paths so oauth2 + delegate_auth_to_upstream interactive servers behave
the same when marked internal. Keeps M2M exclusion. Updates tests.
Log verbose_logger.warning when loading oauth2 interactive servers with
available_on_public_internet=false and delegate_auth_to_upstream=true
(config + DB). Dashboard Alert for the same combo. CLAUDE note for
operators. Tests for log and M2M skip.
Removes accidental duplicate alias/mcp_aliases and get_server_prefix
logic (fixes PLR0915 and avoids resetting alias after mapping).
…erriAI#27936)

_build_mcp_server_table omitted delegate_auth_to_upstream, so GET /v1/mcp/server always returned the default false while the registry kept the DB value.

Co-authored-by: Cursor <cursoragent@cursor.com>
…el (BerriAI#27929)

* feat(proxy): fix vector store retrieve/list/update/delete routing without model

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(proxy): remove unchecked query-param injection in vector store management endpoints

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(proxy): use subset assertion for vector store route test to allow extra kwargs like shared_session

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…ckBatchCost poller (BerriAI#27984)

* fix(managed_batches): convert raw output_file_id to managed ID in CheckBatchCost poller

CheckBatchCost bypasses async_post_call_success_hook, causing raw provider
output_file_ids to be persisted in LiteLLM_ManagedObjectTable. This fix converts
output_file_id and error_file_id to managed base64 IDs before the DB write.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(check_batch_cost): persist managed file before mutating response and propagate team_id

- Move setattr after store_unified_file_id so the response only receives the
  managed ID once the DB record is successfully written. Avoids serializing
  an orphaned managed ID into file_object when the store call fails.
- Populate team_id on the minimal UserAPIKeyAuth from job.team_id so the
  managed file record is created with the correct team ownership, allowing
  other team members to access the batch output file via /files/{id}/content.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* test(managed_batches): extend test to cover error_file_id conversion

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix managed file test

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
…BerriAI#27912)

* fix(vertex-ai): fix zero cost/usage on completed Vertex AI batch jobs

Vertex batch jobs recorded 0 spend and 0 tokens after PR BerriAI#25627 added
automatic transformation of GCS predictions.jsonl to OpenAI format.

Two bugs fixed:

1. batch_utils.py: the Vertex-specific cost/usage reader
   (calculate_vertex_ai_batch_cost_and_usage) was always invoked and
   reads raw usageMetadata fields that no longer exist in the
   OpenAI-shaped output. Now the reader is only used when
   disable_vertex_batch_output_transformation=True; otherwise the
   generic path handles the already-transformed OpenAI-shaped content.

2. cost_calculator.py: batch_cost_calculator skipped the global
   litellm.get_model_info() lookup when a model_info dict was passed
   in, even when that dict had no pricing fields (e.g. deployment
   metadata with only id/db_model). It now falls back to the global
   pricing table when the provided model_info has no pricing data.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update litellm/cost_calculator.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(cost-calculator): use not-any guard for pricing fallback in batch_cost_calculator

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cost-calculator): treat explicit zero batch pricing as set in model_info

The fallback to litellm.get_model_info() used truthy checks on pricing
fields, so 0.0 was treated as missing and replaced by global rates.
Use `is not None` like elsewhere in cost calculation. Add regression test.

Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com>
…c0e43b

ci: use --cov=./litellm so coverage paths resolve unambiguously in Codecov
…legate_pkce

fix(mcp): delegate PKCE bypass for internal MCP servers
* Feat: Add Weighted-Routing Failover

* test(router): cover weighted failover helper functions

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(router): align weighted failover deployment list type with mypy

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(router): address greptile review on weighted failover

- Narrow exception swallowing in `_maybe_run_weighted_failover` to
  `openai.APIError` so model failures defer to the regular fallback
  while programming bugs (AttributeError/KeyError/TypeError) surface.
- Note async-only limitation of `enable_weighted_failover` in the
  Router constructor docstring.
- Make the weighted distribution test less flaky (1000 iterations,
  looser bound) and make the non-simple-shuffle test deterministic by
  failing both deployments instead of relying on the latency strategy's
  first pick.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(router): ensure weighted failover metadata persists in kwargs

The previous `kwargs.setdefault(metadata_variable_name, {}) or {}` returned
a brand-new dict whenever the existing metadata was falsy (empty dict or
None), so writes to `_failover_excluded_ids` never made it back into
`kwargs`. Multi-hop weighted failover then re-selected previously failed
deployments and exhausted `max_fallbacks` prematurely.

Explicitly assign a fresh dict into kwargs when metadata is missing so
mutations are visible to subsequent failover hops.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* test(router): regression for weighted failover metadata persistence

Asserts kwargs["metadata"]["_failover_excluded_ids"] is populated after
_maybe_run_weighted_failover, proving the metadata dict written by the
helper is the same object that lives in kwargs (no disconnected copy).
Pairs with the prior fix that replaced `setdefault(..., {}) or {}` with
an explicit get/assign so writes survive across hops.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(router): harden weighted failover error/state handling

- Catch RouterRateLimitError (ValueError) alongside openai.APIError in
  _maybe_run_weighted_failover so an exhausted intra-group retry falls
  through to the regular cross-group fallback path instead of bubbling
  out and bypassing configured fallbacks.
- Stop mutating the shared input_kwargs dict; build a local copy with
  the weighted-failover keys so the entry (with _excluded_deployment_ids)
  cannot leak into later fallback paths reading the same dict.
- _get_excluded_filtered_deployments now returns an empty list when the
  exclusion filter removes every healthy deployment, instead of falling
  back to the original list. The original-list behavior risked re-picking
  the just-failed deployment; callers already handle the empty case by
  raising their no-deployments error, which weighted failover now catches
  and converts into a normal cross-group fallback.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(router): fall through to rpm/tpm when total weight is zero

When the weight metric's total is zero (e.g. after weighted-failover
exclusion leaves only zero-weight backups), continue to the next metric
(rpm/tpm) instead of returning a uniform random pick immediately. This
lets rpm/tpm still drive routing when present, and only falls back to
the uniform random pick at the end if no metric provides a positive
total weight.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(router): skip weighted failover when remaining deployments are all in cooldown

_maybe_run_weighted_failover was computing 'remaining' from all_deployments
(every deployment in the model group, including those in cooldown). This meant
that when all non-excluded deployments were in cooldown the method still invoked
run_async_fallback unnecessarily, which propagated into async_get_healthy_deployments,
found no eligible deployments, and raised RouterRateLimitError — only safely
caught thanks to the earlier exception-broadening fix.

The fix: before computing 'remaining', fetch the current cooldown set via
_async_get_cooldown_deployments and subtract it from all_ids. This allows
_maybe_run_weighted_failover to return None immediately (skipping the
run_async_fallback call entirely) when every non-failed deployment is in cooldown,
letting the caller fall through to the correct cross-group fallback path without
the wasteful extra round-trip.

Tests added:
- unit: _maybe_run_weighted_failover returns None without calling run_async_fallback
  when all remaining deployments are in cooldown
- unit: _maybe_run_weighted_failover still calls run_async_fallback when at least
  one healthy (non-cooldown) deployment is available
- integration: end-to-end fallthrough to cross-group fallback when remaining
  deployments are in cooldown

Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com>
BerriAI#27976)

* fix(bedrock-mantle): use /anthropic/v1/messages path for Mantle endpoint (BerriAI#27943)

* docs: add one-line docstring to _disable_debugging (BerriAI#27894)

Squash-merged by litellm-agent from oss-agent-shin's PR.

* Add jp. Bedrock cross-region inference profile for claude-sonnet-4-6 (BerriAI#27831)

Squash-merged by litellm-agent from Cyberfilo's PR.

* Sanitize empty text content blocks on /v1/messages (BerriAI#27832)

Squash-merged by litellm-agent from Cyberfilo's PR.

* fix(bedrock-mantle): use /anthropic/v1/messages path for Mantle endpoint

The bedrock-mantle gateway (Claude Mythos Preview) serves the Anthropic
Messages API at /anthropic/v1/messages; /v1/messages returns 404 Not
Found. Both AmazonMantleConfig (chat/completions caller route) and
AmazonMantleMessagesConfig (anthropic-messages caller route) hardcoded
the wrong path, so every Mantle request 404'd before reaching the model.

Per the Anthropic docs: "[Claude in Amazon Bedrock] uses the Messages
API at /anthropic/v1/messages with SSE streaming."
https://platform.claude.com/docs/en/api/claude-on-amazon-bedrock

Confirmed independently against the live endpoint:
  /v1/chat/completions      -> 200 OK
  /v1/messages              -> 404 Not Found  (what litellm used)
  /anthropic/v1/messages    -> 200 OK         (Claude only)

Adds a regression test asserting both Mantle configs build the
/anthropic/v1/messages path, and updates the existing assertions that
encoded the wrong path.

---------

Co-authored-by: oss-agent-shin <ext-agent-shin@berri.ai>
Co-authored-by: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com>

* fix: sanitize empty text blocks in sync anthropic_messages_handler path

Co-authored-by: Yassin Kortam <yassin@berri.ai>

---------

Co-authored-by: João Costa <13508071+jpv-costa@users.noreply.github.com>
Co-authored-by: oss-agent-shin <ext-agent-shin@berri.ai>
Co-authored-by: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Publishes LiteLLM to PyPI and GHCR entirely over OIDC, with no
long-lived signing keys or registry credentials:

- PyPI: OIDC Trusted Publisher upload with PEP 740 attestations,
  SLSA L3 build provenance via actions/attest-build-provenance,
  and detached keyless cosign signatures on the sdist and wheel.
- Docker: a reusable build-push-sign workflow for the three images
  (litellm, -database, -non-root), keyless cosign signing, and
  SLSA L3 provenance attached as an OCI referrer.
- Consumer-style verify jobs that re-check every signature and
  attestation the way a downstream user would (gh attestation
  verify, cosign verify), so a broken pipeline fails loudly.
- A regression test enforcing the supply-chain invariants:
  SHA-pinned actions, keyless-only, OIDC-only, no static key.

The static cosign.pub is removed; keyless verification roots in
Fulcio/Rekor, not a checked-in public key.
@CLAassistant

CLAassistant commented May 16, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
3 out of 5 committers have signed the CLA.

✅ Sameerlite
✅ yuneng-berri
✅ lee-mcfaul2
❌ ishaan-berri
❌ shin-berri
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a fully keyless OIDC release pipeline for both PyPI and GHCR, removes the static cosign.pub key, and ships a regression test suite (test_release_workflow_hardening.py) that locks in the supply-chain invariants. It also bundles several unrelated code changes: a weighted intra-group failover mode for the Router, empty-text-block sanitization on the Anthropic Messages path, a Bedrock Mantle endpoint URL fix, and Vertex AI batch cost-calculation gating.

  • Release pipeline hardening (publish_to_pypi.yml, _publish-container.yml, release-docker.yml): all actions are SHA-pinned, publishing is OIDC-only with no long-lived secrets, SLSA L3 provenance is attached via actions/attest-build-provenance, and consumer-style verification jobs run after every publish.
  • MCP server auth boundary removed (mcp_server_manager.py): the guard that blocked anonymous callers from accessing available_on_public_internet=False delegate servers has been deleted and replaced with a warning log; two tests that enforced this invariant have been renamed and their assertions inverted.
  • Router weighted failover (router.py, router_strategy/simple_shuffle.py, utils.py): new enable_weighted_failover flag enables same-model-group retry after a deployment failure before cross-group fallback runs, with deployment exclusion tracked across retries.

Confidence Score: 3/5

The release pipeline hardening is well-constructed, but the MCP server auth change is a backwards-incompatible relaxation of an access boundary with no opt-in flag, and the tests that previously caught it have been inverted.

The core release pipeline work (OIDC publish, keyless signing, SLSA provenance, SHA-pinned actions) is thorough and well-tested. The unrelated code changes (Bedrock URL fix, Anthropic message sanitization, batch cost gating) are small and low-risk. However, the MCP server manager change removes an explicit access boundary — available_on_public_internet=False servers are now reachable by anonymous PKCE callers — without a feature flag to preserve existing behaviour. The two tests that enforced the old boundary have been renamed and their assertions reversed rather than the code being fixed, eliminating the regression net. Deployments that relied on that flag to restrict internal MCP servers to authenticated-only callers will be silently exposed after merging.

litellm/proxy/_experimental/mcp_server/mcp_server_manager.py and tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py warrant close review before merging.

Security Review

  • Authentication boundary removed (mcp_server_manager.py): The available_on_public_internet=False guard that blocked anonymous callers from reaching internal-only MCP servers via PKCE/delegate-auth flow has been deleted. Deployments that use this flag to fence off internal infrastructure will silently accept unauthenticated PKCE initiations after merging.
  • Security invariant tests inverted: Two tests (test_delegate_ignored_for_non_public_server, test_get_allowed_servers_excludes_non_public_delegate) that enforced the above boundary have been renamed and their assertions reversed rather than the code being fixed, removing the regression net.

Important Files Changed

Filename Overview
.github/workflows/publish_to_pypi.yml New OIDC-only PyPI publish pipeline with SLSA L3 provenance, PEP 740 attestations, and keyless cosign signatures; all actions SHA-pinned; missing fork-repo guard present in the Docker counterpart
.github/workflows/_publish-container.yml New reusable container build/sign workflow; keyless cosign, SLSA provenance via attest-build-provenance, failure cleanup, all actions SHA-pinned; looks correct
.github/workflows/release-docker.yml New Docker release orchestrator with fork guard, tag/commit-hash verification, and consumer-style verify-all matrix job; well-structured
.github/workflows/create-release.yml Release body updated from keyful cosign verification instructions to keyless gh-attestation + cosign verify-blob commands; no functional pipeline changes
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Removes the available_on_public_internet guard from anonymous delegate-server allow-list, allowing internal-only PKCE servers to be reached by anonymous callers; replaces blocking check with a warning log only
tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py Two security invariant tests renamed and assertions inverted to match the new permissive behavior rather than enforcing the original boundary
tests/test_litellm/test_release_workflow_hardening.py New regression test suite locking in supply-chain invariants (SHA-pinned actions, keyless-only signing, no slsa-github-generator, no static cosign key, consistent cosign version)
litellm/router.py Adds enable_weighted_failover flag and _maybe_run_weighted_failover for intra-group retry on simple-shuffle; stamps failed_deployment_id on exceptions and filters excluded deployments from healthy lists
litellm/llms/anthropic/common_utils.py Adds strip_empty_text_blocks_from_anthropic_messages helper to remove empty/whitespace text blocks that cause 400s on the native Anthropic Messages path
litellm/llms/bedrock/chat/mantle/transformation.py Corrects Bedrock Mantle endpoint URL from /v1/messages to /anthropic/v1/messages
litellm/batches/batch_utils.py Gates the Vertex AI batch output path behind disable_vertex_batch_output_transformation flag so standard cost calculation runs by default
litellm/utils.py Moves get_secret import from TYPE_CHECKING block to a real top-level import; adds _get_excluded_filtered_deployments utility for weighted failover

Comments Outside Diff (2)

  1. litellm/proxy/_experimental/mcp_server/mcp_server_manager.py, line 989-999 (link)

    P1 security Auth boundary removed: internal-only servers now reachable by anonymous callers

    The and getattr(server, "available_on_public_internet", True) guard that previously blocked available_on_public_internet=False servers from the anonymous delegate_server_ids allow-list has been removed. Operators who set available_on_public_internet=False specifically to prevent unauthenticated internet callers from reaching an internal MCP server will now find that any anonymous client can initiate the PKCE flow and access the upstream OAuth2 /authorize endpoint — the only mitigation is a warning log.

    The original inline comment ("Internal-only servers must not be reachable from public internet callers who happen to carry an upstream token.") was removed alongside the guard. This is a backwards-incompatible change to the authentication layer with no opt-in flag, and the two tests that enforced the old behavior (test_delegate_ignored_for_non_public_server, test_get_allowed_servers_excludes_non_public_delegate) have had their assertions inverted rather than the code fixed. If this relaxation is intentional, it should require an explicit opt-in flag (e.g. allow_internal_delegate_anonymous_pkce: true) so existing deployments that rely on the available_on_public_internet=False guard are not silently broken.

    Rule Used: What: Fail any PR which may contains a security in... (source)

  2. tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py, line 1491-1539 (link)

    P1 security Security test inverted — previously-failing behavior now asserted as correct

    test_delegate_ignored_for_non_public_server has been renamed test_delegate_bypass_for_internal_server and its assertion reversed: the old test verified that calling an internal-only delegate server raises 401, the new test verifies that it succeeds with no auth. The companion test test_get_allowed_servers_excludes_non_public_delegatetest_get_allowed_servers_includes_internal_delegate similarly flips the assertion from assert "internal-server" not in result to assert "internal-server" in result. These tests existed to lock in a security invariant; replacing their bodies to match the new (more permissive) behavior removes the regression net entirely.

    Rule Used: What: Flag any modifications to existing tests and... (source)

Reviews (1): Last reviewed commit: "ci: add OIDC-rooted keyless release pipe..." | Re-trigger Greptile

Comment on lines +1 to +10
name: Publish to PyPI

on:
workflow_dispatch:

jobs:
preflight-checks:
name: Preflight Checks
runs-on: ubuntu-latest
timeout-minutes: 10

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 No fork-repository guard on the workflow

release-docker.yml has if: github.repository == 'BerriAI/litellm' on its preflight job to prevent spurious runs in forks. publish_to_pypi.yml has no equivalent guard. In a fork, a manual workflow_dispatch will run preflight-checks, build, publish-litellm (which will fail at the OIDC step since the Trusted Publisher isn't configured), and verify-slsa — burning CI minutes and potentially producing noisy failure notifications. The PyPI publish itself cannot succeed without the Trusted Publisher binding, so there is no credential risk, but the missing guard is worth aligning with the Docker workflow for consistency.

@lee-mcfaul2

Copy link
Copy Markdown
Author

Closing this in favour of #28025, which carries the identical change against the default litellm_internal_staging branch. This one was opened against the dated daily staging branch to follow the auto-merge agent's request, but its diff conflates the change with unrelated commits due to branch divergence. Happy to retarget #28025 onto whatever staging branch fits — details there. Thanks!

@veria-ai

veria-ai Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

PR overview

Medium: Internal MCP OAuth endpoints bypass network gating

This PR changes MCP upstream-delegated OAuth so internal-only servers can bypass LiteLLM auth. The streamable MCP routes still apply IP filtering later, but the management OAuth authorize/token endpoints can resolve an internal server by ID without applying that same public/internal check.

Security review

  • 1 new security issue(s) were flagged in the latest review.
  • 1 issue(s) remain open on this pull request.

Risk: 6/10

@@ -1578,7 +1578,6 @@ async def _mcp_oauth_user_api_key_auth(request: Request) -> UserAPIKeyAuth:
_s
and getattr(_s, "auth_type", None) == MCPAuth.oauth2
and getattr(_s, "delegate_auth_to_upstream", False) is True

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.

Medium: Internal MCP OAuth endpoints bypass network gating

An unauthenticated external caller who knows an internal server ID can hit /server/oauth/{server_id}/authorize and /token for a delegate_auth_to_upstream server because this bypass no longer checks available_on_public_internet, and _get_cached_temporary_mcp_server_or_404 resolves get_mcp_server_by_id() without applying the IP filter. Please gate this anonymous OAuth bypass with the same filter_server_ids_by_ip / _is_server_accessible_from_ip check used by MCP tool routes, or keep internal-only delegate servers behind normal LiteLLM auth on these endpoints.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants