Skip to content

feat(proxy): add GET /management/v1/budgets - #35310

Merged
yuneng-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_/management-v1-budgets
Jul 31, 2026
Merged

feat(proxy): add GET /management/v1/budgets#35310
yuneng-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_/management-v1-budgets

Conversation

@yuneng-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The Budgets page cannot sort or filter
  • /budget/list returns the whole table, unpaged
  • Non-admins get 400 there, not 403

How it solves it:

  • New GET /management/v1/budgets with paging, sorting, filtering
  • Search on budget_id, RFC 9457 errors, real total_count
  • /budget/list untouched; the dashboard moves over separately

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

All of the following was captured against a live proxy at commit 78c756dff9, started with

python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log

and seeded with four budgets created through /budget/new: demo-prod-monthly (500, 30d, tpm 60000, rpm 1200), demo-staging-weekly (50, 7d), demo-eval-daily (5, 24h) and demo-internal-unlimited (no cap, no window). --globoff keeps curl from eating the filter[...] brackets


$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=demo-' --header 'Authorization: Bearer sk-1234' | jq .
{
  "data": [
    {
      "budget_id": "demo-internal-unlimited",
      "max_budget": null,
      "soft_budget": null,
      "tpm_limit": null,
      "rpm_limit": null,
      "budget_duration": null,
      "budget_reset_at": null,
      "created_at": "2026-07-31T17:08:40.413000Z",
      "updated_at": "2026-07-31T17:08:40.413000Z"
    },
    {
      "budget_id": "demo-eval-daily",
      "max_budget": 5.0,
      "soft_budget": null,
      "tpm_limit": null,
      "rpm_limit": null,
      "budget_duration": "24h",
      "budget_reset_at": "2026-08-01T00:00:00Z",
      "created_at": "2026-07-31T17:08:40.325000Z",
      "updated_at": "2026-07-31T17:08:40.325000Z"
    },
    {
      "budget_id": "demo-staging-weekly",
      "max_budget": 50.0,
      "soft_budget": null,
      "tpm_limit": null,
      "rpm_limit": null,
      "budget_duration": "7d",
      "budget_reset_at": "2026-08-03T00:00:00Z",
      "created_at": "2026-07-31T17:08:40.167000Z",
      "updated_at": "2026-07-31T17:08:40.167000Z"
    },
    {
      "budget_id": "demo-prod-monthly",
      "max_budget": 500.0,
      "soft_budget": null,
      "tpm_limit": 60000,
      "rpm_limit": 1200,
      "budget_duration": "30d",
      "budget_reset_at": "2026-08-01T00:00:00Z",
      "created_at": "2026-07-31T17:08:39.989000Z",
      "updated_at": "2026-07-31T17:08:39.989000Z"
    }
  ],
  "meta": {
    "total_count": 4,
    "page": 1,
    "page_size": 50,
    "total_pages": 1
  },
  "links": {
    "self": "/management/v1/budgets?q=demo-&page=1",
    "first": "/management/v1/budgets?q=demo-&page=1",
    "prev": null,
    "next": null,
    "last": "/management/v1/budgets?q=demo-&page=1"
  }
}
$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=WEEK' --header 'Authorization: Bearer sk-1234' | jq '{matched: [.data[].budget_id], total_count: .meta.total_count}'
{
  "matched": [
    "demo-staging-weekly"
  ],
  "total_count": 1
}

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=demo-&filter[budget_duration][in]=30d' --header 'Authorization: Bearer sk-1234' | jq '[.data[] | {budget_id, budget_duration}]'
[
  {
    "budget_id": "demo-prod-monthly",
    "budget_duration": "30d"
  }
]

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=demo-&filter[max_budget][is_null]=true' --header 'Authorization: Bearer sk-1234' | jq '[.data[] | {budget_id, max_budget}]'
[
  {
    "budget_id": "demo-internal-unlimited",
    "max_budget": null
  }
]


