Skip to content

refactor(management): move the logs end-user filter onto /management/v1 - #34691

Merged
yuneng-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_/management-endpoint-standards-b1cd57
Jul 27, 2026
Merged

refactor(management): move the logs end-user filter onto /management/v1#34691
yuneng-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_/management-endpoint-standards-b1cd57

Conversation

@yuneng-berri

@yuneng-berri yuneng-berri commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • /customer/aliases shipped 2 days ago, not yet in a release
  • Its name says customers; it actually reads spend logs
  • Params and envelope do not match the approved standard
  • After a release all of that needs a permanent alias

How it solves it:

  • Renames it to /management/v1/spend_logs/end_users
  • page_size, q, filter[startTime][gte], {data, meta, links}
  • Unknown query params now 400 instead of being ignored
  • Dashboard follows links.next instead of computing page + 1

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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

Not yet captured against a live proxy; the steps below are what I want run before merge, at commit b7a3516232

Start a proxy on port 4000 and the dashboard dev server on port 3000, then:

  1. The old path is gone, so nothing can start depending on it (expect 404):
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:4000/customer/aliases?start_date=2026-07-25%2000:00:00&end_date=2026-07-26%2000:00:00' -H 'Authorization: Bearer sk-1234'
  1. Generate some traffic so there are end users in the window:
for c in acme-corp acme_dev globex initech; do curl -s -o /dev/null http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d "{\"model\":\"gpt-5\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"user\":\"$c\"}"; done
  1. The new path returns the control plane envelope, with data, meta and links:
curl -s --globoff 'http://localhost:4000/management/v1/spend_logs/end_users?filter[startTime][gte]=2026-07-25T00:00:00Z&filter[startTime][lte]=2026-07-26T00:00:00Z&page_size=2' -H 'Authorization: Bearer sk-1234'
  1. links.next is followable as-is, and paging through it never repeats a page 1 entry:
curl -s --globoff "http://localhost:4000$(curl -s --globoff 'http://localhost:4000/management/v1/spend_logs/end_users?filter[startTime][gte]=2026-07-25T00:00:00Z&filter[startTime][lte]=2026-07-26T00:00:00Z&page_size=2' -H 'Authorization: Bearer sk-1234' | jq -r .links.next)" -H 'Authorization: Bearer sk-1234'
  1. Search reaches the server, and a literal _ in an end user id matches itself rather than acting as a wildcard (expect acme_dev only, not acme-corp):
curl -s --globoff 'http://localhost:4000/management/v1/spend_logs/end_users?filter[startTime][gte]=2026-07-25T00:00:00Z&filter[startTime][lte]=2026-07-26T00:00:00Z&q=acme_' -H 'Authorization: Bearer sk-1234'
  1. A typo'd param is refused rather than silently returning everything, as an RFC 9457 problem document (expect 400, content-type: application/problem+json, q_typo named in detail, and a type of urn:litellm:error:unknown-query-parameter rather than an https link):
curl -s -i --globoff 'http://localhost:4000/management/v1/spend_logs/end_users?filter[startTime][gte]=2026-07-25T00:00:00Z&filter[startTime][lte]=2026-07-26T00:00:00Z&q_typo=acme' -H 'Authorization: Bearer sk-1234' | head -20
  1. The window stays mandatory, so the query can never run unbounded (expect 400 and a problem document, not a 422):
curl -s -i 'http://localhost:4000/management/v1/spend_logs/end_users' -H 'Authorization: Bearer sk-1234' | head -20
  1. Every other route keeps the error shape its callers already parse, so the problem+json handler is scoped to this prefix (expect the OpenAI-style {"error": ...} body here):
curl -s -i 'http://localhost:4000/customer/info' -H 'Authorization: Bearer sk-1234' | head -20
  1. Team scoping is unchanged from the endpoint this replaces. Make a key for a user who administers one team but not another, generate traffic with a distinct user on each team, then confirm each key only sees its own team's end users:
curl -s http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-TEAM-A-KEY' -H 'Content-Type: application/json' -d '{"model":"gpt-5","messages":[{"role":"user","content":"hi"}],"user":"customer-on-team-a"}'
curl -s --globoff 'http://localhost:4000/management/v1/spend_logs/end_users?filter[startTime][gte]=2026-07-25T00:00:00Z&filter[startTime][lte]=2026-07-26T00:00:00Z' -H 'Authorization: Bearer sk-TEAM-B-KEY'
  1. UI check. Open http://localhost:3000/?page=logs, click Filters, and open the End User dropdown. It should open instantly, load more as you scroll, and narrow as you type. Repeat signed in as a team admin and confirm the list is limited to their teams

