Skip to content

feat(keys): filter /key/list by active, expired, revoked or deleted status and serve deleted keys from /key/info - #41311

Merged
yassin-berriai merged 4 commits into
mainfrom
litellm_key_status_filter_deleted_lookup
Sep 16, 2026
Merged

yassin-berriai merged 4 commits into
mainfrom
litellm_key_status_filter_deleted_lookup

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • /key/list?status= only accepted deleted; active, expired, revoked returned 400
  • /key/info on a deleted key returned 404, so spend and audit lookups broke
  • /key/info never said whether a key was active, expired or revoked
  • The Virtual Keys page had no status filter and no Deleted badge

How it solves it:

  • status accepts active, expired, revoked (blocked) or deleted; the first three partition the live table
  • /key/info falls back to the deleted-keys archive with the same owner/team/org authorization
  • Every /key/info response carries a derived status field; the hashed token stays stripped
  • Archived key records keep organization_id, both when a key is deleted and when /key/info reads it back
  • Virtual Keys page gets a URL-persisted Status filter and a Deleted badge with when/by whom

User Flow

Before: a FinOps admin cannot list revoked keys or look up a key that was deleted last month

  1. They send GET https://litellm-domain/key/list?status=revoked with the admin key
  2. They get 400 Invalid status value. Currently only 'deleted' is supported.
  3. They send GET https://litellm-domain/key/info?key=
  4. They get 404 Key not found in database, even though spend logs still reference that key
  5. They send GET https://litellm-domain/key/info?key= and the response has blocked: true but no status field
  6. They open https://litellm-domain/ui/?page=api-keys, click Filters, and see Team, Organization, User ID and Key ID only

After: the same admin filters keys by status and resolves the deleted key

  1. They send GET https://litellm-domain/key/list?status=revoked with the admin key
  2. They get 200 with only the blocked keys; status=active and status=expired work the same way, and an unknown value still returns 400
  3. They send GET https://litellm-domain/key/info?key=
  4. They get 200 with info.status: "deleted", info.deleted_at, info.deleted_by and the key's alias, spend, budget and organization_id
  5. They send GET https://litellm-domain/key/info?key= and the response now has info.status: "revoked"
  6. They open https://litellm-domain/ui/?page=api-keys, click Filters, pick Status = Deleted, and the table shows the archived key with a Deleted badge; the filter is kept in the URL as filter_status=deleted
  7. Another internal user who does not own the deleted key still gets 403 from /key/info, exactly as for a live key they do not own

Relevant issues

Affected release

Linear ticket

Resolves LIT-1650

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, e.g. 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
  • 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 (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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 key sk-1234) against one Postgres, base on port 4001 and head on port 4000. Fixture keys for user lit1650-user: lit1650-active, lit1650-expired (duration: 1s), lit1650-revoked (then POST /key/block), lit1650-deleted (then POST /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_HASH are the token_id values /key/generate returned. H='Authorization: Bearer sk-1234'

Before (79d4d4d)

/key/list status filter

  1. 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}'; done
  2. Observed:
    {"total_count":null,"aliases":[],"error":"{'error': \"Invalid status value. Currently only 'deleted' is supported.\"}"}
    {"total_count":null,"aliases":[],"error":"{'error': \"Invalid status value. Currently only 'deleted' is supported.\"}"}
    {"total_count":null,"aliases":[],"error":"{'error': \"Invalid status value. Currently only 'deleted' is supported.\"}"}
    {"total_count":1,"aliases":["lit1650-deleted"],"error":null}
    
  3. curl -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

  1. 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}'
  2. Observed: {"error":"Key not found in database","status":null,"key_alias":null,"deleted_at":null,"token":null} (HTTP 404)
  3. curl -s "localhost:4001/key/info?key=$REVOKED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias, blocked: .info.blocked}'
  4. Observed: {"status":null,"key_alias":"lit1650-revoked","blocked":true}
  5. curl -s "localhost:4001/key/info?key=$EXPIRED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias}'
  6. Observed: {"status":null,"key_alias":"lit1650-expired"}

Authorization on the archived key

  1. 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"
  2. 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"
  3. curl -s -w ' %{http_code}\n' "localhost:4001/key/list?status=revoked&return_full_object=true" -H "Authorization: Bearer $OWNER_USER_KEY" -> 400

Untouched /key/list paths (regression sweep)

  1. Same ab_probe.sh on port 4001: no-status list total_count 6, expires=active 5, expires=expired 1, key_alias=lit1650-revoked 1, search=lit1650 7, page=1&size=2&sort_by=created_at&sort_order=asc -> total_pages 3 with ["lit1650-active","lit1650-expired"], /key/info on the active key returns 51 fields with no token

Virtual Keys page

  1. Open http://localhost:3000/api-keys/ (dashboard dev server pointed at the proxy), click Filters
  2. Observed: Team, Organization, User ID and Key ID only, no Status field

before: filters drawer without Status

After (3e8566d)

Commit d8ef940 on top of this only regenerates schema.d.ts with npm run gen:api, so it cannot change behavior and the run above still stands