$ curl -s --globoff -o /dev/null -w '%{http_code}\n' 'http://localhost:4000/management/v1/budgets?sort_by=max_budget' --header 'Authorization: Bearer sk-1234'
400

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?sort_by=max_budget' --header 'Authorization: Bearer sk-1234' | jq .
{
  "type": "urn:litellm:error:unknown-query-parameter",
  "title": "Unknown query parameter",
  "status": 400,
  "detail": "Unrecognized query parameter(s): sort_by.",
  "allowed": [
    "filter[budget_duration][in]",
    "filter[budget_duration][is_null]",
    "filter[created_at][gte]",
    "filter[created_at][lte]",
    "filter[max_budget][gte]",
    "filter[max_budget][is_null]",
    "filter[max_budget][lte]",
    "page",
    "page_size",
    "q",
    "sort"
  ]
}

$ curl -s --globoff -o /dev/null -w '%{http_code}\n' 'http://localhost:4000/management/v1/budgets?sort=budget_duration' --header 'Authorization: Bearer sk-1234'
400

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?sort=budget_duration' --header 'Authorization: Bearer sk-1234' | jq .
{
  "type": "urn:litellm:error:invalid-sort-field",
  "title": "Invalid sort field",
  "status": 400,
  "detail": "Cannot sort budgets by: 'budget_duration'.",
  "allowed": [
    "budget_id",
    "created_at",
    "max_budget",
    "rpm_limit",
    "tpm_limit"
  ]
}

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=demo-&page_size=500' --header 'Authorization: Bearer sk-1234' | jq '.meta'
{
  "total_count": 4,
  "page": 1,
  "page_size": 100,
  "total_pages": 1
}

$ NON_ADMIN_KEY=$(curl -s -X POST http://localhost:4000/key/generate \
    --header "Authorization: Bearer sk-1234" --header "Content-Type: application/json" \
    --data '{"user_id": "demo-non-admin", "allowed_routes": ["/management/v1/budgets", "/budget/list"]}' | jq -r .key)

$ curl -s --globoff -o /dev/null -w "%{http_code}\n" 'http://localhost:4000/management/v1/budgets' --header "Authorization: Bearer $NON_ADMIN_KEY"
403

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets' --header "Authorization: Bearer $NON_ADMIN_KEY" | jq .
{
  "type": "urn:litellm:error:forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "Only proxy admins can list budgets, your role=internal_user"
}

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/budget/list --header "Authorization: Bearer $NON_ADMIN_KEY"
400

$ curl -s http://localhost:4000/budget/list --header "Authorization: Bearer $NON_ADMIN_KEY" | jq .
{
  "detail": {
    "error": "Admin-only endpoint. Not allowed to access this., your role=internal_user"
  }
}
DONE
$ # the same instant, once offset-less and once with an explicit Z: both must count the same rows
$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=demo-&filter[created_at][gte]=2026-07-31T00:00:00' --header 'Authorization: Bearer sk-1234' | jq '.meta.total_count'
4

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=demo-&filter[created_at][gte]=2026-07-31T00:00:00Z' --header 'Authorization: Bearer sk-1234' | jq '.meta.total_count'
4

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=demo-&filter[created_at][lte]=2020-01-01T00:00:00Z' --header 'Authorization: Bearer sk-1234' | jq '.meta.total_count'
0

The last two calls are the same key against both endpoints: the new route answers 403 as a problem document, /budget/list still answers 400 in the proxy's OpenAI error shape. That endpoint is deliberately unchanged here; deprecating it is a follow-up once the dashboard stops calling it

The final pair covers the datetime cast. Before it, every filter[created_at][gte|lte] answered 500 with operator does not exist: timestamp without time zone >= text

Type

🆕 New Feature

Changes

GET /management/v1/budgets registers LiteLLM_BudgetTable against the generic list contract from #35308. The budgets side declares a ListSpec and injects a ListExecutor that owns the table, so the planner stays free of any database dependency and the executor is the only place that knows SQL

The spec sorts on budget_id, max_budget, tpm_limit, rpm_limit and created_at, defaulting to -created_at, with budget_id appended as the tiebreaker on every query. budget_duration is deliberately not sortable: the column holds strings like "7d" and "30d", so a lexicographic ORDER BY would put "30d" ahead of "7d" and quietly mis-order the page

