Skip to content

feat(proxy): server-side global Usage export beyond the top-N key cap - #43138

Closed
devin-ai-integration[bot] wants to merge 2 commits into
litellm_lit_8618_model_top_keysfrom
litellm_user_daily_activity_export
Closed

devin-ai-integration[bot] wants to merge 2 commits into
litellm_lit_8618_model_top_keysfrom
litellm_user_daily_activity_export

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

How it solves it:

  • New GET /user/daily/activity/export, same scoping as /user/daily/activity/aggregated
  • One uncapped rollup query via the shared team export helpers, CSV or JSON
  • Global export modal downloads from that route whenever the key list was truncated
  • Global blocked-reason copy no longer mentions teams

Intentional product change: the global export modal now labels rows by user instead of team and drops the "by user" scope (the global table already is per user), so filenames become user_usage_* and the CSV entity columns read User / User ID

User Flow

Before: an admin with more keys than USAGE_TOP_API_KEYS_LIMIT cannot export global usage at all

  1. They open http://localhost:4000/ui/?page=usage on a proxy with 3 keys and USAGE_TOP_API_KEYS_LIMIT=2
  2. The page calls GET /user/daily/activity/aggregated and gets back api_key_limit: 2, total_api_keys: 3
  3. Export Data is greyed out with a tooltip saying only 2 of 3 keys loaded so a per-team export would under-report
  4. GET /user/daily/activity/export returns 404, there is no server-side alternative

Before: Export Data disabled with the per-team tooltip

After: the same admin exports every key from the server

  1. They open http://localhost:4000/ui/?page=usage on the same proxy and data
  2. The page still shows the capped aggregate, but Export Data is enabled
  3. Choosing "by key" and CSV downloads user_usage_daily_with_keys_<date>.csv from GET /user/daily/activity/export with all 3 keys
  4. A non-admin calling GET /user/daily/activity/export?user_id= gets 403, and without user_id gets only their own rows

After: Export Data enabled on the same page and data

After: export modal with the user scopes

Linear ticket

Resolves LIT-8617

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.) (CodeQL Analyze (python) fails on main too: LogInjection.ql result set over 2 GiB, reproduced on main head f4308bc)
  • 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

Setup: live proxy started from this branch with

LITELLM_MASTER_KEY=sk-lit8617proof USAGE_TOP_API_KEYS_LIMIT=2 \
  python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --use_v2_migration_resolver

against local Postgres. Three virtual keys (lit8617-k1..k3) each made one real anthropic/claude-haiku-4-5 chat completion through POST http://localhost:4000/v1/chat/completions (real provider spend), and the daily rollup flushed three rows into the user spend table

Before (origin/main 1e6c983, unfixed proxy on :4001 with the main dashboard on :3001, marker: /openapi.json has 0 hits for /user/daily/activity/export)

Admin UI global Usage export

  1. Log in at http://localhost:4000/ui/login with the master key, then open the dashboard dev server at http://localhost:3000/usage (Global Usage, default 7 day range)
  2. Export Data is disabled; hovering it shows "Only the 2 highest-spend keys of 3 were loaded, so a per-team export would under-report" (screenshot under User Flow, Before)

Aggregated response is capped

  1. curl -s "http://localhost:4000/user/daily/activity/aggregated?start_date=2026-09-26&end_date=2026-09-26&timezone=0" -H "Authorization: Bearer $MASTER_KEY" | jq '.metadata | {api_key_limit, total_api_keys}'
  2. { "api_key_limit": 2, "total_api_keys": 3 } and results[].breakdown.api_keys holds only 2 of the 3 digests

Server-side export

  1. No /user/daily/activity/export route is registered at this commit, so the UI has nothing to fall back to and stays disabled as shown above (the Before UI capture ran the dashboard from a main worktree against the same proxy and data)

After (4ac163c, this branch merged with current main, proxy on :4000 with the branch dashboard on :3000, marker: /openapi.json has 1 hit for /user/daily/activity/export)

