Skip to content

fix(ui): surface the paginated fallback on Cost Optimization - #37659

Merged
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_lit5700_fallback_ux
Aug 20, 2026
Merged

fix(ui): surface the paginated fallback on Cost Optimization#37659
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_lit5700_fallback_ux

Conversation

@tin-berri

@tin-berri tin-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Cost Optimization gave no sign data was still streaming in
  • Values and cache leakage tables silently churned during fallback loads
  • No way to stop a long paginated load

How it solves it:

  • Page now shows the Usage page's fetch-progress banner with a Stop button
  • Cache leakage tables say they are still filling in mid-stream
  • Banner extracted into one shared component, deleting three inline copies

User Flow

Before: during a slow load the user watches numbers change with no explanation and no way out

  1. They open http://localhost:4000/ui/cost-optimization on a deployment where the single-shot summary endpoint is failing, so the page loads the range page by page
  2. Every card and chart keeps shifting for the whole load, and the only hint is a small "Loading..." under one card
  3. They open the Prompt Caching tab and the Cache leakage table shows full-looking rows whose token counts and ordering keep reshuffling as pages arrive
  4. Nothing on the page offers a way to stop the load short of navigating away

After: the same degraded load announces itself, reports progress, and can be stopped

  1. They open http://localhost:4000/ui/cost-optimization under the same failing summary endpoint
  2. A banner appears above the tabs: "Currently fetching spend data: fetched 4 / 30 pages", with a Stop button and a link to keep working in a new tab
  3. The Prompt Caching tab's Cache leakage table carries a note that data is still loading and rows will update as the rest of the range arrives
  4. Clicking Stop halts the load and the banner switches to "Showing partial spend data (16/30 pages loaded)"
  5. On a healthy deployment nothing changes: the page loads in one request and no banner ever appears

Relevant issues

Linear ticket

Resolves LIT-5700

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: live proxy on localhost:4000 backed by real Postgres, LiteLLM_DailyUserSpend seeded with 29,002 rows across four dates so the range spans 30 pages at the UI's page size. The fallback legs force the aggregated route to return 500 (the route was temporarily patched in the running rig to raise, identically for Before and After); the happy-path legs run the unmodified route

Before (c164944)

Fallback streams silently with no progress or cancel

  1. Open http://localhost:4000/ui/cost-optimization while curl -s -o /dev/null -w "%{http_code}" "http://localhost:4000/user/daily/activity/aggregated?start_date=2026-08-10&end_date=2026-08-17&timezone=420" -H "Authorization: Bearer sk-1234" returns 500
  2. The network log shows the aggregated call fail once, then GET /user/daily/activity page by page; the only visible hint is "Loading..." under the Total saved card while every number climbs for ~12 seconds
  3. The page's only role=alert element is the static "This is an experimental dashboard" note, so there is no progress indication and no way to stop the load

Cache leakage table presents partial rows as final

  1. Mid-stream, open the Prompt Caching tab
  2. The Cache leakage by virtual key table renders full-looking rows with no loading indication, and two screenshots seconds apart show the token counts reshuffling (944,400 then 1,070,000 for the top key)

After (8425b57)

Fallback streams silently with no progress or cancel

  1. Same forced-500 rig, same reload of http://localhost:4000/ui/cost-optimization
  2. A warning banner renders above the tabs: "Currently fetching spend data: fetched 4 / 30 pages. Charts will update periodically as data loads..." with a Stop button
  3. Clicking Stop halts the stream and swaps the banner to "Showing partial spend data (16/30 pages loaded)"

Cache leakage table presents partial rows as final

  1. Mid-stream, the Prompt Caching tab's table now shows "Data is still loading; rows and totals will update as the rest of the range arrives." directly above the rows

Happy path unchanged

  1. Restore the unmodified aggregated route and reload the page
  2. The network log shows exactly one request, GET /user/daily/activity/aggregated returning 200, and a DOM probe confirms no fetch banner exists (document.body.textContent.includes("Currently fetching spend data") is false)

Type

🐛 Bug Fix

Caveats (if any)

  • Usage page banner text changes one word: the post-cancel notice now reads "Showing partial spend data" instead of "Showing partial data", a deliberate unification from the shared component's subject prop
  • Open PR fix(ui): stop usage pagination from under-reporting wide date ranges #36022 adds failed/incomplete surfacing around the same Usage page banner area and will need a small rebase over the extraction

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

