Skip to content

feat(ui): add per-key Savings tab to key detail page - #37693

Merged
tin-berri merged 13 commits into
litellm_internal_stagingfrom
litellm_key_savings_tab
Aug 21, 2026
Merged

feat(ui): add per-key Savings tab to key detail page#37693
tin-berri merged 13 commits into
litellm_internal_stagingfrom
litellm_key_savings_tab

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

What

Adds a "Savings" tab to the key detail view, showing the same savings drivers and time-series chart as the proxy-wide Cost Optimization view, filtered to one API key. Four tiles: total saved, compression, prompt caching, auto-router. A driver the key does not use reads $0.00 rather than being hidden, so the tab always answers "what is this key saving, and from where"

For proxy admins the tab shows the key's full savings across all requests. Everyone else, org admins included, sees only their own requests on that key, with a scope note saying so

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

Implementation

Unbundled scoping: extracted useScopedDailyActivityRange(accessToken, scope: {userId, apiKey?}) so role resolution lives only in the org-wide entry point (useDailyActivityRange), not in every per-key caller

Shared helpers: extracted compressionOf, cachingOf, autorouterOf, savedTokensOf into utils so UsageTab and KeySavingsTab read the same formulas and cannot drift apart

Shared component: extracted SummaryCard, and above it SavingsTiles plus useSavingsTotals, so the proxy-wide tab and the key tab render one tile block and total through one path. Both previously carried a byte-identical four-tile block, the three metric-definition strings included, and five identical useMemo totals, which is how the by-driver donut could have come to disagree with the tile above it

Empty api_key no longer widens the read: the aggregated wrapper built its query with ||, so an empty-string key collapsed to undefined and the parameter was dropped, silently turning a key-scoped request into an unscoped one. Both wrappers now use ??, which only drops null and undefined. The paginated wrapper already behaved this way, so the two transports now agree

Files

  • components/networking.tsx, add an optional apiKey param to both daily-activity wrappers, and fix the || to ??
  • costOptimizationUtils.ts, extract the shared metric accessors and shortDate
  • useDailyActivityRange.ts, split into useScopedDailyActivityRange plus the role-resolving wrapper
  • UsageTab.tsx, render SavingsTiles and total through useSavingsTotals, which the by-driver donut now slices
  • key_info_view.tsx, add the "Savings" tab trigger and wiring, without keepMounted so the rollup request only fires when the tab is opened. The module itself ships with the key page either way
  • utils/roles.ts, spendScopeUserId and hasProxyWideSpendView so org admin is not treated as proxy-wide for this endpoint
  • NEW components/shared/SummaryCard.tsx
  • NEW components/shared/SavingsTiles.tsx
  • NEW components/templates/KeySavingsTab.tsx
  • NEW components/templates/KeySavingsTab.integration.test.tsx
  • NEW useDailyActivityRange.integration.test.tsx

Tests

image

129 tests across the six files this branch touches, all passing with no type errors: roles.test.ts (61), networking.test.ts (37), UsageTab.test.tsx (18), useDailyActivityRange.test.tsx (7), KeySavingsTab.integration.test.tsx (5), useDailyActivityRange.integration.test.tsx (1)

The api_key regression tests are mutation-checked. Reverting ?? back to || fails the aggregated case while the paginated case still passes, which is exactly the divergence the fix closes. A test written only against null would have passed on the broken code, since both transports already stripped null identically

useDailyActivityRange.integration.test.tsx pins the positional args array against the real caller signatures, which the sibling unit test cannot do because it mocks networking and so compares the array only against itself. Swapping user_id and api_key in the aggregated signature alone leaves that unit test green and fails this one on user_id=hash-abc

KeySavingsTab.integration.test.tsx covers tile totals across a multi-day range, empty state distinguished from loading, and the scope note appearing for a non-admin and an org admin but not a proxy admin

Prior art / collision notes

PR #37570 (budgets tab) lands in the same TabsList hunks as the "Savings" tab, under a different name, so a merge conflict there would be trivial