Admin UI global Usage export

  1. Same login and http://localhost:3000/usage on the same data
  2. Export Data is enabled (screenshot under User Flow, After). Click it, pick "Day-by-day breakdown by user and key", keep CSV, click Export CSV
  3. The browser downloads user_usage_daily_with_keys_2026-09-26.csv (601 B) served by GET /user/daily/activity/export; it holds all 3 Key IDs while the Top Virtual Keys table only loaded 2

After: CSV downloaded from the server route

Aggregated response is capped

  1. Same curl as Before
  2. { "api_key_limit": 2, "total_api_keys": 3 }, unchanged

Server-side export

  1. curl -si "http://localhost:4000/user/daily/activity/export?start_date=2026-09-26&end_date=2026-09-26&timezone=0&export_type=daily_with_keys&format=csv" -H "Authorization: Bearer $MASTER_KEY"
  2. Output, all 3 keys present despite the cap of 2 (the integration test rows that share the table are cut for brevity; the columns match the client-side keyed export so the CSV shape does not change when the cap kicks in):
HTTP/1.1 200 OK
content-disposition: attachment; filename="user_usage_daily_with_keys_2026-09-26.csv"
content-type: text/csv; charset=utf-8

Date,User,User ID,Key Alias,Key ID,Spend ($),Requests,Successful Requests,Failed Requests,Total Tokens,Prompt Tokens,Completion Tokens,Cache Read Input Tokens,Cache Creation Input Tokens
2026-09-26,Unassigned,Unassigned,lit8617-k1,b65df54189220696ed0672aa81e5952c7090f57024c3b603d3429af0e8476373,0.0000,1,1,0,17,12,5,0,0
2026-09-26,Unassigned,Unassigned,lit8617-k2,ca53aafed50f18b2797637b996f0402217e71917102d94b566cc18f8ac926134,0.0000,1,1,0,16,12,4,0,0
2026-09-26,lit8617-member,lit8617-member,lit8617-k3,d1c335ebed5630004a97d7cb47bf37d778ba7f6ea6e66bbd7a63ff292eb5d2fe,0.0000,1,1,0,17,12,5,0,0
  1. format=json with export_type=daily returns 200 and a data row for lit8617-member
  2. export_type=daily_with_models&format=csv returns content-disposition: attachment; filename="user_usage_daily_with_models_2026-09-26.csv" and the row 2026-09-26,lit8617-member,lit8617-member,anthropic/claude-haiku-4-5,0.0000,1,1,0,17,12,5,0,0

Reviewer fix (yuneng-berri, 7f2d1fc): under the cap the User columns held team ids

What was flagged: under USAGE_TOP_API_KEYS_LIMIT the modal still exports client-side, and /user/daily/activity/aggregated returns no breakdown.entities, so the client rebuilt entities from breakdown.api_keys grouped by metadata.team_id. With the modal relabelled to users, the User and User ID columns showed team ids (or Unassigned for keys without a team). Fix commit 7f2d1fca8684e213a5f9772ff56a5db4df2764b5: the client groups by metadata.user_id when the entity is a user, and keeps grouping by team_id for every other entity. Regression tests cover both in utils.test.ts

Follow-up from Greptile on that commit, c44da4394c and ccd3956570: the grouping map is now Object.create(null), so user ids that collide with object properties such as constructor or __proto__ are exported as ordinary rows instead of crashing or being dropped, with a regression test in utils.test.ts

Live proof on the running proxy (USAGE_TOP_API_KEYS_LIMIT=100, so the aggregated response is not truncated and the export stays client-side). Setup, as admin: POST /team/new with team_id: lit8617-team and member lit8617-member, POST /key/generate with team_id: lit8617-team, user_id: lit8617-member, key_alias: lit8617-team-key, one POST /v1/chat/completions on anthropic-haiku-4-5 with that key (real Anthropic call). GET /user/daily/activity/aggregated?start_date=2026-09-26&end_date=2026-09-26 then shows that key with metadata: {team_id: lit8617-team, user_id: lit8617-member} and an empty breakdown.entities

