Skip to content

feat(usage): aggregated, search and export routes for tag, org, customer and agent usage - #43141

Closed
devin-ai-integration[bot] wants to merge 1 commit into
litellm_user_daily_activity_exportfrom
litellm_entity_usage_aggregated_routes
Closed

devin-ai-integration[bot] wants to merge 1 commit into
litellm_user_daily_activity_exportfrom
litellm_entity_usage_aggregated_routes

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Tag, Organization, Customer and Agent usage tabs drain every raw daily row page by page
  • Only Team has aggregated, key search and export routes
  • Key search and export on those tabs are limited to the top-N keys already in the browser

How it solves it:

  • Adds /{tag,organization,customer,agent}/daily/activity/aggregated with per-entity breakdown
  • Adds /.../aggregated/search that finds keys outside the top-N, scoped in the same query as auth
  • Adds /.../export returning uncapped CSV or JSON, plus hidden /end_user/... aliases
  • Wires the dashboard so all five entity tabs use the single-shot routes
  • Reuses each route's existing authorization (tag key scoping, org admin, proxy admin, agent access)

User 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

  1. They open https://litellm-domain/ui/?page=new_usage and click the Tag Usage tab for 2026-09-20 to 2026-09-22
  2. The browser hits GET https://litellm-domain/tag/daily/activity?page=1&page_size=10 and sees total_pages: 18, then walks all 18 pages
  3. Typing a key alias in the key search box only filters the keys already loaded
  4. GET https://litellm-domain/tag/daily/activity/aggregated and GET https://litellm-domain/tag/daily/activity/export both answer 404 Not Found; the same is true for organization, customer and agent

After: the same tab loads in one request, key search is server-side and export is uncapped

  1. They open https://litellm-domain/ui/?page=new_usage and click the Tag Usage tab for the same range
  2. The browser hits GET https://litellm-domain/tag/daily/activity/aggregated?start_date=2026-09-20&end_date=2026-09-22 once and gets total_pages: 1, has_more: false with all 3 days
  3. Typing lit8619-key-059 hits GET https://litellm-domain/tag/daily/activity/aggregated/search?search=lit8619-key-059 and returns exactly that key
  4. Clicking export hits GET https://litellm-domain/tag/daily/activity/export?export_type=daily_with_keys&format=csv and downloads a 180-row CSV, one row per key per day; organization, customer and agent behave the same

An 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

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

Screenshots / Proof of Fix

Setup shared by both runs: local proxy on localhost:4000 with litellm/proxy/dev_config.yaml against a local Postgres, seeded with one entity per table (tag-lit8619, org-lit8619 alias Repro Org, cust-lit8619 alias Repro Customer, agent-lit8619 alias Repro Agent), 60 keys lit8619-key-000..059 (token sk-hash-lit8619-NNN) and 3 days (2026-09-20 to 2026-09-22) of daily spend rows, 180 rows per table. $MK is the master key. USAGE_TOP_API_KEYS_LIMIT is the default 100. The same fixture SQL was applied before both runs

Before (efbb3ac)

Aggregated route

  1. 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
  2. All four: {"detail":"Not Found"} 404; /end_user/daily/activity/aggregated also 404

Key search

  1. 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
  2. All four: {"detail":"Not Found"} 404

Export

  1. 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
  2. All four: {"detail":"Not Found"} 404

What the dashboard falls back to

  1. 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"]))'
  2. 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 agent