Type

🧹 Refactoring

Changes

/customer/aliases has not been in a release yet, so its wire contract is still free to change. This lands it on the control plane contract from the List Endpoints + Tables standard before that stops being true; once it ships, the path, the param names and the envelope would each need a permanent legacy adapter carrying Deprecation and Sunset headers.

The endpoint becomes GET /management/v1/spend_logs/end_users. It is a facet, the distinct values one column takes over a filtered query on a resource, rather than an entity collection. Naming it after customers implied it listed LiteLLM_EndUserTable when it reads LiteLLM_SpendLogs, which is a different row set: a customer with a budget row and no traffic in the window appears in one and not the other. Serving it under the parent resource means its filters are the parent's filters, so the dropdown offers exactly the values the logs table can show without two endpoints having to keep agreeing on that.

On the contract itself, size becomes page_size and search becomes q, the window moves off flat start_date / end_date onto filter[startTime][gte] and [lte], and the body becomes {data, meta, links}. Unknown query parameters are now a 400 rather than being dropped, since a silently ignored filter over-returns data. Errors are RFC 9457 problem documents, scoped by path prefix so every other route keeps the shape its callers already parse. Their type is a URN rather than an https URL: RFC 9457 only requires that it identify the problem type, and an https URI promises documentation at that address, which we do not have.

links is what makes the rest of the standard deferrable. The dashboard hook follows the server's links.next instead of computing page + 1, so moving this to cursor pagination later changes the links and nothing the client does. That matters because the inner scan is a sliding window: it takes the newest SPEND_LOGS_FACET_SCAN_CAP rows by startTime, and as new logs land between requests, offset paging can skip or repeat an end user across pages. That is a real bug and it is not fixed here, but the hypermedia means fixing it will not be a breaking change.

Scoping is carried over unchanged and still reuses _get_permitted_team_ids_for_spend_logs, so it cannot drift from how /spend/logs/ui scopes the table itself. Cursor mode, sort, include, ETag and If-None-Match, and the generic ListSpec framework are all deliberately out of scope; each is either additive or internal, so none of them needs to beat the release.

New code sits under litellm/proxy/management_endpoints/management_v1/, with the contract machinery (problem documents, unknown-param rejection, link building) in common.py so the next facet inherits it rather than reimplementing it.

Two repo-wide guards needed updating for the new prefix rather than the new route. backend/routes/allowlist.py gains /management/v1/, since the component allowlist test asserts the gateway and backend route sets union to the whole app and a route on neither 404s on both pods; allowlisting the prefix covers every resource that moves under it later. The otel exception-handler test builds its request as a SimpleNamespace, and the validation handler now reads request.url.path, so that fake needed a url.

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

`/customer/aliases` shipped two days ago and has not been in a release, so its
wire contract is still free to change. This lands it on the control-plane
contract before that stops being true, since after a release the path, the param
names and the envelope would all need a permanent legacy adapter

The endpoint becomes `GET /management/v1/spend_logs/end_users`. It is a facet,
the distinct values one column takes over a filtered query on a resource, not an
entity collection; naming it after `customers` implied it listed the end-user
table when it actually reads spend logs, which is a different row set. Serving it
under the parent resource means its filters are the parent's filters, so the
dropdown offers exactly the values the logs table can show without two endpoints
having to keep agreeing on that

Contract changes: `size` becomes `page_size`, `search` becomes `q`, the window
moves from flat `start_date` / `end_date` to `filter[startTime][gte]` / `[lte]`,
and the body becomes `{data, meta, links}`. Unknown query params are now a 400
rather than being silently dropped, because an ignored filter over-returns data.
Errors are RFC 9457 problem documents on this prefix only; every other route
keeps the shape its callers already parse

`links` is what makes the rest deferrable. The dashboard hook follows the
server's `links.next` instead of computing `page + 1`, so moving this to cursor
pagination later changes the links and nothing the client does. That matters
because the inner scan is a sliding window, so offset paging can currently skip
or repeat an end user across pages; the fix is a follow-up, and the hypermedia
means it will not be a breaking one