Search is a case-insensitive contains on budget_id, the only text identity on the row. Filters are filter[budget_duration][in|is_null], filter[max_budget][gte|lte|is_null] and filter[created_at][gte|lte]. Anything else, including an operator a filter does not declare, is refused 400 with the accepted set in allowed, since a silently ignored filter over-returns budgets and that is worse than a rejected request. page_size defaults to 50 and clamps at 100

The scope is decided from the caller and never from the query string, and the planner puts it ahead of the caller's filters as conjuncts they sit behind, so a filter can only narrow what they may read. A caller without admin view is refused 403 rather than served an empty page, which would read as "this proxy has no budgets". The population is the same one /budget/list gates on today; only the status code differs

The executor reads through query_raw, the way the spend logs facet does, selecting only the nine columns it serves rather than SELECT *, so created_by and model_max_budget never reach the browser. Rows validate through a pydantic model on the way out, which is what makes tpm_limit and rpm_limit JSON numbers: they are BigInt? in Prisma and the query engine hands them back as decimal strings

One change lands in the shared where_sql: datetime binds are now cast. They cross into the query engine as JSON, so an uncast placeholder reaches Postgres as text and timestamp >= text is rejected outright rather than answered wrongly, which made every created_at filter a 500. The cast reads the bind as an instant and drops it to naive UTC to match Prisma's TIMESTAMP(3) column, matching what /spend/logs/ui already does. Budgets is the first list with a datetime filter, so nothing exercised this before

QA runbook

Run against a live proxy on :4000 with the master key. Every step below uses --globoff so curl leaves the filter[...] brackets alone

  • tests/e2e/management/test_budget_customer_user_org_e2e.py::TestBudgetListV1::test_sorts_pages_and_filters_the_budgets_it_created - three budgets tagged with one marker come back sorted, paged and filtered, with a correct total

    • Create three budgets: curl -X POST http://localhost:4000/budget/new -H "Authorization: Bearer sk-1234" -H 'Content-Type: application/json' -d '{"budget_id": "qa1-small", "max_budget": 1.0, "budget_duration": "7d", "tpm_limit": 60000}', then the same for qa1-medium (2.0, 30d) and qa1-large (3.0, 30d)
    • curl --globoff 'http://localhost:4000/management/v1/budgets?q=qa1&sort=-max_budget' -H "Authorization: Bearer sk-1234" and expect exactly qa1-large, qa1-medium, qa1-small in that order
    • curl --globoff 'http://localhost:4000/management/v1/budgets?q=qa1&sort=-max_budget&page=2&page_size=1' -H "Authorization: Bearer sk-1234" and expect only qa1-medium, meta.total_count 3, meta.total_pages 3, and both links.prev and links.next populated
    • curl --globoff 'http://localhost:4000/management/v1/budgets?q=qa1&filter[budget_duration][in]=30d' -H "Authorization: Bearer sk-1234" and expect qa1-medium and qa1-large only
    • Expect tpm_limit to render as 60000, an unquoted JSON number, on every row
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/management/test_budget_customer_user_org_e2e.py::TestBudgetListV1::test_is_null_finds_the_budget_left_uncapped - is_null separates an uncapped budget from a capped one

    • Create qa2-uncapped with no max_budget and qa2-capped with max_budget 4.0
    • curl --globoff 'http://localhost:4000/management/v1/budgets?q=qa2&filter[max_budget][is_null]=true' -H "Authorization: Bearer sk-1234" and expect only qa2-uncapped, with max_budget null
    • Repeat with is_null=false and expect only qa2-capped
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/management/test_budget_customer_user_org_e2e.py::TestBudgetListV1::test_refuses_a_sort_field_and_a_parameter_it_does_not_support - an unsortable column and an undeclared parameter are both refused 400

    • curl --globoff -i 'http://localhost:4000/management/v1/budgets?sort=budget_duration' -H "Authorization: Bearer sk-1234" and expect 400, application/problem+json, and the five sortable fields in allowed
    • curl --globoff -i 'http://localhost:4000/management/v1/budgets?filter[budget_id][eq]=x' -H "Authorization: Bearer sk-1234" and expect 400 naming filter[budget_id][eq] in detail
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/management/test_budget_customer_user_org_e2e.py::TestBudgetListV1::test_is_refused_for_a_non_admin_key - a key without admin view cannot read the budget list

    • curl -X POST http://localhost:4000/key/generate -H "Authorization: Bearer sk-1234" -H 'Content-Type: application/json' -d '{}' and keep the returned key
    • curl --globoff -i http://localhost:4000/management/v1/budgets -H "Authorization: Bearer <that key>" and expect 401 or 403, never a 200 carrying an empty data
    • To see the handler's own 403 rather than the route gate's 401, generate the key with {"allowed_routes": ["/management/v1/budgets"]} and expect urn:litellm:error:forbidden
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky

Prerequisites are a proxy with a database attached and the master key; no provider credentials are needed, since nothing here reaches an LLM. Clean up the qa1-* and qa2-* budgets with /budget/delete when you are done

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

Paging, sorting, filtering and search for an entity collection, declared once
as a ListSpec and served by handle_list. The route injects a ListExecutor that
owns its table, so this module never imports Prisma.

The caller's scope is derived from the caller alone and ANDed with whatever
they filtered on, so a query parameter can only narrow what they may read.

This is the shared half of the budgets list; it lands here so the endpoint has
something to register against, and drops out when the framework arrives on its
own branch.
The Budgets page reads /budget/list, which returns the whole table as a bare
array with no way to page, sort or filter it. A customer with enough budgets to
fill the page has no way to find one.

Registers LiteLLM_BudgetTable against the management/v1 list contract: sortable
on budget_id, max_budget, tpm_limit, rpm_limit and created_at, default order
newest-first with budget_id breaking ties, search on budget_id, and filters for
budget_duration, max_budget and created_at. budget_duration is deliberately not
sortable; the column holds "7d"/"30d" strings, so a lexicographic ORDER BY puts
"30d" ahead of "7d".

tpm_limit and rpm_limit are BigInt? in Prisma, so rows validate through a
pydantic model on the way out and serialize as JSON numbers.

A caller without admin view is refused 403 as a problem document rather than
served an empty page. /budget/list is untouched.
@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a paginated management API for listing budgets.

  • Registers GET /management/v1/budgets for proxy admins and admin viewers.
  • Supports deterministic sorting, budget-ID search, declared filters, pagination metadata, and RFC 9457 error responses.
  • Normalizes datetime filter values to UTC and casts their SQL placeholders for comparison with Prisma timestamp columns.
  • Adds unit, authorization, route-access, and end-to-end coverage plus the generated dashboard schema.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; offset-less datetime filters are normalized to aware UTC values before binding, and the SQL converts the resulting instant to the naive UTC representation used by the database column.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/management_v1/list_framework.py Normalizes parsed datetimes to aware UTC values and renders timestamp-compatible SQL binds, resolving the previously reported naive-datetime boundary shift.
litellm/proxy/management_endpoints/management_v1/budgets.py Implements the authenticated paginated budget-list endpoint with restricted fields, filtering, sorting, and deterministic pagination.
litellm/proxy/_types.py Grants the new read-only route to the existing admin-viewer access tier.
tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py Covers response shape, authorization, query planning, timestamp normalization, SQL generation, pagination, and serialization.
tests/e2e/management/test_budget_customer_user_org_e2e.py Adds live-proxy coverage for budget sorting, paging, filtering, numeric limits, and non-admin rejection.
ui/litellm-dashboard/src/lib/http/schema.d.ts Adds generated TypeScript declarations for the new budget-list operation and response models.

Reviews (2): Last reviewed commit: "refactor(proxy): fold the predicate rend..." | Re-trigger Greptile

Comment thread litellm/proxy/management_endpoints/management_v1/list_framework.py Outdated
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.10390% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...roxy/management_endpoints/management_v1/budgets.py 95.38% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_/management-v1-budgets (858ba17) with litellm_internal_staging (0e9a624)1

Open in CodSpeed

Footnotes

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

…itellm_/management-v1-budgets