After (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 rows

Aggregated route

  1. 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; done
  2. Each entity: 200, about 537 KB, 3 60 60 1 False and the entities breakdown carries the fixture id with its alias metadata, for example {'org-lit8619': {'metadata': {'organization_alias': 'Repro Org'}, ...}}; one request instead of 18
  3. curl -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" gives 200

Key search

  1. 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
  2. All four: ['sk-hash-lit8619-059']
  3. search=nomatch-xyz gives 200 with "results": []
  4. Scoping check (review fix): fixture adds org org-other-lit8619 with 120 keys aliased shared-lit8619-NNN at high spend, and one key shared-lit8619-mine with tiny spend under org-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 Prisma take was filled by the foreign keys and the call returned results: []; after, it returns exactly ['sk-hash-shared-mine']

Export

  1. 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 -l
  2. Each entity: 200, Content-Disposition: attachment; filename="<entity>_usage_daily_with_keys_2026-09-25.csv", 180 data rows (60 keys x 3 days, no top-N cap; tag shows 181 because of the extra formula-named tag row). Headers: Date,Tag,Key Alias,Key ID,... for tag and Date,Organization,Organization ID,... / Date,Customer,Customer ID,... / Date,Agent,Agent ID,... for the others
  3. format=json: len(data) == 180, metadata.total_api_requests == 180, entity_alias populated for org, customer and agent rows and null for tag
  4. grep HYPERLINK /tmp/tag.csv prints 2026-09-20,"'=HYPERLINK(""x"")",...: requester-supplied entity ids are apostrophe-escaped so they cannot run as spreadsheet formulas

Authorization

  1. Generate an internal user key: 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
  2. /customer/daily/activity/aggregated 401; /organization/daily/activity/aggregated?organization_ids=org-lit8619 403 {"detail":{"error":"User is not org_admin for Organization= org-lit8619."}}; /tag/daily/activity/aggregated 200 with "results": [] (its own key has no tag rows); /agent/daily/activity/aggregated 401, identical to the existing /agent/daily/activity with the same key
  3. curl -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"}} 400

Reviewer 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 applied take=100 before 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 match

The fix moves the search into one SQL query with an EXISTS against 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 before ORDER BY spend LIMIT 100. Customer and tag search go through the same helper

Live proof on two proxies against the same database: :4000 serves 8390066 and :4001 serves the pre-fix tip c71e832. Fixture: org-other-lit8619 holds 120 keys aliased shared-lit8619-NNN with high spend, org-lit8619 holds 121 keys where shared-lit8619-mine has the lowest spend, so it sits outside the top 100 and the dashboard has to ask the server

curl -s -H "Authorization: Bearer $MK" "localhost:4000/organization/daily/activity/aggregated?start_date=2026-09-20&end_date=2026-09-22&organization_ids=org-lit8619" | jq .metadata
{"page": 1, "total_pages": 1, "has_more": false, "api_key_limit": 100, "total_api_keys": 121}

curl -s -H "Authorization: Bearer $MK" "localhost:4001/organization/daily/activity/aggregated/search?search=shared-lit8619&start_date=2026-09-20&end_date=2026-09-22&organization_ids=org-lit8619"
days 0 keys []

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"
days 1 keys ['sk-hash-shared-mine']

curl -s -H "Authorization: Bearer $MK" "localhost:4000/organization/daily/activity/export?start_date=2026-09-20&end_date=2026-09-22&organization_ids=org-lit8619&export_type=daily_with_keys&format=csv&timezone=0" | grep -e ^Date -e shared-lit8619
Date,Organization,Organization ID,Key Alias,Key ID,User ID,User Email,Spend ($),Requests,Successful Requests,Failed Requests,Total Tokens,Prompt Tokens,Completion Tokens,Cache Read Input Tokens,Cache Creation Input Tokens
2026-09-21,Repro Org,org-lit8619,shared-lit8619-mine,sk-hash-shared-mine,default_user_id,-,0.0100,1,1,0,150,100,50,0,0

The keys lines are sorted({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-day mine key) and carries the key that search on the pre-fix tip could not find

Admin UI reproduction: open http://localhost:3000/usage/?tab=organization, pick Organization Usage, keep the default 7 day range, choose Repro Org in Filter by organization, open the Key Activity tab and type shared-lit8619 in Search keys. Both tips show the truncation notice Only the 100 highest-spend keys of 121 are loaded, which is what makes the panel call the search route

Before (dashboard pointed at :4001, pre-fix): the search comes back empty

Before: Key Activity search for shared-lit8619 shows No keys match

After (dashboard pointed at :4000, 8390066): the in-scope key is found and expands to its per-key metrics

After: Key Activity search for shared-lit8619 shows shared-lit8619-mine with $0.01 and 1 request

Type

🆕 New Feature
🐛 Bug Fix

Caveats (if any)

Medium

  • Tag rows count a request once per tag, so a multi-tag request appears in several tag rows; this matches the existing paginated tag route
  • The aggregated payload is one large body (about 537 KB for 60 keys x 3 days) instead of many small pages

Low

  • Key search returns at most 100 matching keys by lifetime spend, but only among keys with spend rows inside the caller's entity, exclusion and date scope (one SQL query with an EXISTS on the spend table), so out-of-scope keys can no longer crowd out a visible match
  • Customer and agent aggregated routes are not added to the internal-user route lists, matching their paginated routes which are also absent there
  • schema.d.ts is regenerated with npm run gen:api, so the diff is large but mechanical

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

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

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

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review this PR

@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

[High risk] Adds new read-only analytics endpoints across multiple entity types.

The usage changes appear sound, but the repository’s mocked-test requirement must be satisfied before merging.

Findings

  1. P2 Database-backed test in mock-only directory ▶

Summary

The PR adds aggregated usage, scoped server-side key search, and uncapped CSV/JSON export for tag, organization, customer, and agent activity, and switches their dashboard tabs to the new routes.

  • The main-branch merge does not change the PR’s usage implementation.
  • One newly added database-backed test conflicts with the repository’s mocked-test requirement for its directory.

Reviews (6) · Last reviewed commit: "chore: merge main into litellm_entity_us..."

Comment thread litellm/proxy/management_endpoints/common_daily_activity_routes.py Outdated
Comment thread litellm/proxy/management_endpoints/common_daily_activity_routes.py Outdated
Comment thread litellm/proxy/management_endpoints/tag_management_endpoints.py
@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

@codspeed

codspeed Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_entity_usage_aggregated_routes (b6ac6d5) with litellm_user_daily_activity_export (c5f6886)

Open in CodSpeed

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review this PR

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

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>>> = {

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.

tag, org, customer and agent tabs used to show every key. Moving them to /aggregated now caps them at the top N

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

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

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review this PR

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

tag, org, customer and agent tabs are still limited to the top-N keys

can you verify?

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

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

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review this PR (merged main into the branch at 3561007, no code changes beyond the merge)

Comment on lines +3053 to +3055
@pytest.mark.asyncio
async def test_export_daily_drops_alias_when_alias_metadata_key_is_none(
_aggregated_postgresql: psycopg.Connection,

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.

P2 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>
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_entity_usage_aggregated_routes branch from 3561007 to b6ac6d5 Compare September 27, 2026 00:11
@devin-ai-integration
devin-ai-integration Bot changed the base branch from main to litellm_user_daily_activity_export September 27, 2026 00:11
@devin-ai-integration
devin-ai-integration Bot added this pull request to stack #43387 September 27, 2026 00:11
@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 — b6ac6d5b Waiting Sep 27, 2026 by devin-ai-integration[bot] via oauth #1455
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