/key/list status filter

  1. 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}'; done
  2. Observed:
    {"total_count":4,"aliases":["lit1650-owner-login",null,"lit1650-owner-key","lit1650-active"],"error":null}
    {"total_count":1,"aliases":["lit1650-expired"],"error":null}
    {"total_count":1,"aliases":["lit1650-revoked"],"error":null}
    {"total_count":3,"aliases":["lit1650-org-live","lit1650-org-deleted","lit1650-deleted"],"error":null}
    
    (4 + 1 + 1 = 6, the same 6 live keys the unfiltered list returns, so the three live statuses partition the table; the two extra archived keys are the organization fixtures below)
  3. curl -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'.\"}"
  4. 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

  1. 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}'
  2. Observed: {"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")
  3. curl -s "localhost:4000/key/info?key=$REVOKED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias, blocked: .info.blocked}'
  4. Observed: {"status":"revoked","key_alias":"lit1650-revoked","blocked":true}
  5. curl -s "localhost:4000/key/info?key=$EXPIRED_HASH" -H "$H" | jq -c '{status: .info.status, key_alias: .info.key_alias}'
  6. Observed: {"status":"expired","key_alias":"lit1650-expired"}
  7. 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

  1. 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/generate with "organization_id": "$ORG" and alias lit1650-org-live, then POST /key/delete on that key
  2. curl -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}'
  3. Observed: {"status":"deleted","key_alias":"lit1650-org-live","organization_id":"fb89b5ac-83d5-40b2-b7a5-31200de76bf3"}
  4. At 9541b07 (previous tip) the same steps gave organization_id missing from the response and NULL in the archive row: the archive Pydantic model only knew org_id, so the live row's organization_id was dropped both on write and on read

Authorization on the archived key

  1. 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"
  2. 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"}
  3. 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)

  1. Same ab_probe.sh on 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/info on the active key returns the same 51 fields plus status: "active", still no token

Virtual Keys page

  1. Open http://localhost:3000/api-keys/, click Filters
  2. Observed: a Status select with Active, Expired, Revoked (blocked), Deleted

after: Status select in the filters drawer

  1. Pick Deleted. URL becomes /api-keys/?filter_status=deleted, the table shows lit1650-deleted with a Deleted badge

after: Deleted filter and badge

  1. Pick Revoked (blocked). URL becomes /api-keys/?filter_status=revoked, the table shows lit1650-revoked with its Blocked badge

after: Revoked filter

Regression tests: uv run pytest tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py (558 passed) and npx 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 two organization_id tests fail

Type

🆕 New Feature

Caveats (if any)

Medium

  • status=active|expired|revoked and the older expires=active|expired filter AND together, so status=active&expires=expired returns an empty page instead of 400

Low

  • /key/info responses gain a status field; clients that reject unknown fields will need to allow it
  • The Deleted badge on the Virtual Keys page only appears under Status = Deleted, since the unfiltered list still shows live keys only
  • User docs (deleted_keys_teams.md) do not yet mention the new status values; follow-up in litellm-docs

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

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 deleted list.

API: /key/list now accepts status=active, expired, revoked, or deleted. The first three apply Prisma filters on the live table (blocked / expires); deleted still reads the archive. /key/info looks up the live row first, then the deleted-keys archive (newest by deleted_at), with the same access checks. Responses include a derived status field (active | expired | revoked | deleted); archived rows also expose deleted_at / deleted_by. organization_id is preserved on LiteLLM_DeletedVerificationToken when 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) when deleted_at is present.

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

…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-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

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 sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@codspeed

codspeed Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_key_status_filter_deleted_lookup (d8ef940) with main (0b3e564)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends virtual-key lifecycle visibility across the management API and dashboard.

  • Adds active, expired, revoked, and deleted filtering to /key/list.
  • Allows authorized /key/info lookups for archived keys and returns a derived lifecycle status.
  • Preserves archived keys’ organization_id.
  • Adds a URL-persisted dashboard status filter and deleted-key badge.
  • Regenerates the TypeScript API schema to expose the archived-token organization_id.

Confidence Score: 5/5

The 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
Filename Overview
litellm/models/verification_token.py Adds organization_id to archived verification-token validation so organization ownership survives archival.
litellm/proxy/management_endpoints/key_management_endpoints.py Implements lifecycle filtering, archived-key lookup with existing authorization, and derived status reporting.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Covers status filtering, archived-key retrieval and authorization, organization preservation, and status derivation.
ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx Adds validated, URL-backed lifecycle status filtering to the Virtual Keys table.
ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx Displays archived keys with a deleted badge and audit tooltip.
ui/litellm-dashboard/src/lib/http/schema.d.ts Regenerates the API declaration to include archived tokens’ organization_id; this is the sole post-review change.

Reviews (4): Last reviewed commit: "chore(ui): regenerate schema.d.ts for or..." | Re-trigger Greptile

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

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

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>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

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.

Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

… records

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@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.

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

@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 d8ef940. Configure here.

@yassin-berriai
yassin-berriai merged commit 2bfa10b into main Sep 16, 2026
91 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_key_status_filter_deleted_lookup branch September 16, 2026 21:48
yuneng-berri added a commit that referenced this pull request Sep 16, 2026
… /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
yuneng-berri added a commit that referenced this pull request Sep 17, 2026
leilei3167 pushed a commit to leilei3167/litellm that referenced this pull request Sep 17, 2026
…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
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.

5 participants