Cursor mode, `sort`, `include`, ETag / `If-None-Match` and the generic `ListSpec`
framework are all deliberately out of scope here. They are additive or internal,
so none of them needs to beat the release
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Renames the spend-log end-user facet and aligns it with the management API contract.

  • Adds GET /management/v1/spend_logs/end_users with bounded, scoped querying, strict query validation, problem-detail errors, and hypermedia pagination links.
  • Updates route authorization, response models, generated API types, and dashboard infinite-scroll integration.
  • Removes the unreleased /customer/aliases endpoint and its obsolete client hook.
  • Adds backend and dashboard tests covering scoping, search escaping, pagination, validation, and UI behavior.

Confidence Score: 5/5

The PR appears safe to merge, with the renamed endpoint, authorization scope, error contract, and dashboard integration remaining internally consistent.

The new endpoint preserves the existing spend-log visibility rules, constructs valid parameterized queries across search and scope combinations, bounds database work, and keeps its generated client contract synchronized with the dashboard.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/management_v1/spend_logs.py Implements the bounded and authorization-scoped spend-log end-user facet with consistent SQL parameterization and response pagination.
litellm/proxy/management_endpoints/management_v1/common.py Adds shared strict-query validation, problem responses, and page-link construction for management-v1 routes.
litellm/proxy/proxy_server.py Registers the new router and scopes problem-detail handling to the management-v1 API surface.
litellm/proxy/_types.py Moves the renamed endpoint into the same spend-log access tiers as the route it supports.
ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.ts Updates the dashboard query contract and derives subsequent page parameters from server-provided links.
ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx Migrates the end-user filter to the new facet response without changing dropdown behavior.
tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py Adds focused coverage for validation, SQL bounds, scoping, pagination, search escaping, and role reachability.

Reviews (1): Last reviewed commit: "Merge branch 'litellm_internal_staging' ..." | Re-trigger Greptile

The RFC 9457 `type` was `https://docs.litellm.ai/errors/<slug>`, copied from the
standard's own error example. That path is a 404 and there is no docs section
behind it, so every error body shipped a broken link

RFC 9457 only requires `type` to identify the problem type; it encourages, but
does not require, that dereferencing it yield documentation. An https URI makes a
promise we are not keeping, so use `urn:litellm:error:<slug>` instead, which
carries the same machine-readable identity with nothing to resolve. Switching to
an https base later is a contract change for anyone matching on `type`, so that
should wait for pages that actually exist

A test pins the identifier against regressing to an https docs URL, since the
existing assertion built the expected value from the same constant and would have
stayed green whatever it held
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.89313% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...y/management_endpoints/management_v1/spend_logs.py 88.67% 6 Missing ⚠️
...proxy/management_endpoints/management_v1/common.py 96.77% 1 Missing ⚠️
litellm/proxy/proxy_server.py 94.44% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_/management-endpoint-standards-b1cd57 (c3edf24) with litellm_internal_staging (2412326)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (2f2e1e7) during the generation of this report, so 2412326 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Both failures are from this branch, not pre-existing

The component allowlist test asserts the gateway and backend route sets union to
the whole app, so any route on neither is a 404 on both pods. Allowlist the
`/management/v1/` prefix on the backend, next to the other control plane
entries, so every resource that moves under it later is covered without a
per-resource edit

The otel handler test builds its request as a SimpleNamespace carrying only
`state`. The validation handler now reads `request.url.path` to decide whether
the caller is on a surface with its own error contract, so the fake needs a url;
a real Request always has one, which is why the handler does not guard for it

The control plane branch returns early, and nothing covered that it still closes
the dangling SERVER span first, so those requests would have leaked a span
apiece. Added a case that pins it; removing the close call fails it
Same cause as the otel handler test: this file builds its request as a
SimpleNamespace carrying only `state`, and the validation handler now reads
`request.url.path` to pick an error contract, so the fake needs a url

While here, cover what the two existing tests do not. They only exercise the
proxy-wide 422, and the control plane's 400 problem document was reachable only
through the route test, which registers its own copy of the handler in a local
app rather than the real one. Two cases now pin the real handler directly: a
`/management/v1` path returns problem+json with a `detail` string, and paths that
merely resemble the prefix (`/management`, `/v1/management/foo`) keep the 422
shape their callers parse
@yuneng-berri
yuneng-berri merged commit 2b7e01b into litellm_internal_staging Jul 27, 2026
78 of 79 checks passed
@yuneng-berri
yuneng-berri deleted the litellm_/management-endpoint-standards-b1cd57 branch July 27, 2026 18:30
stvnksslr pushed a commit to stvnksslr/litellm that referenced this pull request Aug 3, 2026
…point-standards-b1cd57

refactor(management): move the logs end-user filter onto /management/v1

(cherry picked from commit 2b7e01b)
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.

2 participants