Note

Low Risk
UI-only loading/status presentation; no auth, data, or fetch-logic changes beyond forwarding existing hook fields.

Overview
When Cost Optimization falls back to page-by-page daily activity, the page now shows the same fetch-progress banner and Stop control as Usage, instead of silently reshuffling numbers.

useDailyActivityRange forwards progress, cancelled, and cancel from the paginated hook. Cache leakage tables add a still-loading note while extra pages stream in (not during a full range reload).

The duplicated Usage/EntityUsage banners are extracted into shared PaginationStatusAlerts (optional subject for agent vs spend data). Happy-path single-shot loads still show no banner.

Reviewed by Cursor Bugbot for commit 8425b57. Bugbot is set up for automated code reviews on this repo. Configure here.

The page streamed its fallback silently: useDailyActivityRange dropped
the hook's progress and cancel fields and CacheLeakageCard only showed
a loading state while empty. Extract the Usage page's fetch banner into
a shared PaginationStatusAlerts component, render it above the tabs,
and note on the cache leakage tables when pages are still arriving.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Score: 5/5

This is a clean, focused refactor with genuine UX improvement. Here's the breakdown:

What's done well:

  • Extraction is correct. The three near-verbatim inline banners in UsagePageView.tsx, EntityUsage.tsx, and now CostOptimizationView.tsx are replaced by a single PaginationStatusAlerts component. The shared component adds the subject prop cleanly, and the one observable text change ("Showing partial data" → "Showing partial spend data") is deliberate and documented.

  • No leaky abstractions. PaginationStatusAlerts is a pure presentational component — no hooks, no side effects, just props in / markup out. The subject default of "spend data" is the right call: it's the most common case, and the agent data caller overrides it explicitly.

  • Forwarding is complete. useDailyActivityRange now threads progress, cancelled, and cancel straight through from usePaginatedDailyActivity without dropping or transforming them. The test at useDailyActivityRange.test.tsx:50 pins this contract.

  • Tests cover the right cases. The new PaginationStatusAlerts suite tests all four states: fetching (with Stop wired), cancelled (frozen count), custom subject, and idle/hidden. The integration test in CostOptimizationView.activity.test.tsx validates the banner appears during fallback and is absent on the happy path — exactly the two cases that matter.

  • The CacheLeakageCard note is the right choice. Showing a prose note above the table rows (rather than a spinner overlay or disabling the table) keeps partial data visible while signaling it's incomplete. The condition rows.length > 0 && (loading || isFetchingMore) is correct — it only shows when there's something to show and it's still arriving.

No issues.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR exposes paginated fallback progress and cancellation on Cost Optimization, annotates partially populated cache-leakage tables, and consolidates three Usage-page banner implementations into a shared component

  • Forwards pagination progress, cancellation state, and the cancel callback through the Cost Optimization activity hook
  • Adds progress and partial-data banners plus cache-leakage loading messaging
  • Reuses the shared banner across Usage and entity-usage views and adds focused component tests

Confidence Score: 4/5

The PR needs the cache-leakage loading condition corrected so a new whole-range request does not misrepresent stale rows as streamed partial data

The shared cancellation banner is wired correctly, but the cache-leakage note now renders during initial loading while the hook retains results from the previous date range

Files Needing Attention: ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx, ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx

Important Files Changed

Filename Overview
ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx Adds a loading annotation, but its loading branch mislabels retained prior-range rows as a continuing page stream
ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx Correctly renders the shared pagination status using the activity hook's forwarded state and cancellation callback
ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts Extends the activity contract to forward pagination progress and cancellation controls without changing query scope
ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx Faithfully consolidates the existing banners, but adds a source comment prohibited by the repository's comment policy
ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx Replaces duplicated spend and agent pagination banners while preserving agent-visibility gating and cancellation wiring
ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx Replaces the inline fallback status UI with the shared component while preserving pagination state and actions

Reviews (1): Last reviewed commit: "fix(ui): surface the paginated fallback ..." | Re-trigger Greptile