Reproduce in the Admin UI: http://localhost:3000/usage/, Global Usage, Export Data, Day-by-day breakdown by user and key, CSV, Export CSV. The raw rows below are from the two downloaded CSVs filtered to the lit8617 keys

Before (6a6de8c): the team key is attributed to lit8617-team, and the member's team-less key to Unassigned, so one user shows up as two rows under two wrong identities

After (7f2d1fc): both keys roll up under lit8617-member

Raw rows from the two downloaded user_usage_daily_with_keys_2026-09-26.csv files, grep lit8617-team-key:

before: 2026-09-26,lit8617-team,lit8617-team,lit8617-team-key,622b3dbdfee2a530b694e8547d23c72739fda3d3a281704f260e7d4a0e7ac79e,0.0000,1,1,0,17,12,5,0,0
after:  2026-09-26,lit8617-member,lit8617-member,lit8617-team-key,622b3dbdfee2a530b694e8547d23c72739fda3d3a281704f260e7d4a0e7ac79e,0.0000,1,1,0,17,12,5,0,0
after:  2026-09-26,lit8617-member,lit8617-member,lit8617-member-key,967af931464648b78673f332e455be307c82026634b006b603a1718263eb41bb,0.0000,1,1,0,18,14,4,0,0

The plain Day-by-day breakdown by user export on the fixed head gives one row per user, for example 2026-09-26,integration-d41ac2351e0d47ac99b5717fc6225366,integration-d41ac2351e0d47ac99b5717fc6225366,0.1800,3,3,0,120,60,60,0,0