PR #37659 has since merged, adding progress, cancelled and cancel to DailyActivityRange. Staging is merged in and the one conflict is resolved: those three fields now flow through useScopedDailyActivityRange, and the role branch stays spendScopeUserId rather than the all_admin_roles check #37659 carried, which is the org-admin narrowing this PR is for. That PR also added a hook test, which passes unchanged against the split


Note

Medium Risk
Touches spend-data scoping (who can see proxy-wide vs own-user rows) and query serialization for user_id/api_key; a filter drop would over-report savings. Backend still enforces admin view, but the UI now decides what it asks for.

Overview
Adds a Savings tab on the key detail page with the same compression / prompt-caching / auto-router tiles and time-series as Cost Optimization, filtered to one API key. Proxy admins see the whole key; everyone else (including org admins) sees only their own requests, with an explicit scope note. The tab is not keepMounted, so the daily rollup only loads when opened.

Daily-activity networking now forwards api_key. Empty user_id / api_key stay as filters instead of being dropped (the aggregated caller used ||, which could widen a scoped read to proxy-wide). Role checks use new spendScopeUserId / hasProxyWideSpendView so org admin is not treated as proxy-wide.

Shared SavingsTiles, useSavingsTotals, and metric helpers so the proxy-wide usage tab and the key tab total through one path. Tests cover filter serialization, org-admin scoping, and tile totals.

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

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

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a per-key Savings tab with date-scoped savings metrics and charts, extends daily-activity networking calls with an API-key filter, and extracts shared metric and summary-card helpers.

  • Introduces role-dependent per-key activity scoping for administrators and non-administrators.
  • Reuses the cost-optimization savings formulas and chart presentation.
  • Adds component coverage for loading, empty, and scope states.

Confidence Score: 4/5

The org-admin scoping mismatch should be fixed before merging because it silently presents partial savings as the full key total.

The networking tuple and key identifier are correctly wired, but org_admin is classified as a whole-key viewer in the new component while the backend narrows that role to caller-owned activity, producing misleading totals without the scope note.

Files Needing Attention: ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx; ui/litellm-dashboard/src/components/templates/KeySavingsTab.test.tsx

Important Files Changed

Filename Overview
ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx Adds the per-key savings UI and request scope selection, but misclassifies org_admin as receiving whole-key scope.
ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts Extracts a scoped activity hook and correctly aligns arguments for aggregated and paginated fetch functions.
ui/litellm-dashboard/src/components/networking.tsx Correctly forwards the optional api_key filter through both daily-activity wrappers.
ui/litellm-dashboard/src/components/templates/key_info_view.tsx Lazily mounts the new Savings tab and supplies the hashed key identifier used by daily activity.
ui/litellm-dashboard/src/components/templates/KeySavingsTab.test.tsx Covers key rendering and scope states, but the full-component suite uses the unit-test filename instead of the integration-test tier.
ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts Extracts shared savings and cache-hit helpers without changing their formulas.
ui/litellm-dashboard/src/components/shared/SummaryCard.tsx Extracts the existing summary-card presentation into a reusable component.

Reviews (1): Last reviewed commit: "feat(ui): add per-key Savings tab to key..." | Re-trigger Greptile

Comment on lines +48 to +55
apiKey: keyToken,
});

const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;

const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]);

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 Org admin scope is misrepresented

When an org_admin opens this tab, all_admin_roles causes the UI to request whole-key activity and hide the scope note, but the backend narrows the request to that caller's activity. The displayed savings therefore omit other users' requests while being presented as the key's full total.

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

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.

Not live: this prop is a session-role label, so org_admin arrives as Org Admin and was already scoped. Hardened anyway with spendScopeUserId mirroring user_api_key_has_admin_view

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.

You're right — the current diff already uses hasProxyWideSpendView(userRole) and spendScopeUserId(userRole, userId) instead of the raw all_admin_roles.includes(userRole) check I flagged. The role string mismatch issue and the scoping logic are both handled correctly in the updated code. My comment no longer applies. Thanks for the clarification.

Comment on lines +27 to +43
userId="user-123"
userRole="user"
/>
);