</Tabs>
</CardHeader>
<CardContent>
{rows.length > 0 && (loading || isFetchingMore) && (

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.

P1 Loading state mislabels stale rows

When the date range changes, loading retains prior rows, so this note mislabels stale data as pages still arriving

Suggested change
{rows.length > 0 && (loading || isFetchingMore) && (
{rows.length > 0 && isFetchingMore && (

Knowledge Base Used: Admin dashboard (ui/litellm-dashboard)

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!

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.

Fixed: the note now gates on isFetchingMore alone, with a regression test pinning that loading over prior rows shows nothing

Comment on lines +14 to +19
/**
* The fetching-progress and partial-data banners shown while a daily activity
* range streams in page by page. Rendered wherever usePaginatedDailyActivity
* falls back to pagination; both banners stay hidden on the single-shot
* aggregated path because it never sets isFetchingMore or cancelled.
*/

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 Redundant component comment

This comment restates straightforward rendering conditions, adding documentation that must be maintained without explaining complex business logic

Suggested change
/**
* The fetching-progress and partial-data banners shown while a daily activity
* range streams in page by page. Rendered wherever usePaginatedDailyActivity
* falls back to pagination; both banners stay hidden on the single-shot
* aggregated path because it never sets isFetchingMore or cancelled.
*/

Context Used: CLAUDE.md (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!

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.

Removed the comment, the props and render conditions speak for themselves

loading also covers a fresh aggregated request over the previous
range's rows, where pagination copy mislabels stale data. Drop the
redundant component comment flagged against the repo comment policy.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 8425b57. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

5/5 — clean, well-scoped, and correctly executed.

What's done well:

  • Extraction is faithful. PaginationStatusAlerts preserves all the original markup (spinner, page counters, external-link, Stop button) with no behavioral change for existing callers. The only deliberate copy-edit ("partial spend data" vs "partial data") is documented in the PR description.
  • The forwarding chain is complete. useDailyActivityRange now surfaces progress, cancelled, and cancel all the way up to CostOptimizationView, so the Cost Optimization page is a full citizen of the same fallback-awareness pattern as Usage.
  • Tests are precise and cover the right states. PaginationStatusAlerts tests hit: fetching, cancelled (frozen page count), named subject, and idle (renders nothing). CacheLeakageCard tests hit: mid-stream note visible, note absent on full reload (loading: true), note absent when settled. Integration test distinguishes happy path (no banner) from fallback path (banner + Stop button).
  • No new network paths, auth changes, or data-correctness mutations — everything is prop wiring and render gating.
  • Idle/happy path is unchanged. The aggregated-success path never sets isFetchingMore, so PaginationStatusAlerts renders nothing and the DOM remains identical to before.

Minor note (not blocking): PaginationStatusAlerts uses window.location.href directly. This was already the pattern in all three inline copies, so it's not a regression — just worth noting if the component is ever used in a context where window is unavailable (e.g., SSR tests). Not an issue here.

@tin-berri
tin-berri merged commit 60e03be into litellm_internal_staging Aug 20, 2026
70 checks passed
@tin-berri
tin-berri deleted the litellm_lit5700_fallback_ux branch August 20, 2026 21:55
tin-berri added a commit that referenced this pull request Aug 21, 2026
* feat(ui): add per-key Savings tab to key detail page

Adds a "Savings" tab to the key detail view, showing the same four metrics
and time-series chart as the proxy-wide Cost Optimization view, but scoped
to a single API key.

For org admins, the tab shows the key's full savings across all requests.
Non-admins see only their own requests on the key, with a scope note
explaining the limitation.

Root cause: userDailyActivityCall and userDailyActivityAggregatedCall
never forwarded an api_key query parameter to the backend, even though
both handlers already accept and filter by it.

Changes:

- networking.tsx: Add optional apiKey param to both daily activity call
  wrappers (appended to variadic options tuple for backward compatibility).

- costOptimizationUtils.ts: Extract shared metrics helpers (compressionOf,
  cachingOf, autorouterOf, savedTokensOf, cacheHitRatio) and shortDate
  so both UsageTab and KeySavingsTab use the same formulas and prevent
  divergence.

- useDailyActivityRange.ts: Refactor into useScopedDailyActivityRange(
  accessToken, scope: {userId, apiKey?}) for reuse-by-parameter unbundling.
  Role resolution stays at the entry point (useDailyActivityRange), not in
  a scoped caller. Update test expectations for new 6-arg tuple.

- UsageTab.tsx: Simplify by importing extracted helpers and SummaryCard
  component instead of defining them inline. No behavioral change.

- key_info_view.tsx: Insert "Savings" tab trigger between "Overview" and
  "Settings"; wire TabsContent to new KeySavingsTab component with lazy
  mounting (no keepMounted) to defer daily-activity fetch until tab opened.

- NEW: components/shared/SummaryCard.tsx — Shared presenter for four-tile
  summary row (label + value + hint + optional info popover). Extracted
  from UsageTab so both surfaces show identical tile layout without CSS
  divergence.

- NEW: components/templates/KeySavingsTab.tsx — Per-key view with admin/
  non-admin scope branching, empty-state messaging, same chart toggles
  and info popovers as UsageTab.

- NEW: components/templates/KeySavingsTab.test.tsx — 7 tests covering mount,
  loading state, empty state, scoping, and scope-note visibility.

Authorization: No new permission check. Both backends gate api_key filter
by the same user role check that governs the request itself. Non-admins
must send their own user_id and can only see their own keys.

Tests: 6121 pass (1 pre-existing failure unrelated to this change).

Prior art / collision note:
- PR #37570 (budgets tab) lands in same TabsList hunks as "Savings" tab,
  but different tab names so conflict trivial if both merge.
- PR #37659 (my own) adds progress/cancelled/cancel to DailyActivityRange,
  but this PR uses stable three-field interface from staging.

* fix(ui): scope spend view by the backend's admin-view contract, not all_admin_roles

Greptile flagged org admin handling on the key savings tab. The live bug it
described does not fire today: useAuthorized supplies session-role labels and
all_admin_roles only carries the raw org_admin spelling, so an org admin was
already scoped. That safety was accidental, so replace the predicate with
spendScopeUserId / hasProxyWideSpendView in utils/roles.ts, mirroring the
backend's user_api_key_has_admin_view (proxy admin and admin viewer only, org
admin excluded in both spellings), and use it in both useDailyActivityRange
and KeySavingsTab

Reclassify the KeySavingsTab render test as an integration test per the
repo's unit/integration split, move scope-resolution coverage to roles.test.ts
as a full role matrix, use real session-role values instead of raw ones, and
assert tile totals against non-empty metrics. Replace the nested ternary in
the chart body (frontend-lint error) with flat conditional rendering

* fix(ui): show auto-router savings as the fourth key-savings tile

Cache hit rate had displaced auto-router savings from the fourth slot,
diverging from the org-wide Cost Optimization page's tile order. Match
it: Total / Compression / Prompt caching / Auto-router, with cache hit
rate as a fifth tile.

* fix(ui): drop cache hit rate from the key savings tiles

Keep the four tiles this page is meant to show: total, compression,
prompt caching, and auto-router savings.

* fix(ui): stop an empty api_key from widening a key-scoped activity read

The paginated and aggregated daily-activity wrappers disagreed on an
empty filter value: the paginated one appended it, the aggregated one
coerced it to undefined with || and dropped it. Since the aggregated
call is the one tried first, an empty key hash would have silently
turned a key-scoped read into a proxy-wide one and reported every
key's savings as this key's. Use ?? so both send the filter through
and it matches nothing instead.

* style(ui): satisfy prettier and the inline-object lint rule in key savings tests

* refactor(ui): drop the cacheHitRatio extraction left over from the removed tile

* fix(ui): pass daily-activity filters raw so both transports agree at the null boundary

* refactor(ui): share the savings tiles and totals between both surfaces

The per-key Savings tab and the proxy-wide Cost Optimization tab carried a byte-identical
four-tile block, three long metric-definition strings included, and five identical useMemo
totals. Both now render SavingsTiles and total through useSavingsTotals, so the donut cannot
slice numbers the tile above it disagrees with.

* docs(ui): say request, not mount, in the savings tab comment

The comment claimed mounting eagerly would fire the rollup sweep, which reads as a claim about
the bundle. Only the request is deferred; the module ships with the key page either way.

* test(ui): pin the daily-activity args array against the real caller signatures

The sibling unit test mocks networking, so it checks the positional array against itself and
stays green when the array and a networking signature drift apart. Swapping user_id and api_key
in the aggregated signature alone passes there and fails here on user_id=hash-abc.

* style(ui): hoist the daily-activity query options out of the call argument

The four-property object literal tripped local/no-large-inline-object-arg. The violation predates
this branch, which only moved the line into the annotated range, and the rule count drops 550 to 549.
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