# Conflicts:
#	litellm/proxy/management_endpoints/management_v1/list_framework.py
#	litellm/types/proxy/management_endpoints/management_v1.py
PR #35308 landed a different shape than this branch was written against: `where`
is a tuple of frozen predicates rather than a Prisma-shaped mapping, `ListSpec`
carries both the row and the wire type, and `where_sql` / `order_by_sql` render
for a raw-SQL executor. The budgets executor now queries through `query_raw` the
way the spend logs facet does, selecting only the columns it serves.

Also casts datetime binds in `where_sql`. They cross into the query engine as
JSON, so an uncast placeholder arrives as text and Postgres refuses
`timestamp >= text` outright; every `filter[created_at][gte|lte]` was answering
500. The cast reads the bind as an instant and drops it to naive UTC to match
Prisma's TIMESTAMP(3) column, the same one /spend/logs/ui applies.
recursive_detector flags `_render_all`, and the flag is fair: it recursed once
per predicate, so the stack grew with the number of filters on the request for
no reason. Walking a predicate list is a running bind index, which is a fold.

`_render` still re-enters for `AnyOf`, but its clauses are plain comparisons
built by `?q=`, so that nesting is one level deep and no caller can drive it
deeper.
@yuneng-berri

Copy link
Copy Markdown
Contributor Author

@greptile

@devin-ai-integration

Copy link
Copy Markdown
Contributor

QA verdict: PASS

QA'd this against a live proxy on localhost:4000 with a real Postgres, driving the route with curl only. Seeded seven budgets through /budget/new (one uncapped, one sharing max_budget with another for the tiebreaker, one whose id holds a literal underscore), ran 95 requests across the happy path, the sad path and the edge cases, then deleted everything. No 5xx on the route at any point; the log tally for the run was 64x200, 26x400, 4x401, 1x403

Happy path: sort, page, envelope, and numeric tpm/rpm

-max_budget puts the 5.0 first and breaks the 4.0 tie by budget_id ascending, so qa2-capped precedes qa3-tie; meta and links are exact and next preserves the other params

happy path listing

tpm_limit / rpm_limit come back as bare numbers, checked on the raw bytes rather than after a JSON parse (which would hide a quoted BigInt):

$ curl -s --globoff 'http://localhost:4000/management/v1/budgets?q=qa1-small' \
    -H 'Authorization: Bearer sk-1234' | grep -o '"[tr]pm_limit":[^,]*'
"tpm_limit":60000
"rpm_limit":100

Nulls stay last in both directions, which is the run a missing NULLS LAST would fail since Postgres defaults nulls-first on DESC:

sort=max_budget   -> qa1-small qa1-medium qa1-large qa2-capped qa3-tie qa3-under_score qa2-uncapped
sort=-max_budget  -> qa3-under_score qa2-capped qa3-tie qa1-large qa1-medium qa1-small qa2-uncapped

Paging partitions the set with no duplicates, a page past the end is a 200 with empty data, and page_size=500 clamps:

page 1 of page_size=3 -> ['qa1-large','qa1-medium','qa1-small']   next=...&q=qa&sort=budget_id&page_size=3&page=2
page 2                -> ['qa2-capped','qa2-uncapped','qa3-tie']  prev+next both set
page 3                -> ['qa3-under_score']                      next=null
?q=qa&page=99         -> 200 {"data":[],"meta":{"total_count":7,"page":99,"page_size":50,"total_pages":1}}
?q=qa&page_size=500   -> "meta":{...,"page_size":100,...}
created_at filters, the 500 this PR fixes

All three spellings resolve to the same instant and none 500s; the boundary genuinely splits the set (1 row vs the other 6), so the comparison is not a no-op. A malformed value is a 400 problem document rather than a driver error

created_at filters

Nothing in the proxy log mentions timestamp >= text or operator does not exist

Problem documents, and the old route left alone

problem documents

The side-by-side is the useful part: for the same non-admin caller the new route answers 403 application/problem+json while /budget/list still answers 400 application/json in the OpenAI error shape, so the problem-document handler did not leak onto it. allowed sets came back exactly as declared, budget_duration correctly absent from the sortable set, and ?page=1&page=999 is refused as a duplicate rather than paging from 999