expect(screen.getByText("Total saved")).toBeInTheDocument();
expect(screen.getByText("Compression savings")).toBeInTheDocument();
expect(screen.getByText("Prompt caching savings")).toBeInTheDocument();
expect(screen.getByText("Cache hit rate")).toBeInTheDocument();
});

it("shows empty state when no results in range", () => {
vi.spyOn(useScopedDailyActivityRangeModule, "useScopedDailyActivityRange").mockReturnValue(mockActivity());

render(
<KeySavingsTab
accessToken="test-token"

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 Integration suite uses unit naming

This suite renders KeySavingsTab with its real child component tree, so it belongs in the dashboard's integration-test tier with a .integration.test.tsx filename. Keeping it as a unit test bypasses the repository's intended test organization and selection boundary.

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.

Agreed. Renamed to KeySavingsTab.integration.test.tsx and moved scope-resolution coverage to unit tests in roles.test.ts

…ll_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
@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: 4.2 / 5


What works well

Role scoping is correct and well-tested. spendScopeUserId correctly mirrors the backend's user_api_key_has_admin_view gate — org admins are deliberately excluded from proxy-wide view. The dedicated test asserting "differs from all_admin_roles by exactly org_admin" is excellent defensive documentation.

useScopedDailyActivityRange extraction is clean. Role resolution now lives only at the org-wide entry point; the per-key caller isn't silently re-scoped on a second dimension it didn't ask for. Good design.

Shared utils prevent formula drift. Extracting compressionOf, cachingOf, cacheHitRatio, etc. means the two surfaces can't diverge. The reuse of cacheHitRatio inside computeCacheLeakage is a nice cleanup.

No keepMounted on the Savings tab — correct. Avoids an eager daily-rollup fetch for users who never open the tab.

Test coverage is solid. The 7 tests cover the important paths: totals math, edge-case zero denominator, empty vs loading state, and scoping for each role tier.


Issues

1. Null vs undefined inconsistency for api_key between the two networking wrappers (minor bug risk)

In userDailyActivityCall:

api_key: apiKey,   // passes null when no key is given

In userDailyActivityAggregatedCall:

api_key: apiKey || undefined,   // converts null/empty-string to undefined

userId in the paginated call also passes null directly, so this is probably fine if extraQueryParams strips nulls — but that's an assumption about the transport layer worth making explicit. More importantly, || rather than ?? means an empty string "" silently becomes undefined in the aggregated path. If callers ever pass "" as a fallback, the two functions would behave differently. Suggest using ?? undefined in both:

        api_key: apiKey ?? undefined,

2. Positional-args coupling comment signals a fragile interface (pre-existing, but now extended)

The comment in useDailyActivityRange.ts is honest but concerning:

"Positional, and read by two functions whose signatures diverge at index 3: the paginated call takes page there (injected by the hook) and the aggregated one does not."

This is a pre-existing design issue, not introduced by this PR, but the PR extends the positional tuple one step further. Adding a future param (e.g., teamId) will require careful surgery across both networking functions and every test expectation. No action required now, but worth tracking.

3. effectiveSessionRole("proxy_admin_viewer") maps to "Admin", not "Admin Viewer"

Looking at roles.ts, effectiveSessionRole("proxy_admin_viewer") returns "Admin". rolesWithProxyWideSpendView includes both "Admin" and "Admin Viewer", so the function works correctly end-to-end — but the effectiveSessionRole mapping for viewer roles is a latent bug worth a separate cleanup.


Overall this is a well-designed, carefully reasoned feature addition. The role scoping correctness is the most important property for a spend-visibility feature and it's handled correctly. The null-vs-undefined inconsistency is the only concrete code issue worth addressing before merge.

@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 3b592c0. Configure here.

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.
Keep the four tiles this page is meant to show: total, compression,
prompt caching, and auto-router savings.
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.
@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 21, 2026

Copy link
Copy Markdown
Contributor

Score: 4 / 5

Here's the breakdown:


What earns the high mark:

  • Security scoping is correct and well-tested. spendScopeUserId / hasProxyWideSpendView correctly exclude org admin from the proxy-wide read, exactly mirroring the backend's user_api_key_has_admin_view gate. The test that explicitly names org admin in both raw and display-name spellings is the right proof.

  • The || → ?? fix is real and the mutation test is the right way to verify it. The comment explaining why null and empty-string must behave differently is clear and the test file documents the divergence that existed between the two callers.

  • The extraction (SummaryCard, metric helpers, useScopedDailyActivityRange) is genuinely useful — it eliminates formula drift between the two surfaces and the split of role-resolution into the wrapper keeps useScopedDailyActivityRange honest about what it does.

  • 107 tests including the integration tests for the scope note, empty vs. loading state, and tile totals — the coverage matches the risk surface.


What keeps it from a 5:

  1. api_key null-handling is inconsistent across the two transports. The paginated caller passes api_key: apiKey (null stays as null, handled downstream by extraQueryParams), while the aggregated caller uses api_key: apiKey ?? undefined (null becomes undefined, stripped from the object). Both happen to produce the same URL, but the two now diverge at the null boundary in a way that's easy to miss when the next param is added.

  2. user_id: userId ?? undefined in the aggregated call is an undocumented behaviour change. Previously userId || undefined dropped empty strings. The change is benign (user IDs are never empty strings in practice), but it's not called out anywhere and changes observable behaviour.

  3. useScopedDailyActivityRange lives in cost-optimization/_components/ but is now imported directly by components/templates/KeySavingsTab.tsx. That's a cross-directory coupling that leaks cost-optimization internals into a generic templates directory. The type (DailyActivityScope) should probably live somewhere shared if two unrelated surfaces consume it.

  4. The positional args array passed to usePaginatedDailyActivity is a pre-existing footgun that this PR extends by one more slot. The comment acknowledges it, but extending a fragile pattern rather than wrapping it is a deferred risk.

None of these are blockers, but items 1 and 3 in particular are the kind of thing that causes a subtle regression when the next engineer touches either file.

@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 2776356. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

Thanks, this was a useful round. Two fixed as one change, two I want to push back on with evidence. Pushed as 51855fc.

1 + 2, fixed together: the ?? undefined was laundering that bought nothing

These are the same bug, so they get one fix. The key fact is that ?? undefined was already a no-op. appendQuery in lib/http/client.ts does:

if (value === undefined || value === null) continue;

and QueryValue is string | number | boolean | null | undefined. The paginated side's appendDailyActivityQueryParam has the identical guard. So both transports already strip null themselves, and mapping null -> undefined at the call site just moved the same decision one layer up while making the two wrappers look different. You were right that the divergence is easy to miss when the next param is added; the fix is to delete the laundering rather than mirror it:

// Passed raw, matching the paginated caller: both serializers drop null and undefined,
// and both keep "". An empty filter must not vanish, or a request scoped to one user or
// key would silently widen into an unscoped, proxy-wide read.
user_id: userId,
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
api_key: apiKey,

Both wrappers now read identically at the null boundary, and the assumption about the transport is stated instead of implied. Net -2 lines of logic.

On the user_id behaviour change being undocumented: fair, and I've done better than document it. Preserving "" is not incidental, it's the same safety property as api_key: userId || undefined turns an empty user filter into no user filter, which is a silent widening to a proxy-wide read, exactly the failure api_key is guarded against. So it now has the matching test rather than a comment:

✓ keeps an empty user_id as a filter rather than widening the paginated read
✓ keeps an empty user_id as a filter rather than widening the aggregated read

Both new tests are mutation-checked: reverting either wrapper to || fails the aggregated case while the paginated case still passes, which is precisely the drift you flagged. Without them, someone could revert user_id alone and no test would notice.

3, refuting: this is the established pattern, and the file already does it

useScopedDailyActivityRange lives in cost-optimization/_components/ but is now imported directly by components/templates/KeySavingsTab.tsx

The same file already reaches across features, on the base branch, before this PR. Line 6 of useDailyActivityRange.ts as it exists on staging:

import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";

So a cost-optimization hook consumes a usage-feature hook out of that feature's _components. Feature-owned data hooks being imported across directories is how this codebase is built, and there are three more pre-existing src/components/* -> app/(dashboard)/*/_components/* imports on staging (caching/.../types, guardrails/.../TagsInput, vector-stores/.../IndexesTab).

Moving DailyActivityScope to a shared home would make this hook inconsistent with its own dependency: the thing it imports would still live in a feature directory while the thing importing it would not. That's a codebase-wide convention change, and it belongs in its own PR that relocates usePaginatedDailyActivity too, not smuggled into a feature PR as a drive-by.

4, refuting as out of scope: the fix is a cross-feature signature change

The positional args array is a pre-existing footgun that this PR extends by one more slot

Agreed on the diagnosis, and I'll note the comment you saw is load-bearing. But usePaginatedDailyActivity has three production callers:

  • usage/_components/components/UsagePageView.tsx
  • usage/_components/components/EntityUsage/EntityUsage.tsx
  • cost-optimization/_components/useDailyActivityRange.ts

Two of those are owned by the usage feature, not this one. Converting args: any[] to named params means editing other people's surfaces in a PR about a key detail tab, and the any[] type means the compiler wouldn't catch a miss, leaving all three to be verified by hand at review time. Adding one slot to a documented six-slot tuple is a smaller risk than a partially-verified refactor of a shared hook.

Filing it separately is the right call. Happy to be overruled if you'd rather it block this PR.

@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 51855fc. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Score: 4 / 5


Why 4 and not 5

Strengths that push it high:

  1. The ||?? bug fix is correct and important. Empty-string apiKey was silently widening a key-scoped request into a proxy-wide one. The mutation test that verifies reverting ?? back to || fails the aggregated case is exactly the right proof. Without that test, the regression would be invisible on any CI run that only tests null.

  2. Org admin scoping is right. hasProxyWideSpendView deliberately excludes org admin, mirroring the backend's user_api_key_has_admin_view gate. The test "differs from all_admin_roles by exactly org admin, which is the whole point of not reusing it" is a direct statement of the invariant — hard to misread later.

  3. useScopedDailyActivityRange / useDailyActivityRange split is clean. Role resolution stays in one place; per-key callers don't re-derive it. The DailyActivityScope interface documents the contract clearly.

  4. Shared helpers and SummaryCard extraction are justified. Two surfaces with the same formulas will drift without a shared source. The extraction is narrow enough that it doesn't create an abstraction looking for problems.

  5. Test coverage is comprehensive. 107 tests, all four touched files covered, empty-state vs loading-state distinguished, scope note visibility matrix tested.


Two things that keep it from 5:

1. The positional args array in usePaginatedDailyActivity is fragile.
The hook passes a raw array to both the paginated and aggregated callers, and the paginated one injects page at index 3, so every subsequent parameter must be appended to both signatures in the exact same order. The comment documents this:

// Positional, and read by two functions whose signatures diverge at index 3
args: [accessToken, startTime, endTime, userId, true, apiKey],

That comment is the only thing preventing a future contributor from inserting a parameter in the wrong position. An object-shaped args or a thin adapter would close this footgun without a large refactor, but as written it requires discipline to maintain.

2. The eager JS import vs "lazy" framing.
The key_info_view.tsx comment reads: "No keepMounted: this tab sweeps the daily rollup, and mounting it eagerly would fire that on every key page open for people who never look at it." That is true for React rendering — the component won't mount (and therefore won't run useScopedDailyActivityRange) until the tab is opened. But KeySavingsTab is imported statically at the top of the file, so its module is bundled and shipped with the key page regardless. The data fetch is correctly deferred; the bundle is not. The comment's phrase "the rollup only loads when opened" overstates the laziness. Using React.lazy() or Next.js dynamic() would make the framing accurate, but the practical impact is small since this is a UI-only chunk.

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.
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.
…ignatures

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

Copy link
Copy Markdown
Contributor Author

On the positional args: agreed the comment was the only guard, so there is now a test that drives the hook through the real networking callers instead of mocking them. Swapping user_id and api_key in the aggregated signature alone leaves the sibling unit test green and fails the new one on user_id=hash-abc, so the ordering is CI-enforced rather than discipline-enforced. I skipped object-shaped args because usePaginatedDailyActivity types args as any[], so a labeled shape buys no compiler check across its three callers

On the eager import: correct, the module ships with the key page either way, so the comment now says the request is what gets deferred rather than the mount. Separately, both surfaces share SavingsTiles and useSavingsTotals now instead of carrying identical tile blocks and totals

@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 21, 2026

Copy link
Copy Markdown
Contributor

Score: 8/10

The responses to both concerns land well. Here's what tips it to an 8 rather than higher, and what holds it back.

What earns the score:

The ?? over || fix is the most important thing in this PR, and it's correct. An empty api_key that silently widens to a proxy-wide read is exactly the class of scoping bug that produces wrong figures without any visible error, and you've both fixed it and written mutation-checked tests that prove the two transports agree. That's the right level of rigor for spend-data filtering.

The hasProxyWideSpendView / spendScopeUserId split mirrors the backend gate correctly. Org admin deliberately excluded, both raw and session spellings covered, and the test that asserts spendScopeUserId differs from all_admin_roles by exactly org_admin is a good canary — if someone later adds org admin to the proxy-wide list by accident that test fails immediately.

The integration test that drives through the real networking callers rather than mocking them is the right answer to the positional args concern given args: any[]. The mutation test (??|| fails the aggregated case, leaves paginated green) is exactly what makes the regression visible.

What keeps it from a 9:

rolesWithProxyWideSpendView carries "Admin Viewer" but effectiveSessionRole("proxy_admin_viewer") maps to "Admin", not "Admin Viewer". So "Admin Viewer" in that list matches a raw session-role string from some path that doesn't go through effectiveSessionRole. That path isn't tested — the test suite covers raw and effectiveSessionRole-normalized values for proxy_admin_viewer, but not the session string "Admin Viewer" directly arriving at hasProxyWideSpendView. It's probably defensive and correct, but it's untested defensive coverage in a security-relevant list.

The module-level import is fine for bundle purposes; the comment update makes the intent clear, so that's resolved.

Minor: useSavingsTotals exported from SavingsTiles.tsx is a slightly odd home for it (a hook living in a component file). Not a blocker, but if it ever needs to be called without rendering tiles it'll need to move.

@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 697a2ae. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

On the Admin Viewer entry: it is pinned, not untested. roles.test.ts feeds the literal string "Admin Viewer" into spendScopeUserId, which is one line, hasProxyWideSpendView(role) ? null : userId, so the toBeNull assertion only passes while the entry exists. Mutation check: deleting "Admin Viewer" from rolesWithProxyWideSpendView fails two existing tests, "accepts Admin Viewer in either the session-role or raw spelling" and "differs from all_admin_roles by exactly org admin". The grant itself mirrors the backend, user_api_key_has_admin_view returns true for PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY, and the helper only decides what the UI asks for: the endpoint force-scopes a non-admin's null user_id to their own rows and 403s any other id, so a wrong entry here cannot widen a read

On the hook home: the tiles and the by-driver donut are its only consumers and both render beside it. costOptimizationUtils is pure functions, so a React hook is an odd fit there too. It can move when a third consumer appears

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

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Both points hold up under scrutiny.

Admin Viewer: The mutation argument is correct. The test "accepts Admin Viewer in either the session-role or raw spelling" pins it directly, and "differs from all_admin_roles by exactly org admin" would fail if the entry disappeared. The endpoint-force-scopes argument is also right — the backend won't honour a null user_id from a non-admin, so a misclassification here narrows (is rejected), not widens. My concern was misplaced.

Hook home: Agreed. useSavingsTotals is a React hook (calls useMemo), so costOptimizationUtils.ts — which is pure functions — is the wrong file for it. Co-locating it with its only two consumers is the right call at this size.


Score: 4/5

The ||?? fix is correct and the test coverage for it is unusually thorough — the mutation check that reverting ?? fails the aggregated case while the paginated one stays green is exactly the right verification. The role model (spendScopeUserId, hasProxyWideSpendView) mirrors the backend accurately and is tested at both spellings. The component extraction (SummaryCard, SavingsTiles) removes the duplicated tile block cleanly. The integration test pinning positional args against real networking callers is a good catch for a class of bugs the unit test can't see.

The one remaining fragility is the positional args array itself. The comment acknowledges it and the integration test catches drift, but future callers who add a parameter to one signature and not the other won't get a compile-time error — they'll get a silent wrong-field read until the integration test fires. A named-options object on usePaginatedDailyActivity's args would close that permanently, but that's a larger refactor and out of scope here. The current mitigation (documented comment + integration test) is reasonable for now.

Nothing here would block merge.

…ument

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.
…itellm_key_savings_tab

# Conflicts:
#	ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts
@tin-berri

Copy link
Copy Markdown
Contributor Author

Merged staging. #37659 added progress/cancelled/cancel to DailyActivityRange; those now flow through the scoped hook, and the role branch stays spendScopeUserId rather than the all_admin_roles check that PR carried, which is this PR's org-admin narrowing. Its hook test passes unchanged against the split.

@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 21, 2026

Copy link
Copy Markdown
Contributor

Score: 4.5 / 5


What makes this strong:

The ||?? fix is the most important change and it's correct. In userDailyActivityAggregatedCall, userId || undefined silently dropped "" (empty string), making a user-scoped or key-scoped request proxy-wide. ?? only drops null and undefined, preserving "" as a live filter. The test that reverting this fails the aggregated case while leaving the paginated case green is exactly the right mutation check — it proves the two transports were diverging.

spendScopeUserId / hasProxyWideSpendView is the right design. all_admin_roles includes org_admin, but the backend's user_api_key_has_admin_view does not grant org admins a proxy-wide view on the daily-activity endpoint. Extracting a separate list and testing the exact diff (all_admin_roles minus org_admin) means any future change to either constant will immediately surface as a test failure. The test "differs from all_admin_roles by exactly org admin" is especially good.

The shared SavingsTiles / useSavingsTotals extraction removes a real divergence risk. The original code had identical four-tile blocks in two separate components with five separate useMemo totals each. Any arithmetic change to one tile now propagates automatically, and the donut chart and tiles now total through the same path.

The useScopedDailyActivityRange split is clean. Role resolution stays in useDailyActivityRange (the org-wide entry point), and useScopedDailyActivityRange takes a plain {userId, apiKey} struct. KeySavingsTab correctly calls spendScopeUserId itself before passing the scope, and the integration test explicitly verifies the hook receives userId: null for a proxy admin and userId: orgAdminId for an org admin.

The integration test for positional args is smart. The unit test mocks networking and can't catch a transposition between user_id and api_key in the args array because it only compares the array against itself. The integration test drives the real serializer and checks the URL query string, so a swap in either networking signature fails it. The comment in useDailyActivityRange.ts documenting this fragility is the right signal to future readers.


The one design smell worth noting:

The positional args array [accessToken, startTime, endTime, userId, true, apiKey] remains fragile by construction — the paginated and aggregated callers diverge at index 3 (page vs userId), so appending a new filter requires coordinating two signatures through a comment rather than a type boundary. The integration test mitigates this but doesn't eliminate it. A typed options object would make it structurally impossible to misalign, though refactoring usePaginatedDailyActivity to accept one is out of scope here and the current comment-plus-test is a reasonable local solution.


Summary: The filter-widening fix is correct, the role narrowing for org admin aligns with backend behavior, the abstractions prevent drift, and the test strategy (mutation testing + integration URL pinning) is well-matched to the failure modes. The positional-args coupling is the only thing I'd want to see typed away eventually.

@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 e631629. Configure here.

@tin-berri
tin-berri merged commit 0c50286 into litellm_internal_staging Aug 21, 2026
71 checks passed
@tin-berri
tin-berri deleted the litellm_key_savings_tab branch August 21, 2026 21:50
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