Repository navigation
feat(usage): aggregated, search and export routes for tag, org, customer and agent usage - #43141
Conversation
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
|
|
@greptileai review this PR |
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
@greptileai review this PR |
yuneng-berri
left a comment
There was a problem hiding this comment.
Org and agent search pass own_keys=None, so higher spend keys from other orgs can win over caller's key
| @@ -107,7 +119,44 @@ const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = { | |||
| // Single-shot endpoints returning the whole range in one response; entity types | |||
| // without one fall back to page-draining the paginated endpoint. | |||
| const ENTITY_AGGREGATED_FETCH_FNS: Partial<Record<EntityType, (...args: any[]) => Promise<any>>> = { | |||
There was a problem hiding this comment.
tag, org, customer and agent tabs used to show every key. Moving them to /aggregated now caps them at the top N
There was a problem hiding this comment.
Intentional, matching team since #41293: the cap is the fix for loading every key. The UI shows the truncation notice, search finds keys outside the top N, and export stays uncapped
There was a problem hiding this comment.
Agreed this is a behaviour change for these four tabs. Escalating to Yassin: keep the team-style top-N cap, or add server-side key paging so every key stays reachable
|
Re the own_keys=None search finding: fixed in d30e695. Key search now runs in SQL with an EXISTS on the scoped entity spend rows, so the LIMIT only ranks in-scope keys. Live proof in the description |
|
@greptileai review this PR |
yuneng-berri
left a comment
There was a problem hiding this comment.
tag, org, customer and agent tabs are still limited to the top-N keys
can you verify?
|
Verified live (Reviewer fix section): 121 keys, panel loads top 100 with the notice, search finds key 121, export returns all 361 rows. Same as team |
|
@greptileai review this PR (merged main into the branch at 3561007, no code changes beyond the merge) |
| @pytest.mark.asyncio | ||
| async def test_export_daily_drops_alias_when_alias_metadata_key_is_none( | ||
| _aggregated_postgresql: psycopg.Connection, |
There was a problem hiding this comment.
Database-backed test in mock-only directory
This new export test uses _aggregated_postgresql, a fixture that starts PostgreSQL, then writes and queries through that connection. Repository instructions allow only mocked tests under tests/test_litellm/. This requirement must be satisfied before merging.
Rule Used: What: prevent any tests from being added here that make real network calls - only mock tests can be added to this folder. Exception: tests/e2e folder, this folder can contain network calls. Why: ensure it runs correctly on github ci/cd + for all d... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…mer and agent usage Resolves LIT-8619 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
3561007 to
b6ac6d5
Compare
TLDR
Problem this solves:
How it solves it:
/{tag,organization,customer,agent}/daily/activity/aggregatedwith per-entity breakdown/.../aggregated/searchthat finds keys outside the top-N, scoped in the same query as auth/.../exportreturning uncapped CSV or JSON, plus hidden/end_user/...aliasesUser Flow
Before: an admin opening the Tag usage tab for a 60-key range waits for 18 sequential page loads and cannot search for a key that is not in the loaded slice
total_pages: 18, then walks all 18 pagesAfter: the same tab loads in one request, key search is server-side and export is uncapped
total_pages: 1, has_more: falsewith all 3 dayslit8619-key-059hits GET https://litellm-domain/tag/daily/activity/aggregated/search?search=lit8619-key-059 and returns exactly that keyAn internal user still cannot read customer usage (401), a non org-admin still gets 403 on someone else's organization, and a tag caller still only sees rows from their own keys
Linear ticket
Resolves LIT-8619
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)Screenshots / Proof of Fix
Setup shared by both runs: local proxy on
localhost:4000withlitellm/proxy/dev_config.yamlagainst a local Postgres, seeded with one entity per table (tag-lit8619,org-lit8619aliasRepro Org,cust-lit8619aliasRepro Customer,agent-lit8619aliasRepro Agent), 60 keyslit8619-key-000..059(tokensk-hash-lit8619-NNN) and 3 days (2026-09-20 to 2026-09-22) of daily spend rows, 180 rows per table.$MKis the master key.USAGE_TOP_API_KEYS_LIMITis the default 100. The same fixture SQL was applied before both runsBefore (efbb3ac)
Aggregated route
for e in tag organization customer agent; do curl -s -w "\n%{http_code}" -H "Authorization: Bearer $MK" "localhost:4000/$e/daily/activity/aggregated?start_date=2026-09-20&end_date=2026-09-22"; done{"detail":"Not Found"}404;/end_user/daily/activity/aggregatedalso404Key search
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $MK" "localhost:4000/$e/daily/activity/aggregated/search?search=lit8619-key-059&start_date=2026-09-20&end_date=2026-09-22"for each entity{"detail":"Not Found"}404Export
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $MK" "localhost:4000/$e/daily/activity/export?start_date=2026-09-20&end_date=2026-09-22&export_type=daily_with_keys&format=csv"for each entity{"detail":"Not Found"}404What the dashboard falls back to
curl -s -H "Authorization: Bearer $MK" "localhost:4000/tag/daily/activity?start_date=2026-09-20&end_date=2026-09-22&page=1&page_size=10" | python3 -c 'import json,sys; d=json.load(sys.stdin); m=d["metadata"]; print(m["total_pages"], m["has_more"], len(d["results"][0]["breakdown"]["api_keys"]))'18 True 10: the UI walks 18 pages of 10-key slices (about 32.6 KB each), and the same holds for organization, customer and agentAfter (c71e832, scoped search re-proven at 8390066)
The fixture for this run also has one extra tag row for a tag named
=HYPERLINK("x")on key 000, so the tag table holds 181 rowsAggregated route
for e in tag organization customer agent; do curl -s -o /tmp/$e.json -w "$e %{http_code} %{size_download}\n" -H "Authorization: Bearer $MK" "localhost:4000/$e/daily/activity/aggregated?start_date=2026-09-20&end_date=2026-09-22&timezone=0"; python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metadata"]; r=d["results"]; print(len(r), len(r[0]["breakdown"]["api_keys"]), m["total_api_keys"], m["total_pages"], m["has_more"], r[0]["breakdown"]["entities"])' /tmp/$e.json; done200, about 537 KB,3 60 60 1 Falseand the entities breakdown carries the fixture id with its alias metadata, for example{'org-lit8619': {'metadata': {'organization_alias': 'Repro Org'}, ...}}; one request instead of 18curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $MK" "localhost:4000/end_user/daily/activity/aggregated?start_date=2026-09-20&end_date=2026-09-22"gives200Key search
curl -s -H "Authorization: Bearer $MK" "localhost:4000/$e/daily/activity/aggregated/search?search=lit8619-key-059&start_date=2026-09-20&end_date=2026-09-22" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(sorted({k for r in d["results"] for k in r["breakdown"]["api_keys"]}))'for each entity['sk-hash-lit8619-059']search=nomatch-xyzgives200with"results": []org-other-lit8619with 120 keys aliasedshared-lit8619-NNNat high spend, and one keyshared-lit8619-minewith tiny spend underorg-lit8619.curl -s -H "Authorization: Bearer $MK" "localhost:4000/organization/daily/activity/aggregated/search?search=shared-lit8619&start_date=2026-09-20&end_date=2026-09-22&organization_ids=org-lit8619". Before d30e695 the 100-key Prismatakewas filled by the foreign keys and the call returnedresults: []; after, it returns exactly['sk-hash-shared-mine']Export
curl -s -D - -H "Authorization: Bearer $MK" "localhost:4000/$e/daily/activity/export?start_date=2026-09-20&end_date=2026-09-22&export_type=daily_with_keys&format=csv&timezone=0" -o /tmp/$e.csv; head -1 /tmp/$e.csv; tail -n +2 /tmp/$e.csv | wc -l200,Content-Disposition: attachment; filename="<entity>_usage_daily_with_keys_2026-09-25.csv",180data rows (60 keys x 3 days, no top-N cap; tag shows181because of the extra formula-named tag row). Headers:Date,Tag,Key Alias,Key ID,...for tag andDate,Organization,Organization ID,.../Date,Customer,Customer ID,.../Date,Agent,Agent ID,...for the othersformat=json:len(data) == 180,metadata.total_api_requests == 180,entity_aliaspopulated for org, customer and agent rows andnullfor taggrep HYPERLINK /tmp/tag.csvprints2026-09-20,"'=HYPERLINK(""x"")",...: requester-supplied entity ids are apostrophe-escaped so they cannot run as spreadsheet formulasAuthorization
curl -s -X POST -H "Authorization: Bearer $MK" -H "Content-Type: application/json" localhost:4000/key/generate -d '{"user_id":"lit8619-internal","user_role":"internal_user"}', then repeat the aggregated calls with that key/customer/daily/activity/aggregated401;/organization/daily/activity/aggregated?organization_ids=org-lit8619403{"detail":{"error":"User is not org_admin for Organization= org-lit8619."}};/tag/daily/activity/aggregated200with"results": [](its own key has no tag rows);/agent/daily/activity/aggregated401, identical to the existing/agent/daily/activitywith the same keycurl -s -w "\n%{http_code}" -H "Authorization: Bearer $MK" "localhost:4000/tag/daily/activity/aggregated?start_date=2020-01-01&end_date=2026-12-31"gives{"detail":{"error":"Date range must be at most 400 days"}}400Reviewer fix: scoped key search (d30e695, types tidied in 8390066)
What was flagged: the organization and agent key search passed
own_keys=None, so the verification-token lookup ordered every key on the proxy by lifetime spend and appliedtake=100before the entity filter. High-spend keys belonging to other organizations or agents could fill the limit and a visible key in the caller's own scope came back as no matchThe fix moves the search into one SQL query with an
EXISTSagainst the entity's daily spend table (build_daily_activity_key_search_sql), so entity ids, exclusions, date range and the tag internal-user key scope are applied beforeORDER BY spend LIMIT 100. Customer and tag search go through the same helperLive proof on two proxies against the same database:
:4000serves 8390066 and:4001serves the pre-fix tip c71e832. Fixture:org-other-lit8619holds 120 keys aliasedshared-lit8619-NNNwith high spend,org-lit8619holds 121 keys whereshared-lit8619-minehas the lowest spend, so it sits outside the top 100 and the dashboard has to ask the serverThe
keyslines aresorted({k for r in d["results"] for k in r["breakdown"]["api_keys"]})over the JSON. The export is uncapped (361 data rows, 121 keys x 3 days minus the one-dayminekey) and carries the key that search on the pre-fix tip could not findAdmin UI reproduction: open http://localhost:3000/usage/?tab=organization, pick Organization Usage, keep the default 7 day range, choose
Repro Orgin Filter by organization, open the Key Activity tab and typeshared-lit8619in Search keys. Both tips show the truncation noticeOnly the 100 highest-spend keys of 121 are loaded, which is what makes the panel call the search routeBefore (dashboard pointed at
:4001, pre-fix): the search comes back emptyAfter (dashboard pointed at
:4000, 8390066): the in-scope key is found and expands to its per-key metricsType
🆕 New Feature
🐛 Bug Fix
Caveats (if any)
Medium
Low
schema.d.tsis regenerated withnpm run gen:api, so the diff is large but mechanicalFinal Attestation
Link to Devin session: https://app.devin.ai/sessions/447fba239c454631bd3b7a6a747dbb54
Open in Devin Desktop: https://app.devin.ai/desktop/session/447fba239c454631bd3b7a6a747dbb54?variant=devin
Requested by: @yassin-berriai