q escaping and injection

The discriminating row is the second one: q=qa3_under_score returns 0 while q=qa3-under_score returns 1. Unescaped, that _ wildcard would cover the hyphen and match, so this is direct evidence the LIKE escaping is applied. A bare % returns 0 rather than everything, and the DROP TABLE attempt returns 0 rows with the table still intact afterwards

q escaping and injection

Authorization tiers
no Authorization header  -> 401
sk-totally-invalid       -> 401
plain non-admin key      -> 401   (route check, before the handler)
key with allowed_routes  -> 403 application/problem+json
                            {"type":"urn:litellm:error:forbidden","title":"Forbidden","status":403,
                             "detail":"Only proxy admins can list budgets, your role=internal_user"}
admin viewer key         -> 200, the same 7 rows the master key sees

Worth noting for the description: a plain non-admin key is stopped 401 by the route check before reaching the handler, so the handler's 403 is only observable for a key explicitly permitted to call the route. Both are safe refusals and neither is ever a 200 with an empty page

Three small observations, none blocking. filter[budget_id][eq]=x is refused as unknown-query-parameter rather than unsupported-filter-operator because budget_id isn't a declared filter field at all; the links percent-encode the bracketed keys and the sort comma, which is correct but visible to any client comparing link strings; and the 401-vs-403 layering above

The PR's own tests also pass against this proxy (392 unit, and the 4 TestBudgetListV1 e2e cases, which I confirmed genuinely hit it by finding their markers in the access log)

Tested by this Devin session

@yuneng-berri
yuneng-berri enabled auto-merge (squash) July 31, 2026 18:46
@yuneng-berri
yuneng-berri merged commit fcec148 into litellm_internal_staging Jul 31, 2026
80 checks passed
@yuneng-berri
yuneng-berri deleted the litellm_/management-v1-budgets branch July 31, 2026 18:47
@devin-ai-integration

Copy link
Copy Markdown
Contributor

QA verdict: PASS

A second, independent pass on top of the earlier QA run, on a larger seeded table and covering ground that one did not: the no-database 503 branch, page_size clamping, duplicate query parameters, timezone-offset agreement on the created_at filter, deep paging over 250 rows, the column projection, and /budget/list as a regression check

QA'd against a live proxy on localhost:4000 backed by Postgres 16 with 259 seeded budgets, plus a second proxy on :4001 started without DATABASE_URL for the no-database branch. 28 cases across happy path, sad path and edge cases; every one behaved as specified and nothing returned a 500. Two non-blocking nits at the bottom

Proxy started with:

docker run -d --name litellm-pg -e POSTGRES_PASSWORD=litellm -e POSTGRES_USER=litellm -e POSTGRES_DB=litellm -p 5432:5432 postgres:16

DATABASE_URL="postgresql://litellm:litellm@localhost:5432/litellm" STORE_MODEL_IN_DB=True \
  uv run --no-sync python litellm/proxy/proxy_cli.py --config qa_budgets_config.yaml --detailed_debug --use_v2_migration_resolver 2>&1 | tee litellm.log

# second proxy, no DB, for the 503 branch
env -u DATABASE_URL uv run --no-sync python litellm/proxy/proxy_cli.py --config qa_budgets_config.yaml --port 4001

qa_budgets_config.yaml is deliberately minimal, since nothing here reaches an LLM:

model_list:
  - model_name: fake-openai
    litellm_params:
      model: openai/fake
      api_key: sk-fake
      api_base: http://localhost:8080

general_settings:
  master_key: sk-1234

litellm_settings:
  drop_params: true

Seeded through /budget/new: qa1-small (1.0, 7d, tpm 60000), qa1-medium (2.0, 30d), qa1-large (3.0, 30d), qa2-uncapped (no cap), qa2-capped (4.0), qa3-tie-a/b/c (all 9.0, for the tiebreaker), qa4-ünïcode-测试 (tpm 9007199254740993) and qa5-bulk-001..250. Every curl uses --globoff so the filter[...] brackets survive

