Repository navigation
feat(keys): filter /key/list by active, expired, revoked or deleted status and serve deleted keys from /key/info - #41311
Conversation
…tatus and serve deleted keys from /key/info Persist and expose the lifecycle of API keys so spend, audit and FinOps workflows can still resolve a key after it is revoked, expires or is deleted. /key/list?status= now accepts active, expired and revoked next to the existing deleted value. revoked means blocked=true, expired means not blocked with a past expiry, active is the rest, so the three values partition the live key table. deleted keeps reading the LiteLLM_DeletedVerificationToken archive. /key/info falls back to that archive when the key is no longer in the live table, running the same owner/team/org authorization check, and every response now carries a derived status field. The hashed token is still stripped. The Virtual Keys page gets a Status filter (URL-persisted) and a Deleted badge that shows when and by whom the key was deleted. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
|
Greptile SummaryThis PR extends virtual-key lifecycle visibility across the management API and dashboard.
Confidence Score: 5/5The PR appears safe to merge, with no outstanding correctness, security, or repository-rule findings. The post-review change only synchronizes the generated TypeScript schema with the archived-token model and introduces no runtime behavior. The direct-query finding was conceded, while the behavioral-test and redundant-comments threads are resolved; the redundant-comments thread was manually resolved without explanation. Important Files Changed
Reviews (4): Last reviewed commit: "chore(ui): regenerate schema.d.ts for or..." | Re-trigger Greptile |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…us through the endpoint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
… records Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
There was a problem hiding this comment.
✅ 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 d8ef940. Configure here.
… /key/info PR #41311 made /key/info serve deleted keys from the LiteLLM_DeletedVerificationToken archive with status "deleted", so the harness cleanup's 404 expectation now fails every scenario in the management and providers lanes. The cleanup now checks the live row is gone and that /key/info reports the key as deleted
…iAI#39996 and BerriAI#41310 Every one of these fails on main's own scheduled CircleCI run with the same assertion as on any PR, and each traces to a merged behavior change that never updated the test that pinned the old behavior - tests/integration/_support/client.py: BerriAI#41311 made /key/info serve deleted keys from the archive with status deleted, so the scenario teardown asserts the live row is gone and the readback reports deleted instead of a 404. This alone accounts for nine integration-management and one integration-providers failure - tests/integration/authorization/test_warmed_policy.py: BerriAI#39996 made team admins unable to edit any team field unless a proxy admin allow-lists it, and tpm_limit is the only field it accepts today. The demotion test now enables tpm_limit for the scenario and edits that instead of team_alias - tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py: BerriAI#41337 reads usage off the terminal response and copies the event when it is missing, which a Mock(spec=ResponsesAPIResponse) cannot survive. The four mocks now carry a usage object - tests/test_openai_endpoints.py: BerriAI#41310 lengthened the access-denied message, and the test matched against the ExceptionInfo repr, which saferepr truncates in the middle. It now matches the exception text - tests/local_testing/test_text_completion.py: Together no longer serves Qwen2-1.5B serverless, the cheapest cost-map row. The test mocks the completions call and asserts the request litellm builds, so a vendor catalog rotation cannot fail it again test_router_fallbacks_with_cooldowns_and_dynamic_credentials is deliberately untouched: it passes and fails on main with identical code, and the failing path is a product question about whether dynamic-credential 429s cool down
TLDR
Problem this solves:
/key/list?status=only accepteddeleted;active,expired,revokedreturned 400/key/infoon a deleted key returned 404, so spend and audit lookups broke/key/infonever said whether a key was active, expired or revokedHow it solves it:
statusacceptsactive,expired,revoked(blocked) ordeleted; the first three partition the live table/key/infofalls back to the deleted-keys archive with the same owner/team/org authorization/key/inforesponse carries a derivedstatusfield; the hashed token stays strippedorganization_id, both when a key is deleted and when/key/inforeads it backUser Flow
Before: a FinOps admin cannot list revoked keys or look up a key that was deleted last month
Invalid status value. Currently only 'deleted' is supported.Key not found in database, even though spend logs still reference that keyblocked: truebut no status fieldAfter: the same admin filters keys by status and resolves the deleted key
status=activeandstatus=expiredwork the same way, and an unknown value still returns 400info.status: "deleted",info.deleted_at,info.deleted_byand the key's alias, spend, budget andorganization_idinfo.status: "revoked"filter_status=deleted/key/info, exactly as for a live key they do not ownRelevant issues
Affected release
Linear ticket
Resolves LIT-1650
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)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
Shared setup: a real proxy per side (
python litellm/proxy/proxy_cli.py --config config.yaml --use_prisma_db_push, master keysk-1234) against one Postgres, base on port 4001 and head on port 4000. Fixture keys for userlit1650-user:lit1650-active,lit1650-expired(duration: 1s),lit1650-revoked(thenPOST /key/block),lit1650-deleted(thenPOST /key/delete), plus an internal-user key that owns them ($OWNER_USER_KEY) and an unrelated internal-user key ($OTHER_USER_KEY).$ACTIVE_HASH,$REVOKED_HASH,$EXPIRED_HASH,$DELETED_HASHare thetoken_idvalues/key/generatereturned.H='Authorization: Bearer sk-1234'Before (79d4d4d)
/key/list status filter
for s in active expired revoked deleted; do curl -s "localhost:4001/key/list?status=$s&user_id=lit1650-user&return_full_object=true" -H "$H" | jq -c '{total_count, aliases: [.keys[]?.key_alias], error: .error.message}'; donecurl -s -w ' %{http_code}\n' "localhost:4001/key/list?status=bogus" -H "$H" | jq -c '.error.message'->400 "{'error': \"Invalid status value. Currently only 'deleted' is supported.\"}"/key/info on deleted, revoked and expired keys
curl -s "localhost:4001/key/info?key=$DELETED_HASH" -H "$H" | jq -c '{error: .error.message, status: .info.status, key_alias: .info.key_alias, deleted_at: .info.deleted_at, token: .info.token}'{"error":"Key not found in database","status":null,"key_alias":null,"deleted_at":null,"token":null}(HTTP 404)curl -s "localhost:4001/key/info?key=$REVOKED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias, blocked: .info.blocked}'{"status":null,"key_alias":"lit1650-revoked","blocked":true}curl -s "localhost:4001/key/info?key=$EXPIRED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias}'{"status":null,"key_alias":"lit1650-expired"}Authorization on the archived key
curl -s -w ' %{http_code}\n' "localhost:4001/key/info?key=$DELETED_HASH" -H "Authorization: Bearer $OTHER_USER_KEY" | jq -c '.error.message'->404 "Key not found in database"curl -s -w ' %{http_code}\n' "localhost:4001/key/info?key=$DELETED_HASH" -H "Authorization: Bearer $OWNER_USER_KEY" | jq -c '.error.message'->404 "Key not found in database"curl -s -w ' %{http_code}\n' "localhost:4001/key/list?status=revoked&return_full_object=true" -H "Authorization: Bearer $OWNER_USER_KEY"->400Untouched /key/list paths (regression sweep)
ab_probe.shon port 4001: no-status listtotal_count 6,expires=active5,expires=expired1,key_alias=lit1650-revoked1,search=lit16507,page=1&size=2&sort_by=created_at&sort_order=asc->total_pages 3with["lit1650-active","lit1650-expired"],/key/infoon the active key returns 51 fields with notokenVirtual Keys page
After (3e8566d)
Commit d8ef940 on top of this only regenerates
schema.d.tswithnpm run gen:api, so it cannot change behavior and the run above still stands/key/list status filter
for s in active expired revoked deleted; do curl -s "localhost:4000/key/list?status=$s&user_id=lit1650-user&return_full_object=true" -H "$H" | jq -c '{total_count, aliases: [.keys[]?.key_alias], error: .error.message}'; donecurl -s -w ' %{http_code}\n' "localhost:4000/key/list?status=bogus" -H "$H" | jq -c '.error.message'->400 "{'error': \"Invalid status value. Supported: 'active', 'expired', 'revoked', 'deleted'.\"}"curl -s "localhost:4000/key/list?user_id=lit1650-user&status=active&expires=expired&return_full_object=true" -H "$H" | jq -c '{total_count}'->200 {"total_count":0}(contradictory filters AND together instead of erroring)/key/info on deleted, revoked and expired keys
curl -s "localhost:4000/key/info?key=$DELETED_HASH" -H "$H" | jq -c '{error: .error.message, status: .info.status, key_alias: .info.key_alias, deleted_at: .info.deleted_at, token: .info.token}'{"error":null,"status":"deleted","key_alias":"lit1650-deleted","deleted_at":"2026-09-15T21:20:55.764000+00:00","token":null}(HTTP 200,deleted_by: "default_user_id")curl -s "localhost:4000/key/info?key=$REVOKED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias, blocked: .info.blocked}'{"status":"revoked","key_alias":"lit1650-revoked","blocked":true}curl -s "localhost:4000/key/info?key=$EXPIRED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias}'{"status":"expired","key_alias":"lit1650-expired"}curl -s "localhost:4000/key/info?key=sk-does-not-exist" -H "$H" | jq -c '.error.message'->404 "Key not found in database"Organization on archived keys
ORG=$(curl -s localhost:4000/organization/new -H "$H" -H 'Content-Type: application/json' -d '{"organization_alias":"lit1650-org"}' | jq -r .organization_id), then/key/generatewith"organization_id": "$ORG"and aliaslit1650-org-live, thenPOST /key/deleteon that keycurl -s "localhost:4000/key/info?key=$ORG_KEY" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias, organization_id: .info.organization_id}'{"status":"deleted","key_alias":"lit1650-org-live","organization_id":"fb89b5ac-83d5-40b2-b7a5-31200de76bf3"}organization_idmissing from the response andNULLin the archive row: the archive Pydantic model only kneworg_id, so the live row'sorganization_idwas dropped both on write and on readAuthorization on the archived key
curl -s -w ' %{http_code}\n' "localhost:4000/key/info?key=$DELETED_HASH" -H "Authorization: Bearer $OTHER_USER_KEY" | jq -c '.error.message'->403 "You are not allowed to access this key's info. Your role=internal_user"curl -s -w ' %{http_code}\n' "localhost:4000/key/info?key=$DELETED_HASH" -H "Authorization: Bearer $OWNER_USER_KEY" | jq -c '{status: .info.status, key_alias: .info.key_alias}'->200 {"status":"deleted","key_alias":"lit1650-deleted"}curl -s -w ' %{http_code}\n' "localhost:4000/key/list?status=revoked&return_full_object=true" -H "Authorization: Bearer $OWNER_USER_KEY" | jq -c '{total_count, aliases: [.keys[]?.key_alias]}'->200 {"total_count":1,"aliases":["lit1650-revoked"]}(own keys only)Untouched /key/list paths (regression sweep)
ab_probe.shon port 4000: byte-identical to the base for no-status list (6),expires=active(5),expires=expired(1), exact alias (1),search=lit1650(7), pagination (total_pages 3, same first page)./key/infoon the active key returns the same 51 fields plusstatus: "active", still notokenVirtual Keys page
/api-keys/?filter_status=deleted, the table showslit1650-deletedwith a Deleted badge/api-keys/?filter_status=revoked, the table showslit1650-revokedwith its Blocked badgeRegression tests:
uv run pytest tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py(558 passed) andnpx vitest run src/components/VirtualKeysPage/VirtualKeysTable.test.tsx(68 passed). With the backend change reverted, 14 of the new backend tests fail; with the archive model change reverted, the twoorganization_idtests failType
🆕 New Feature
Caveats (if any)
Medium
status=active|expired|revokedand the olderexpires=active|expiredfilter AND together, sostatus=active&expires=expiredreturns an empty page instead of 400Low
/key/inforesponses gain astatusfield; clients that reject unknown fields will need to allow itStatus = Deleted, since the unfiltered list still shows live keys onlydeleted_keys_teams.md) do not yet mention the new status values; follow-up in litellm-docsFinal Attestation
ran /live-pr-risk and found no regressions/backward incompatible risks
Link to Devin session: https://app.devin.ai/sessions/5d136c4d79564fe99fed6744c4c1a35f
Open in Devin Desktop: https://app.devin.ai/desktop/session/5d136c4d79564fe99fed6744c4c1a35f?variant=devin
Requested by: @yassin-berriai
Note
Medium Risk
Changes key listing, lookup, and authorization paths for live and archived tokens; behavior is tested but affects finops/audit flows on management APIs.
Overview
Extends virtual key management so admins can filter and inspect keys by lifecycle status, not only live rows or a separate
deletedlist.API:
/key/listnow acceptsstatus=active,expired,revoked, ordeleted. The first three apply Prisma filters on the live table (blocked/expires);deletedstill reads the archive./key/infolooks up the live row first, then the deleted-keys archive (newest bydeleted_at), with the same access checks. Responses include a derivedstatusfield (active|expired|revoked|deleted); archived rows also exposedeleted_at/deleted_by.organization_idis preserved onLiteLLM_DeletedVerificationTokenwhen keys are archived and when info is returned.Dashboard: Virtual Keys adds a URL-backed Status filter (
filter_status) wired to/key/list, and shows a Deleted badge (with audit tooltip) whendeleted_atis present.Reviewed by Cursor Bugbot for commit d8ef940. Bugbot is set up for automated code reviews on this repo. Configure here.