Non-admin scoping

  1. POST /user/new with user_role: internal_user created lit8617-member, /key/generate minted $MEMBER_KEY under it and one chat completion was made with it
  2. curl -s "http://localhost:4000/user/daily/activity/export?start_date=2026-09-26&end_date=2026-09-26&timezone=0&export_type=daily&format=json" -H "Authorization: Bearer $MEMBER_KEY" returns 200 with metadata.user_id: "lit8617-member" and a single row for that user
  3. The same call with &user_id=lit8617-other returns 403 {"detail":{"error":"Non-admin users can only view their own spend data."}}
  4. In the dashboard the server export passes the user shown on screen (the admin's user filter, or the signed-in user under My Usage) as user_id, so a scoped view exports the same scope it displays

Lint fix on the restacked head (b67a956): user-label lookup chunked

CI code-quality flagged the new _resolve_export_users lookup as an unbounded Prisma in filter over every exported user id. It now goes through find_many_in from litellm/repositories/chunked_in.py, the same chunking helper the rest of the repo uses, so a large export cannot exceed the Postgres parameter limit. Behaviour is unchanged; the unbounded-IN checker reports 0 new, and the export tests in test_internal_user_endpoints.py still pass

Live re-verify on this head (proxy from this branch, USAGE_TOP_API_KEYS_LIMIT=2, three users lit8617-u1..u3 each with one daily row):

curl -s "http://localhost:4000/user/daily/activity/export?start_date=2026-09-27&end_date=2026-09-27&timezone=0&export_type=daily_with_keys&format=csv" -H "Authorization: Bearer $MASTER_KEY"

Date,User,User ID,Key Alias,Key ID,Spend ($),Requests,Successful Requests,Failed Requests,Total Tokens,Prompt Tokens,Completion Tokens,Cache Read Input Tokens,Cache Creation Input Tokens
2026-09-27,u1@example.com,lit8617-u1,-,hk1,0.0100,1,1,0,15,10,5,0,0
2026-09-27,u2@example.com,lit8617-u2,-,hk2,0.0200,1,1,0,15,10,5,0,0
2026-09-27,u3@example.com,lit8617-u3,-,hk3,0.0300,1,1,0,15,10,5,0,0

All three keys are returned past the cap of 2 and the User column resolves emails through the chunked lookup

Taxonomy audit (current diff against the base, re-run on b67a956)

Fixed on this head: B-style unbounded query (the user-label in list is now chunked through find_many_in). Fixed earlier in this PR: A1-style grouping identity (client export grouped users by team id, now by metadata.user_id), a prototype-key collision in the grouping map (Object.create(null)), and CSV formula injection on identifier columns (csv_safe)

Judged not applicable with the reason: D2/Z7 (entity id is resolved through _resolve_user_daily_activity_entity_id, the same helper the aggregated route uses, so admin and non-admin scoping cannot diverge); X1/E5 (no or fallback on spend, token or request counts, only on nullable labels where empty means absent); W1 (the route builds new tuples and dicts, it does not mutate the caller's request or rows); F3/O4 (sibling team export already exists from #42996 and shares get_daily_activity_export_rows, the tag and org usage pages have no export modal); C1-C7 (new route only, no default flips or status code changes on existing routes; the modal relabel from team to user is called out under TLDR); H1-H10 (no new source comments, no em dashes in copy, X | None typing, no Any in new backend code, schema.d.ts regenerated, and the dashboard prebuilt bundle is produced by the release build, not committed); T1-T5 (integration tests assert the composed CSV rows and the 403 body, and test_user_activity_export_returns_every_key_beyond_the_top_n_cap fails on main because the route 404s)

Type

🆕 New Feature

Caveats (if any)

Low

  • Master-key traffic has no user, so its rows export as User ID: Unassigned
  • Keyed and per-model exports exclude the PTU flat-cost sentinel, same as the team route
  • The User column is the user's email, else alias, else the user id, looked up from the user table in one extra query per export; JSON carries user_email and user_alias as separate fields
  • Under the cap the client-side export only has key metadata (user_id, no email), so its User column falls back to the user id

Link to Devin session: https://app.devin.ai/sessions/f364d9a3f7e54d91a11d089b02390304
Open in Devin Desktop: https://app.devin.ai/desktop/session/f364d9a3f7e54d91a11d089b02390304?variant=devin
Requested by: @yassin-berriai

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

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

@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Adds server-side export endpoint for user spending data.

The PR appears safe to merge; no outstanding finding or new blocking issue was identified.

Summary

The PR adds an uncapped, user-scoped server export for Global Usage and routes truncated dashboard exports through it.

  • Supports CSV and JSON daily, keyed, and per-model reports.
  • Updates the dashboard’s user labels and export tests.
  • The latest change batches user-label lookups for large exports.

Reviews (9) · Last reviewed commit: "fix(proxy): chunk the export user-label ..."

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

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.89209% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...tellm/proxy/management_endpoints/team_endpoints.py 40.90% 13 Missing ⚠️
...xy/management_endpoints/internal_user_endpoints.py 87.30% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_user_daily_activity_export (b67a956) with litellm_lit_8618_model_top_keys (317cc1e)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_lit_8618_model_top_keys (5d777c1) during the generation of this report, so c38b7fc was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/management_endpoints/internal_user_endpoints.py Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/management_endpoints/internal_user_endpoints.py Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

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

looks like the user and user_id columns are filled in with team_ids under the cap

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@yuneng-berri good catch, fixed in 7f2d1fc: the client-side export now groups keys by user_id for user exports. Under-cap CSV proof is in the PR body

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/management_endpoints/internal_user_endpoints.py Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review the current head 4ac163c (merge of main, no source changes since ccd3956)

Resolves LIT-8617

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_user_daily_activity_export branch from 4ac163c to c5f6886 Compare September 27, 2026 00:11
@devin-ai-integration
devin-ai-integration Bot changed the base branch from main to litellm_lit_8618_model_top_keys September 27, 2026 00:11
@devin-ai-integration
devin-ai-integration Bot added this pull request to stack #43387 September 27, 2026 00:11
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review the current head b67a956 (restacked on #43137, plus the user-label lookup now chunked through find_many_in)

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Superseded by the redesigned bounded-usage stack: repository #43398, routes #43408, UI #43409.

This branch is waiting to be deployed

1 waiting deployment
e2e-changed — b67a956e Waiting Sep 27, 2026 by devin-ai-integration[bot] via oauth #1529
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.

3 participants