Skip to content

feat(proxy): add expires filter to GET /key/list - #32953

Merged
ryan-crabbe-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_lit_3387_key_list_expires_filter
Jul 11, 2026
Merged

feat(proxy): add expires filter to GET /key/list#32953
ryan-crabbe-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_lit_3387_key_list_expires_filter

Conversation

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-3387

Pre-Submission checklist

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

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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 (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Captured against a local proxy on localhost:4000 backed by a real Postgres, at commit 08c5180. The flow seeds three keys (one already expired, one active with a future expiry, one that never expires), then lists with each filter value.

# 1. expired key (expires 1 minute ago)
curl -s http://localhost:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" -d '{"key_alias":"lit3387-expired","duration":"-60s"}'

# 2. active key (expires in 30 days)
curl -s http://localhost:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" -d '{"key_alias":"lit3387-active","duration":"30d"}'

# 3. never-expiring key
curl -s http://localhost:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" -d '{"key_alias":"lit3387-noexp"}'

# no filter -> all three, unchanged behavior
curl -s "http://localhost:4000/key/list?return_full_object=true&size=100" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" | jq '[.keys[] | select(.key_alias|startswith("lit3387")) | {key_alias, expires}]'

# expires=expired -> only the expired key (NULL expires excluded)
curl -s "http://localhost:4000/key/list?expires=expired&return_full_object=true&size=100" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" | jq '[.keys[] | select(.key_alias|startswith("lit3387")) | {key_alias, expires}]'

# expires=active -> the never-expiring key + the future one
curl -s "http://localhost:4000/key/list?expires=active&return_full_object=true&size=100" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" | jq '[.keys[] | select(.key_alias|startswith("lit3387")) | {key_alias, expires}]'

# typo -> HTTP 400, never a silent "all keys"
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:4000/key/list?expires=expred" \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY"

Type

🆕 New Feature

Changes

Adds an opt-in expires query parameter to GET /key/list. expires=expired returns only keys whose expires is in the past (keys with a NULL expires never expire, so they are excluded); expires=active returns keys that either never expire or expire in the future. Omitting expires preserves the current behavior for every existing caller, and an unrecognized value returns HTTP 400 instead of silently falling back to returning all keys.

Before this, fetching expired keys for an internal cleanup job meant paginating through every page of /key/list and filtering in application code; on a deployment with thousands of keys that is dozens of round-trips pulling the entire key table into memory. The filter is pushed to the database through the existing Prisma where builder (_build_key_filter_conditions), the same path already used for project_id, access_group_id, and agent_id, so no new query shape is introduced.

Note on indexing: the only index touching expires today is the composite @@index([budget_reset_at, expires]), which a filter on expires alone cannot use efficiently. The main win here is avoiding the full-table fetch into application memory. A dedicated single-column index on expires would make the filtered query itself index-friendly, but that is a schema migration with its own write-amplification tradeoff, so I left it out of this PR; happy to file a follow-up if we want it

Tests live in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py and cover the pure clause builder (exact lt/gte shape and NULL semantics for both values), the where-builder integration (filter applied for valid values, omitted for None or an unrecognized value, boundary computed at call time as tz-aware UTC), and the endpoint (400 on a typo, verbatim forwarding to the helper for expired/active/None, and the no-expires call still forwarding None so existing callers are unaffected). I ran a local mutation pass over the new logic (flipping lt to gte, dropping the NULL exclusion, weakening the validation and the value guard); each mutation was caught by a failing test

Add an opt-in expires query param to GET /key/list so callers can fetch
only expired or only active keys without paginating every page and
filtering client-side. 'expired' matches keys whose expires is in the
past (NULL expires excluded); 'active' matches keys that never expire or
expire in the future. Omitting the param preserves existing behavior for
every caller. An unrecognized value returns HTTP 400 rather than silently
returning all keys.

The filter is pushed to the database via the existing Prisma where
builder so callers avoid pulling the full key table into application
memory.

Resolves LIT-3387
@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an opt-in expires query parameter to GET /key/list, allowing callers to filter keys by expiration state (expired or active) at the database layer instead of pulling the entire key table into application memory. Omitting the parameter preserves existing behavior exactly, and any unrecognized value returns HTTP 400.

  • New _build_expires_where_clause helper produces the correct Prisma AND/OR predicates: expired excludes NULL-expires (never-expiring) keys and matches expires < now; active includes NULL-expires keys and matches expires >= now.
  • _build_key_filter_conditions / _list_key_helper each receive a new expires_filter: str | None = None parameter, plumbing the filter down to the existing where-clause builder without introducing a new query shape.
  • Tests cover clause shape, NULL semantics, UTC boundary, 400 on typos, correct forwarding for valid/None values, and the no-expires backward-compatibility case — all via mocks, no real network calls.

Confidence Score: 5/5

Safe to merge — the change is purely additive, the default behavior is unchanged, and invalid inputs are rejected with a 400.

The filter is pushed to the database via the existing Prisma where-clause builder with no new query shape. The VALID_EXPIRES_FILTER_VALUES frozenset guards both the endpoint and the helper. NULL semantics for never-expiring keys are correct and directly tested. The list_keys except handler converts HTTPException to ProxyException (ProxyException.init calls str(code)), so the test assertion code == '400' is valid. No pre-existing callers are affected.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/key_management_endpoints.py Adds expires query param to list_keys with validation, a new _build_expires_where_clause helper, and plumbs expires_filter through _build_key_filter_conditions and _list_key_helper. Logic is correct: invalid values raise HTTP 400 (converted to ProxyException by the existing except handler), NULL semantics for never-expiring keys are handled properly, and the double-guard in _build_key_filter_conditions provides defense-in-depth.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Adds 9 new tests covering the clause builder shape, where-builder integration, UTC boundary semantics, 400 on invalid values, and correct forwarding of the filter (including None for existing callers). All tests use mocks/patches — no real network calls, consistent with the rule for this test directory.
ui/litellm-dashboard/src/lib/http/schema.d.ts Adds `expires?: string

Reviews (2): Last reviewed commit: "refactor(proxy): declare VALID_EXPIRES_F..." | Re-trigger Greptile

Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py Outdated
Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py
@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai moved VALID_EXPIRES_FILTER_VALUES above list_keys so it's declared before its first use. The other note about unit tests running against a real Postgres instance isn't accurate; the clause tests exercise the pure _build_expires_where_clause / _build_key_filter_conditions functions with plain dict assertions, and the endpoint tests mock prisma_client and _list_key_helper, so nothing touches a database

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Both points check out against the diff. VALID_EXPIRES_FILTER_VALUES is declared at module level before list_keys, and the tests are all pure-unit: the clause-builder tests do plain dict assertions on _build_expires_where_clause / _build_key_filter_conditions, and the endpoint tests mock both prisma_client and _list_key_helper, so no database is involved.

The implementation looks solid — the filter is pushed into _build_key_filter_conditions behind the same guard pattern used for other filters, _build_expires_where_clause correctly handles the NULL-exclusion for expired and NULL-inclusion for active, and the double-validation (400 at the endpoint + in VALID_EXPIRES_FILTER_VALUES guard in the helper) is good defense-in-depth. No further concerns on my end.

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re review

@codspeed-hq

codspeed-hq Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit_3387_key_list_expires_filter (08e58a7) with litellm_internal_staging (f2fb6b8)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (0710cf2) during the generation of this report, so f2fb6b8 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@ryan-crabbe-berri
ryan-crabbe-berri merged commit cb24864 into litellm_internal_staging Jul 11, 2026
129 checks passed
@ryan-crabbe-berri
ryan-crabbe-berri deleted the litellm_lit_3387_key_list_expires_filter branch July 11, 2026 23:48
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.

2 participants