The core contract: sorting, paging, links and the projected columns

?q=qa1&sort=-max_budget orders large(3), medium(2), small(1); &page=2&page_size=1 returns only qa1-medium with total_count 3, total_pages 3 and both prev and next populated

sorting and paging

The response carries exactly the nine serving columns, tpm_limit comes back as an unquoted JSON number rather than the "60000" string a raw BigInt? column would give, created_by and model_max_budget are absent, and /budget/list is untouched (still a bare array of 21-key rows)

columns and BigInt

Filters and the always-appended budget_id tiebreaker: budget_duration[in]=30d narrows to medium and large, max_budget[is_null] true/false splits the capped and uncapped pair, and three rows tied at 9.0 come back deterministically as a, b, c

filters and tiebreaker

Walking the whole table at page_size=2 gives fetched=unique=total_count=259, so no row is duplicated across a boundary or skipped; a proxy_admin_viewer key reads the list with 200

page continuity

The 403-vs-400 improvement

Two independent gates. No key and a bogus key are 401 at the route gate. A non-admin key that lists /management/v1/budgets in its allowed_routes reaches the handler's own scope check and gets 403 application/problem+json urn:litellm:error:forbidden, while the same key on the legacy /budget/list still returns 400 with the old OpenAI-shaped error. That contrast is the behavioural fix here

403 vs 400

Sad path: problem documents, validation, and the no-DB 503

sort=budget_duration is refused 400 with urn:litellm:error:invalid-sort-field and an allowed array naming the five sortable fields; an undeclared filter gives unknown-query-parameter naming the exact key, and a wrong operator gives unsupported-filter-operator

problem documents

Eight malformed inputs, eight 400s with distinct, actionable details, no 500s: bad datetime, bad float, page=0, page=-1, page_size=0, page=abc, a repeated page, and an unknown limit

validation matrix

The proxy started without DATABASE_URL answers 503 as a problem document rather than the generic error shape

503 no DB

Edge cases: clamping, timezones, injection safety, unicode, deep paging

page_size=500 is clamped to 100 server-side. created_at[lte] agrees across the naive, Z and -05:00 forms of the same instant (all 3 rows) and returns 0 for instants before the rows, which is what the ::timestamptz AT TIME ZONE 'UTC' cast buys; these used to 500

clamp and offsets

Injection payloads in q, in sort and in a filter value are bound or refused (0 rows, or 400 invalid-sort-field, since column names never come from input), and a direct psql count straight afterwards still shows 259 rows. LIKE metacharacters %, _ and \ match literally, each returning 0 instead of the 259 an unescaped _ would have matched

injection and table intact

Unicode and CJK substrings both find qa4-ünïcode-测试, a tpm_limit of 9007199254740993 round-trips exactly in the raw body (only jq's float64 rounds it to ...992), and page 5 of 50 over the bulk rows returns 201..250 in 19 ms

unicode and bigint

Regression suites
$ LITELLM_LOCAL_MODEL_COST_MAP=True uv run --no-sync pytest \
    tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py \
    tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py \
    tests/test_litellm/proxy/auth/test_route_checks.py -q
392 passed

$ LITELLM_PROXY_URL=http://localhost:4000 LITELLM_MASTER_KEY=sk-1234 uv run --no-sync pytest \
    tests/e2e/management/test_budget_customer_user_org_e2e.py -k TestBudgetListV1 -q -p no:randomly
4 passed

grep -c internal-server-error litellm.log is 0 for the whole session; the tracebacks in the log are the expected 401 auth rejections

Two nits, neither blocking

Out-of-range pages return 200 with empty data and next: null as they should, but links.prev is not clamped: ?page=99 points prev at page 98 and ?page=999999999999 points it at 999999999998, both non-existent when total_pages is 6. Following prev from an out-of-range page lands on another empty page rather than the last real one. A client that only follows next never sees this

prev nit

Separately, a caller has to percent-encode + in a datetime offset (...T23:33:00%2B05:00), since a raw + decodes to a space and is then correctly rejected as an invalid datetime. Not a bug, but a line in the docstring next to the existing curl example would save someone a confusing 400

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