A non-admin scoped to ["model-a"] could call /health?model_id=id-b
(where id-b belongs to a deployment outside their scope) and the
background-cache code path would return id-b's cached health entry. The
helper returned {model_id} unconditionally, so the cache filter was
driven by an unvalidated id and the global cache leaked the entry — the
ternary `targeted_ids if not None else allowed_model_ids` skipped any
intersection with the caller's allowed deployments.
Make _resolve_targeted_model_ids walk the supplied model_list for both
the model and model_id branches. Callers pass an already-scoped list
(filtered to allowed model_names for non-admins, full list for admins),
so an out-of-scope model_id resolves to an empty set and the cache
filter drops every entry — matching the live path's existing behavior.
When JWT auth is enabled but `JWT_AUDIENCE` is unset, `auth_jwt`
disabled audience verification entirely. Tokens minted by any other
application that shared the same IdP signing keys (Azure AD, Okta,
etc.) were accepted as long as their signature checked out, even
though their `aud` and `iss` claims pointed at unrelated apps. The
proxy then fell into the no-team / no-user branch where access checks
default-allow.
This change:
1. Adds support for the `JWT_ISSUER` env var. When set, PyJWT verifies
the token's `iss` claim — turning on the same defense for tokens
that share an audience but come from a different IdP tenant.
2. Refactors the duplicated `jwt.decode` calls (RSA/EC/OKP path and
x509 path) into a single `_build_decode_kwargs` helper that
computes audience, issuer, and the corresponding `verify_*` opt-outs
once per call.
3. Logs a single startup-time warning when JWT auth is enabled but
neither `JWT_AUDIENCE` nor `JWT_ISSUER` is configured, so operators
running the insecure default see a flag in their logs without
getting spammed per-request.
Default behavior (no env vars) is preserved for backward compatibility.
Setting `JWT_AUDIENCE` and/or `JWT_ISSUER` opts into the verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub serves PDFs from raw.githubusercontent.com, github.com/.../raw/..., LFS, and Releases as application/octet-stream by deliberate anti-hotlinking policy. Anyone who passes a GitHub-hosted PDF URL as an OpenAI / Gemini / Bedrock file_id hits "unsupported MIME type 'application/octet-stream'" because _process_image_response inlines the URL with whatever Content-Type the server sent.
When the server-provided Content-Type is application/octet-stream or binary/octet-stream and the URL extension maps to a known MIME type (.pdf, .png, .jpg, etc.), trust the extension instead. Specific Content-Types (image/png, application/pdf) still win over the extension; the override only applies to generic binary types.
Also restores the Greptile SHA-pinned raw.githubusercontent.com URL on the file_id integration test so we test against the same hosting real users hit, no third-party CDN.
Anthropic's URL fetcher intermittently returns 400 'Unable to download
the file' for the Wikipedia URL the test was using. Point it at the
repo's existing tests/llm_translation/fixtures/dummy.pdf via raw
GitHub instead — small, deterministic, reliably fetchable.
With a stable URL the test no longer needs to be opted out of VCR;
remove it from the incompatible list so it can replay from cassette.
`_check_proxy_admin_viewer_access` enumerates write routes a
PROXY_ADMIN_VIEW_ONLY caller may not invoke, then falls through to
"allow" for any management route not listed. Several write endpoints
were never added to the blocklist, so a viewer could:
- block or unblock any team via `/team/block` / `/team/unblock`
- mutate team permissions via `/team/permissions_update` and
`/team/permissions_bulk_update`
- create, update, or delete JWT key mappings via
`/jwt/key/mapping/{new,update,delete}`
- bulk-edit keys via `/key/bulk_update`
- reset key spend via the path-parameterized `/key/{id}/reset_spend`
Hoist the blocklist into a module-level frozenset and a tuple of
suffix patterns so it's clear what to extend when a new write route
is added, and pull the existing key write routes from the
`KeyManagementRoutes` enum so the two stay in sync. Adds parametrized
tests over the newly-blocked routes plus baseline coverage for routes
that should remain allowed (info / list / daily-activity reads).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve URL conflict: keep Greptile's commit-SHA pin for immutability, but route through jsDelivr (cdn.jsdelivr.net/gh/BerriAI/litellm@<sha>/...) so the response Content-Type is application/pdf instead of application/octet-stream. Without this, OpenAI / Gemini / Router PDF tests reject the inlined file_data with "unsupported MIME type 'application/octet-stream'".
Per review: `assert response.status_code != 503` is satisfied by 404,
500, or any other non-503 code, so a regression that returned the wrong
non-503 status would slip through. Switch to `== 200` so the assertions
verify the actual expected status, not just the absence of one specific
failure.
A test that produces non-deterministic request bodies (e.g. uuid in
the prompt) under record_mode=new_episodes never replays — every CI
run appends fresh unmatched episodes. The cassette grows unbounded
over time and silently inflates Redis (we observed one cassette at
22 episodes / ~860KB after ~5 CI runs).
Refuse the save when episode count exceeds MAX_EPISODES_PER_CASSETTE
so the pathology surfaces with a loud warning that points to the
opt-out fix instead of festering invisibly.
The previous URL switch to raw.githubusercontent.com fixed Anthropic's "Unable to download" failure but caused OpenAI / Gemini / Router PDF tests to fail with "unsupported MIME type 'application/octet-stream'": those providers download the URL and inline it as data:<Content-Type>;base64,..., and raw.githubusercontent.com serves PDFs as application/octet-stream.
jsDelivr proxies the same in-repo fixture (cdn.jsdelivr.net/gh/BerriAI/litellm@main/...) and returns the correct Content-Type: application/pdf, so all providers (Anthropic forwards the URL natively; OpenAI/Gemini/Bedrock fetch and inline) get the right MIME type without changing transformer code.
Some tests can't benefit from cassette replay because they assert on
state that only exists in the live provider between two calls (e.g.
prompt-cache propagation, intermittent provider quirks). Marking them
with @pytest.mark.vcr just wastes cycles trying to record cassettes
they will never replay against successfully.
Opt-out by nodeid suffix so subclassed/parametrized variants are
covered:
- ::test_prompt_caching — Anthropic/Bedrock prompt-cache propagation
isn't deterministic in the 0–1s window the test gives it.
- ::test_async_pdf_handling_with_file_id — flaky upstream Wikipedia
fetch through the Anthropic Files API.
- TestBedrockInvokeNovaJson::test_json_response_pydantic_obj —
Bedrock Nova returns tool_call vs JSON nondeterministically (other
providers' subclasses are healthy).
- ::test_bedrock_converse__streaming_passthrough — Bedrock streaming
response_cost calc returns None intermittently.
These tests keep their existing @pytest.mark.flaky retry behavior.
When use_background_health_checks is enabled, /health?model=foo returned
the full cached aggregate across every model — so an unhealthy foo
combined with any other healthy deployment kept healthy_count > 0 and
the targeted-503 path never fired.
Resolve the targeted model/model_id to a deployment-id set first
(mirroring perform_health_check's match-on-model_name-or-litellm_model
semantics) and narrow the cache to those IDs before _post_process
evaluates healthy_count, so the 503 contract holds for both the live
and cache code paths.
Two follow-ups to the managed-resource isolation fix:
1. Rename the new composite indexes to match Prisma's auto-generated naming
convention (`<Table>_created_by_team_id_created_at_idx`). The previous
`*_team_owner_created_at_idx` names left `prisma migrate diff` reporting
an outstanding `RENAME INDEX`, failing `test_aaaasschema_migration_check`.
2. Make `build_owner_filter` return an OR clause when the caller has both
a `user_id` and a `team_id`, so listings include team-shared resources
the same way `can_access_resource` already permits reading them. Without
this a user could fetch a team-shared resource by id but never see it
in their list view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A test that fails (incl. all the failing retries before a passing one)
can otherwise overwrite a known-good cassette with a 'bad luck'
recording. Tests like test_prompt_caching, which assert on provider
state across two calls, can produce a 200 response that semantically
fails the assertion — the 2xx filter doesn't catch this because the
HTTP layer is fine.
- pytest_runtest_makereport hook attaches each phase report to the
pytest item.
- _vcr_outcome_gate fixture (combining the verbose-mode reporter)
reads the call-phase outcome at teardown and informs the persister
via mark_test_outcome_for_cassette before vcrpy's Cassette.__exit__
triggers save_cassette.
- save_cassette consults the per-key 'did the test pass?' flag and
short-circuits when False, leaving any prior good recording intact.
- Defaults to passed=True when no marker is present so non-test
usage of the persister still works.
Service-account API keys are issued without a `user_id`, and managed
file/batch/vector-store ownership checks compared
`resource.created_by == user_api_key_dict.user_id`. Because Python
evaluates `None == None` as True, any service-account key passed
ownership checks for any resource also created without a user id, and
listing endpoints skipped the `created_by` filter entirely when the
caller had no user id — returning every tenant's records.
Replace the bare equality with an identity-aware helper:
- Admins (PROXY_ADMIN, PROXY_ADMIN_VIEW_ONLY) keep their unscoped view.
- Callers with a `user_id` are scoped to records they created.
- Callers without a `user_id` but with a `team_id` are scoped to records
created within their team via a new `created_by_team_id` column.
- Callers with no admin role and no identifying ids are denied — the
listing path returns an empty page without issuing a query.
Schema migration adds `created_by_team_id` to LiteLLM_ManagedFileTable,
LiteLLM_ManagedObjectTable, and LiteLLM_ManagedVectorStoreTable, plus
indexes for the new filter. Writes in BaseManagedResource and the
enterprise managed_files hook now stamp the column from
`user_api_key_dict.team_id`. Reads in `can_user_access_unified_resource_id`,
`can_user_call_unified_file_id`, `can_user_call_unified_object_id`,
`list_user_resources`, `list_user_batches`, and `get_user_created_file_ids`
all delegate to the new helper.
Tests cover the helper in isolation, the base-class listing/access paths,
and the enterprise file-access hook (including a regression test for the
original `None == None` bypass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/health?model=foo and /health?model_id=foo previously returned HTTP 200
even when zero endpoints were healthy, forcing monitoring systems to
parse the JSON body to detect failure. /health/readiness similarly
returned 200 even when a configured Prisma DB was unreachable, leaving
unhealthy pods in rotation.
Both endpoints now flip to HTTP 503 in the failure case while keeping
the JSON response body identical, so existing parsers continue to work
and orchestrators can rely on the HTTP status alone.
Set LITELLM_VCR_VERBOSE=1 to print a one-line cassette verdict per
test (HIT / MISS / PARTIAL / NOOP) showing replay vs new-recording
counts. Useful for local QA to confirm which tests actually exercised
the cache and which fell through to the live provider.
Managed Redis (e.g. Upstash) drops idle TLS connections, which surfaced
in CI as a teardown ERROR on test_gemini_image_size_limit_exceeded:
redis.exceptions.ConnectionError: EOF occurred in violation of
protocol (_ssl.c:2427)
Cassette persistence is a cache, not test correctness, so:
- Configure the redis client with Retry(ExponentialBackoff, retries=2)
on ConnectionError/TimeoutError to absorb single-socket drops.
- Wrap save_cassette so a final failure logs a warning instead of
failing teardown — the next run re-records.
- Wrap load_cassette so an outage on read becomes a cache miss
(CassetteNotFoundError) instead of erroring in setup.
Tag each key SCIM blocks with metadata.scim_blocked=True. On reactivation
unblock only those keys, leaving keys an admin blocked for unrelated
reasons untouched.
Stop falling back to REDIS_URL/REDIS_SSL_URL/REDIS_HOST for the VCR
persister. Sharing a Redis with the application cache risks cassettes
being wiped by tests that flush the app Redis.
The test was passing the Wikipedia URL https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf as the file_id, which Anthropic's URL fetcher can no longer download (returns "Unable to download the file"). The URL is healthy for generic clients but Anthropic's fetcher fails on it deterministically, so the test has been red across PRs on litellm_internal_staging.
Switch to the in-repo fixture at tests/llm_translation/fixtures/dummy.pdf served via raw.githubusercontent.com - same fixture used elsewhere in the repo, no external CDN dependency that can block by user-agent.
If atomic_check_and_increment_by_n returns overall_code=OVER_LIMIT but no
status entry matches a descriptor key the dynamic limiter dispatcher knows
how to translate into a 429 (`model_saturation_check` or `priority_model`),
the for-loop previously exited cleanly and execution fell through to the
priority-tracking increment + the data["litellm_proxy_rate_limit_response"]
write — silently admitting an over-limit request.
This is the fail-open path a future contributor would hit by wiring a new
descriptor type into enforced_descriptors without updating the dispatcher.
Refuse the request with a generic 429 carrying the offending descriptor
metadata so the operator can see what slipped past, and emit an error log
to surface the wiring gap.
Adds a regression test (test_dynamic_rate_limiter_v3_fails_closed_on_unknown_descriptor)
that drives the limiter with a synthetic OVER_LIMIT response carrying an
unrecognized descriptor_key and asserts a 429 is raised.
Tests: 65 passed (1 skipped), 0 regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add expected_base_url origin check to get_litellm_gateway_api_key
* fix: scope get_stored_api_key and save base_url on login
* fix: pass base_url to get_stored_api_key in CLI entrypoint
* fix: scope ProxyClient stored key to base_url
* test: add expected_base_url coverage for get_stored_api_key
* fix: initialize self.http with resolved api_key not raw param
* fix: black formatting in client.py and test_auth_commands.py
- Lua script now reads time via redis.call('TIME') instead of a client-supplied
timestamp. Prevents window-reset divergence across replicas with skewed
wall-clocks, which could otherwise reopen the cross-replica TOCTOU window.
- Per-descriptor window_size is now plumbed through both the Lua ARGV layout
and the in-memory fallback. Previously the in-memory path used the global
self.window_size while Lua honored the per-descriptor override, so a
descriptor with a custom window would be enforced inconsistently between
Redis-available and Redis-unavailable code paths.
- Lua-failure fallback path now logs at error severity and explicitly
documents the in-memory ↔ Redis state divergence risk so operators can
alert on it. Prior `warning` log understated the impact.
- Coarse-granularity lock is now documented inline with the conditions under
which a per-descriptor sharded lock would be worth introducing.
- New regression test: zero-token batch consumes RPM only and is properly
capped by the RPM ceiling (validates the asymmetric quota path that arises
from `inc_amount <= 0: continue`).
Tests: 64 passed (1 skipped), 0 regressions. Multi-instance Redis loadtest
re-verified: chat 20/80 success @ RPM=20, batches 3/20 @ TPM=200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: scope /health response to caller's models and tidy display fields
Two small consistency changes to the /health response:
1. health_endpoint() now narrows _llm_model_list to deployments whose
model_name is in user_api_key_dict.models, matching how other model
listing endpoints already scope their output. The same narrowing applies
to the cached health_check_results dict when background_health_checks is
enabled, via a new _filter_health_check_results_by_model_ids helper.
2. ILLEGAL_DISPLAY_PARAMS in health_check.py picks up api_base and
api_version, which are provider routing fields and not part of the
health response shape.
Tests in tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
pin both behaviors so future changes do not widen the response shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* address greptile review feedback (greploop iteration 1)
- tests: extend background-cache test with model_id on cached entries plus
positive assertions that model-a's deployment is the one returned, so
the test is no longer satisfied by an empty result.
- _health_endpoints.py: add a verbose_proxy_logger.debug line when a scoped
key has accessible model_names but the matching deployments have no
model_info.id, so the empty cache-result case is observable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* make background-cache test's non-vacuity explicit
Restructure test_health_endpoint_filters_background_cache_by_user_access
so the assertions positively pin the post-scoping result (one entry,
model_id == "id-a", api_base == https://example-a.test) and add fixture
sanity checks that confirm the source cache had two entries and every
cached entry carries a model_id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* gate api_base in /health response on proxy-admin role
Replace the blanket strip of api_base / api_version with a role-aware
post-processor:
- api_base is now left in the cleaned per-deployment dict that
_clean_endpoint_data produces (api_version stays in the denylist).
- health_endpoint() removes api_base from each endpoint entry before
returning when the caller's user_role is not PROXY_ADMIN /
PROXY_ADMIN_VIEW_ONLY. The strip uses a copy so the shared
health_check_results cache still carries api_base for subsequent
admin reads.
Net effect: a proxy admin can still see which Vertex region or Azure
resource is healthy in the /health output, while non-admin keys (and
read-only keys) only see model / model_id / status fields.
Tests:
- test_health_endpoint_admin_sees_api_base_non_admin_does_not pins both
branches and verifies the cache is not mutated.
- test_clean_endpoint_data_strips_credentials_but_keeps_api_base
replaces the previous mask/drop tests now that the cleaning helper
no longer touches api_base.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* address review feedback: api_version symmetry, missing-id warnings, deprecation header
Three blockers raised in review:
1. api_version asymmetry — api_base was role-gated for proxy admins, but
api_version was unconditionally stripped via ILLEGAL_DISPLAY_PARAMS.
Move api_version out of the credential denylist and into a new
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS tuple alongside api_base, so admins
keep both routing fields and non-admins lose both. Useful for telling
apart Vertex regions or Azure api-versions from the /health response.
2. Silent empty results when scoped key's deployments lack model_info.id —
raise the existing log from .debug to .warning, and add a structured
"warnings" field to the response so the caller can distinguish "no
deployments configured" from "deployments excluded due to missing
model_info.id".
3. Migration signal for the api_base / api_version removal — when a
non-admin caller hits /health, set a "Litellm-Health-Field-Notice"
response header so existing dashboards or scripts that parsed those
fields can detect the change programmatically rather than silently
seeing absent keys.
Tests adjusted: existing background-cache test injects a Response stub,
admin-vs-non-admin test now asserts both api_base and api_version are
gated and asserts the notice header. New test covers the warnings field
when a scoped key's deployments are missing model_info.id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* defensive copies + clarifying comments in /health filter
- _filter_health_check_results_by_model_ids now shallow-copies each
retained endpoint dict before returning. The shared module-level
health_check_results cache should never be mutated by downstream
transforms, even though _strip_admin_only_fields_from_health_result
already builds new dicts today.
- Document the live (model_name) vs cache (model_id) scoping asymmetry
so future readers do not have to derive it from the warnings field.
- Document why _PROXY_ADMIN_ROLES includes PROXY_ADMIN_VIEW_ONLY (read-
only operators need routing fields to diagnose health).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: yuneng-jiang <yuneng@berri.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile follow-ups on the prior commit:
- (P1) ``is_request_body_safe`` recursed into ``litellm_embedding_config``
with no depth bound, so a request body 1000 levels deep could exhaust
Python's call stack and surface a 500 ``RecursionError``. Refactored
the check to be iterative (single-level descent into a fixed list of
nested-config keys) and extracted the per-dict banned-param scan into
a helper that's shared between the root and the nested call sites.
Also fixes the ``recursive_detector`` CI job that was triggered by
the recursive-by-name pattern.
- (P2) ``assert_same_origin`` error messages identified the mismatching
component but echoed the ``expected`` host and the candidate
hostname back to the caller. In the SSRF threat model the caller is
the attacker, so reflecting that information was a secondary leak of
operator infrastructure. Messages now identify only *which*
component mismatched (scheme / host / port) without naming names.
- (P2) ``_NESTED_CONFIG_KEYS`` was defined after the function that used
it. Hoisted the constant (and the new ``_BANNED_REQUEST_BODY_PARAMS``
tuple) above the function for readability.
Adds a 1000-level-deep nested config test that asserts no
``RecursionError`` and a hostname-leak test that asserts no operator
host appears in the rejection message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The async CustomStreamWrapper.__anext__ caught httpx.TimeoutException
and re-raised it raw, bypassing _handle_stream_fallback_error. The
Router's FallbackStreamWrapper only reacts to MidStreamFallbackError,
so stream_timeout firing mid-stream never triggered fallbacks the way
connection-phase timeout does.
Route timeouts through _handle_stream_fallback_error like every other
exception so they wrap into MidStreamFallbackError and the Router can
switch to a fallback model.
Two SSRF findings were OPEN with no in-flight fix; both are closed
now using narrow defenses that key off existing trust boundaries.
VERIA-6 (Milvus ``litellm_embedding_config``):
``is_request_body_safe`` already blocks ``api_base`` / ``api_key`` /
``langfuse_host`` / ``s3_endpoint_url`` / etc. at the *root* of the
request body, gated by an admin opt-in (``allow_client_side_credentials``
or per-deployment ``configurable_clientside_auth_params``). The bug is
that the Milvus vector-store transformer unpacks
``litellm_embedding_config`` into ``litellm.embedding(**embedding_config)``,
so a caller can smuggle the same banned params in via nesting and bypass
the check. Fix: ``is_request_body_safe`` now recurses into a known list
of nested-config dicts (``litellm_embedding_config`` for now) and applies
the same banned-param check with the same admin opt-in. Admin-side
vector-store config flows through ``litellm_params`` rather than the
request body, so it's unaffected.
VERIA-51 (polling URLs returned by upstream APIs):
Azure DALL-E 2, Azure Document Intelligence, and Black Forest Labs
all blindly fetched a polling URL returned by the upstream and
attached the operator's API key to the request. A compromised upstream
or a future API contract change could redirect credentials anywhere.
New ``url_utils.assert_same_origin(candidate, expected)`` helper checks
scheme, host (case-insensitive), and port (with default-port
normalization). Applied at all five polling sites: Azure DALL-E
sync+async, Azure DI sync+async, BFL image generation sync+async, BFL
image edit sync+async. Cross-origin polling URLs now raise rather than
forward credentials. The Azure DALL-E ``Expected 'status' in response``
exception no longer reflects the raw response body — that path turned
Blind SSRF into Full-Read SSRF for the limited window before the
origin check fully closed it.
Tests: 7 ``assert_same_origin`` unit tests, 6 ``is_request_body_safe``
nested-config tests, 5 polling-site rejection tests + 1 same-origin
sanity check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile: LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT=none (case-insensitive)
strips encoding_format so OpenAI-compatible backends can use provider defaults.
Preserves optional_params passthrough when env is unset.
Co-authored-by: Cursor <cursoragent@cursor.com>