Commit graph

142 commits

Author SHA1 Message Date
ryan-crabbe-berri
eda81fff59 feat(ui): shared URL-state layer for tables and tabs
Add useUrlTableState (search, sort, page, page size and filter_<column>
in the query string via one nuqs useQueryStates call, with keyPrefix and
urlKeys for routes that host two tables or need legacy key names) and
useUrlTab (validated ?tab= param with a role-aware fallback). Migrate
the Virtual Keys table onto useUrlTableState with a byte-identical URL
contract and bind the Playground tab strip to ?tab=.

DataTable gains controlled columnVisibility/onColumnVisibilityChange
plus usePersistedColumnVisibility (localStorage per table id), and an
isError prop that keeps the server page clamp from rewriting a
deep-linked ?page= after a failed fetch. Virtual Keys uses both.

The expired-session redirect in handleError now keeps the query string
and hash so the return URL captured on re-login restores the filtered
view instead of the bare list.

Delete useTabRouting and tabRoutes, the pathname tab router left over
from the reverted path-per-tab attempt (#34327, reverted in #34629);
tab persistence has to be a query param on the static export.
2026-09-16 15:51:45 -07:00
ryan-crabbe-berri
69c3212220 fix(ui): gate the usage export on range coverage, not on a fetch being in flight
A loading flag only flips once the fetch effect runs, so the render right after a
date or filter change still reported the previous range as loaded and let an export
read its rows. Stamp the completed range on the hook and compare it during render
instead, the way the tiles already do.

Also stop the failure banner claiming a page loaded when the first request is what
failed, which left it reading 1/1.
2026-09-15 16:21:37 -07:00
ryan-crabbe-berri
9b35954347 fix(ui): block usage export and flag the range when a spend page fails
The Usage page drains the daily activity endpoint page by page. A page that
threw was only logged to the console: the loading banner disappeared, the
partial totals stayed on screen looking final, and Export Data stayed
clickable, so the CSV handed to finance was silently short.

The hook now reports `failed`, PaginationStatusAlerts renders it as an error
banner naming how many pages actually loaded, and the export is blocked with
the reason on hover while the data on screen does not cover the range.
2026-09-15 14:10:23 -07:00
ryan-crabbe-berri
e65b7ff0b8
Merge pull request #40659 from BerriAI/litellm_team_member_table_search_sort_filter
feat(ui): search, sort and role filter for the team member table
2026-09-11 19:37:29 -07:00
yuneng-jiang
cf97b757a4
fix(ui): preserve cleared shared select values (#40795)
Preserve explicit null when shared selectors clear and adapt affected forms, validation, and request payloads. Clear stale dependent relationships and retain required-selection checks. Document project detachment, user model-budget clearing, and routing-compression clearing as deferred follow-ups.
2026-09-11 18:37:08 -07:00
ryan-crabbe-berri
1be930664f
feat(ui): link the User ID and Team ID cells on the Memory page (#40752)
Both columns rendered as dead pills, so tracing a memory row back to its
owner meant copying an id into another page's search box. IdCell grows an
href prop that turns the pill into a client-routed link, and the Memory
columns pass the shared entityLinks helpers so the proxy admin and
dashboard sentinels stay unlinked.

Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
2026-09-11 17:48:44 -07:00
ryan
f61e204a72 fix(ui): reset member table filters per team and index org members once
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 01:52:24 +00:00
ryan-crabbe-berri
06b259e092 fix(ui): name the popover copy buttons after the field they copy
The shared user popover copied alias, email and ID through three copy
buttons that all announced themselves as "Copy ID", so a screen reader
could not tell them apart. IdCell now takes the label, defaulting to the
old text everywhere else.

Also drops the closest("tr") the new link tests used, which put the
testing-library/no-node-access budget over its ceiling, and asserts the
sentinel row leaves User Email and the admin badge unlinked too.

Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
2026-09-10 18:40:05 -07:00
ryan-crabbe-berri
56e2d8846d feat(ui): link the entity cells on the team detail page's keys table
The team detail page's Virtual Keys table showed Organization ID, User
Email, User ID and Created By as dead text, so getting from a key to the
org or user behind it meant copying an id and searching for it.

Those four cells now render as links, reusing the sentinel-aware href
helpers, so default_user_id and the litellm-dashboard team stay plain
text instead of pointing at pages that do not exist.

The Created By cell was a verbatim copy of the Virtual Keys page's user
popover, so that moved into the shared table_cells kit and both tables
now use the one implementation.

Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
2026-09-10 18:38:31 -07:00
ryan
a9bd86b371 feat(ui): search, sort and role filter for the team member table
Rebuild the shared member table on DataTable so admins can search members by name, email or user id, sort by name, email, role, budget and spend, and filter by role. /team/info now returns each member's user_alias so the table can show a human-readable name

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 01:37:48 +00:00
ryan-crabbe-berri
71d1bfb70a feat(ui): link the User, Team and Created By cells on the Virtual Keys page
The key detail page already walks out to the user, team and org behind a key,
but the Virtual Keys table rendered those same values as dead text, so getting
to a team meant copying its alias and searching the Teams page.

User, Team and Created By now render through the shared IdentityCell with an
href, the same hover-highlight-and-chevron affordance the Key column already
uses.

Sentinel ids do not get a link, since they have no detail page to open.
Rather than repeat that check at every call site, teamDetailHref and
userDetailHref now return undefined for "litellm-dashboard" and
"default_user_id", the way modelGroupHref already does for model grants, and
EntityLink falls back to plain text when it has no href, the way BadgeLink
already does. Both sentinels move into src/utils/sentinels.ts instead of
staying as string literals scattered across components.

Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
2026-09-10 15:55:03 -07:00
ryan-crabbe-berri
d05d2a6f05 fix(ui): clamp server-paginated DataTable page index when rowCount shrinks
Server-mode tables kept whatever page index the user was on after the
server's total dropped below it, for example after deleting the last
rows of the final page or when a refetch came back empty. The footer
then read "Page 2 of 1" and "Showing 26-25 of 25" with Previous and
First enabled over an empty body, and every one of the 13 server-mode
consumers was exposed since none of them clamped

The shared DataTable now snaps the controlled page index to the last
valid page as soon as a non-loading rowCount no longer reaches it, so
the fix applies to every consumer without per-table clamps. Loading
responses are ignored so a pending fetch never bounces the user to
page 1
2026-09-04 11:33:26 -07:00
ryan-crabbe-berri
ff97e71652 refactor(ui): type and de-mutate the table scrolling spec, drop CSS narration
DataTable loses the comment that narrated its sticky header classes. The
table scrolling e2e spec now types every management API response it
reads, seeds rows through an immutable reduce instead of pushing into
arrays, and deletes what it seeded in each test's finally block instead
of draining a shared mutable list in afterEach.

Refs LIT-4738

Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D
2026-09-03 17:25:26 -07:00
ryan-crabbe-berri
dc9f40c11f fix(ui): scroll admin table rows inside the table instead of the page
Virtual Keys, Teams, Request Logs and Tags now hand DataTable a bounded
flex chain and use fillHeight, so the app shell main stays the only page
scroller, the rows scroll under a pinned header and the pagination footer
sits at the bottom of the page. DataTable keeps the sticky header inside
its own scroller in maxBodyHeight mode too, which is what let the header
scroll away with the rows on Keys, Teams and Models. Model Hub, Vector
Stores and the team detail keys tab drop their 75vh boxes and flow with
the page scroller.

Adds an e2e spec that fails on the merge base for every one of those
pages and passes at this tip.

Refs LIT-4738

Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D
2026-09-03 17:13:18 -07:00
Yuneng Jiang
eb53639ecb
test(ui): pick select options by role instead of by text
Clicking a Base UI select entry found by text or by a title attribute is a
race. The text node exists one render before the popup finishes entering,
and until then the positioner still carries pointer-events: none, so
user-event refuses the click and the test throws. Querying by role only
matches once the popup is exposed to the accessibility tree, which is after
that window closes.

Route the 37 remaining select interactions through chooseSelectOption, which
does the role query. Instrumenting the converted files shows the text query
resolving while the popup was still pointer-blocked on 6 of 41 samples; the
role query was never blocked.

Seven files kept their text queries because their popup entries carry no
accessible role, so there is nothing to query by.
2026-09-01 12:42:27 -07:00
Yuneng Jiang
c5ba2b5fcf
test(ui): query the screen instead of the render result
Two changes, both about finding elements the way a user finds them.

Twenty-six test files destructured queries off render and called them
bare. Those queries are scoped to the render container, so they quietly
miss anything portalled into the body, and they read as if they were
free functions. They now go through screen.

ChatMessageBubble and the key info panel derived elements by walking
closest/parentElement/firstElementChild and then asserted on the classes
they found. A wrapper element anywhere in between broke them. The bubble
surface, the avatar and the budget reset value now publish a test id, so
the assertions survive markup changes and still fail when the styling
they check actually regresses.

Budgets drop with the counts: prefer-screen-queries 221 to 21,
no-node-access 723 to 716.

The 21 remaining prefer-screen-queries are not all fixable: 18 of them
are within(dialog) results in MCPToolsetsTab, which the rule cannot tell
apart from a render result. Target is 18, not 0.
2026-09-01 00:26:49 -07:00
Yuneng Jiang
2fbea77afa
test(ui): assert DataTable behavior instead of DOM structure
The shared DataTable test reached for elements by CSS selector and by
walking parentElement chains, then asserted on Tailwind class strings. It
had no role queries at all, so a wrapper div anywhere in the render tree
broke it while changing nothing a user sees.

Columns, rows and headers are now found the way a user finds them: by
role and by the text on screen. The compact skeleton row is compared
against the loaded row's height rather than a hard-coded h-8, so renaming
the class no longer breaks the test but shrinking the row still does.

The fillHeight and maxBodyHeight cases stay class assertions. jsdom has
no layout engine, so there is nothing behavioural to assert there. What
they no longer do is derive their elements from incidental nesting: the
three layout wrappers and the header now publish a stable test id, which
is also why the resizer's write-only data-resizer attribute became one.

Budgets drop with the counts: no-container 150 to 133, no-node-access 760
to 723.
2026-09-01 00:19:27 -07:00
Yuneng Jiang
02046fb7b7
fix(ui): keep a deleted-from search query instead of blanking the box
The paginated search select diffs the input against the selected option's
label to recover what the user just typed, so a query started from a picked
value searches for the new text rather than the label plus the new text.
That diff can only express an insertion: it walks a common prefix and a
common suffix and returns what sits between them. A deletion leaves nothing
between them, so every deletion-only edit returned the empty string.

Backspacing once in a field showing a selected label therefore threw the
edit away. The empty result was stored as the query, the controlled input
re-rendered blank, and the server was asked for the unfiltered page instead
of the text the user left in the box.

An edit that yields no insertion but did change the value is a deletion, and
there the remaining text is the query the user means. Insertions and
whole-selection replacements are untouched.
2026-08-29 15:52:48 -07:00
yuneng-jiang
fa25ff2a2e
Merge pull request #38626 from BerriAI/litellm_ui_model_links_team_key_info
feat(ui): link team and key model chips to the models page filtered to that group
2026-08-29 11:26:15 -07:00
ryan-crabbe-berri
4411562a0f fix(ui): satisfy lint and the modelInfoCall arity in the models hook tests 2026-08-29 10:32:29 -07:00
tin-berri
ca0b951a43
feat(spend): report prompt caching savings as total and gateway-attributed (#38134)
* feat(spend): report prompt caching savings as total and gateway-attributed

`prompt_caching_savings_spend` credited every cached request, including caching a
client asked for with its own `cache_control` and caching a provider does implicitly,
so the number overstated what the gateway had any hand in.

Gating that column in place would have fixed the overstatement by changing what the
column means, leaving rows written before the change saying "all caching savings" and
rows after saying "gateway-injected only" with nothing to tell them apart, and forcing
a decision about rewriting history. It also breaks the cache-leakage estimate on the
dashboard, whose numerator would be gated while its denominator, the cached token
counts, would not, so the rate it extrapolates from would be quietly diluted.

Report both instead. `prompt_caching_savings_spend` keeps meaning every net dollar
caching saved, which is what a customer means by "what did caching save me", and the
new `gateway_injected_caching_savings_spend` carries the subset litellm caused by
injecting the breakpoints itself. Both are derived from the same marker, so this
changes what is done with it rather than how it is obtained.

The attributed figure is normally the smaller of the two, being a subset of the same
requests, but not always: a request that writes cache it never reads has negative net
savings, and excluding such a request can lift the attributed figure above the total.

Also stops the marker riding into a fallback leg. The fallback rebuild spread the
failed attempt's metadata forward, so a deployment that injected nothing inherited the
marker and was credited anyway, which silently restored the very overstatement this
separates out.

* fix(bedrock): credit gateway caching where the tool cachePoint is placed (#38478)

The savings marker records breakpoints litellm placed, and a tool_config
injection point becomes one only in the converse transform, and only when the
request carries tools. The prompt hook cannot see either condition, so marking
on the point's presence credited request shapes that cached nothing, while
Bedrock tool caching the gateway did cause went uncredited.

Record it at the placement site instead. The marker's reader also resolves its
bucket by value now: litellm_params declares litellm_metadata as None on every
request, so asking the shared name resolver named a bucket that was not there
and the mark was dropped.
2026-08-28 00:19:06 -07:00
ryan-crabbe-berri
56a80c8125 feat(ui): link team and key model chips to the models page filtered to that group
Model chips on the team info and virtual key info pages (overview and settings tabs) now link to /models-and-endpoints?model_group=<name>, which the All Models tab reads through a new nuqs-backed model group filter. Grant sentinels such as all-proxy-models stay plain badges. The selected group is also sent as the server-side search so the matching deployments are fetched even when they are beyond the first page.
2026-08-27 21:08:33 -07:00
ryan-crabbe-berri
141c91404f fix(ui): reason-gate the remaining server-searched comboboxes
Migrate the create-key user picker, add-member user search, and usage team filter onto the shared paginated selects, gate the logs error-code filter on input reasons, and add clearAllLabel, autoHighlight, and aria-required passthroughs the migrations need.
2026-08-27 14:22:10 -07:00
ryan-crabbe-berri
bf86eadd83 fix(ui): take whole-selection edits verbatim in the paginated search select
Select the picked label on focus and snapshot whether the pre-edit selection covered the whole input; when it did, the next input value is a full replacement, so skip the typedInsertion diff that mangles pastes sharing a prefix or suffix with the label.
2026-08-27 14:07:02 -07:00
yuneng-jiang
3746ba58d7
fix(ui): let the paginated search select keep what the user types (#38475)
* fix(ui): let the paginated search select keep what the user types

The combobox handed Base UI a freshly built option object for the current
selection every time a page of results came back. Base UI answers a changed
value by rewriting the input with that option's label, so every search response
wiped the query mid-typing and the list never narrowed. Once a user had been
picked in the Usage page filter box, no other user could be reached.

The component now owns the input text. It holds the query while the list is
open, falls back to the selected option's label once the list closes, and
remembers the picked option so its label survives later pages that no longer
carry it, the way the multi-select sibling already does.

* refactor(ui): name the paginated select's search state instead of commenting it

* fix(ui): start a fresh query when typing lands on the selected label

Focusing the filter box without clicking it leaves the caret at the end of the
selected option's label, so the next keystroke extended that label into a query
no server could match. Only a click cleared the box first.

A keystroke that arrives while the box is showing a label is now read as the
start of a new query, wherever in the label it landed.
2026-08-27 13:32:20 -07:00
yuneng-jiang
1a431687f0
Merge pull request #38306 from BerriAI/litellm_/page-header-spec-rollout-46f0dd
refactor(ui): move every page header onto the shared PageHeader
2026-08-26 13:42:56 -07:00
ryan-crabbe-berri
32dac12f9b
Merge pull request #38282 from BerriAI/litellm_ui_zindex_scale
refactor(ui): replace hand-picked z-index values with one named scale and lint it
2026-08-26 12:52:01 -07:00
Yuneng Jiang
7c777a0b4b
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/page-header-spec-rollout-46f0dd 2026-08-25 23:19:45 -07:00
Yuneng Jiang
ca77b32454
refactor(ui): move every page header onto PageHeader and drop the legacy one
Virtual Keys, Budgets, Projects, Access Groups, Guardrails Monitor and Cost
Optimization all move onto the shared PageHeader, matching the Teams page.
That empties LegacyPageHeader, so it and its test are deleted.

Each page now uses its own sidebar icon, so the nav and the page agree:
Virtual Keys keeps KeyRound and Budgets keeps Wallet, Projects picks up
Folder and Access Groups picks up Boxes, and Guardrails Monitor swaps the
indigo Shield for the sidebar's HeartPulse. Cost Optimization keeps
PiggyBank but drops its hardcoded size and stroke, which PageHeader owns.

Control rows follow the spec instead of each page inventing one. Virtual
Keys had its create button rendered as a sibling below the header, Budgets
hand-rolled a row with a bottom border that closed the header off, and
Projects and Access Groups sat their button next to the title. All four now
pass primaryAction. Guardrails Monitor's date picker moves out of the parent
and joins Export Data in utilities. Cost Optimization's tabs move into the
tabs slot with the standard 22px spacing.

Page insets go to p-8 with a 24px gap to content, replacing p-6 px-12,
p-6, mx-4 and py-2.

Every page test now asserts its heading, subtext and sidebar icon. Swapping
any of the six icons fails its suite.
2026-08-25 23:19:22 -07:00
Yuneng Jiang
a5b6f31585
refactor(ui): install the shadcn alert primitive
components/shared/Alert.tsx was base-vega's own alert.tsx copied in by
hand, carrying the same four exports and the same class strings, so
npx shadcn add could never reach it and it would drift from every
upstream fix silently. It also still wrapped each part in forwardRef,
which React 19 no longer needs.

Install the primitive into components/ui/ where the CLI can update it,
and reduce the shared file to a wrapper that adds the four status
variants (info, success, warning, error) the dashboard actually uses on
top of upstream's default and destructive.

Rendered output is unchanged: every variant produces byte-identical
classes, role and data-variant, so all 45 call sites look the same.
2026-08-25 21:24:07 -07:00
ryan-crabbe-berri
6385b6d801 refactor(ui): replace hand-picked z-index values with one named scale and lint it
Regression LIT-6143 (the policy Flow Builder painting its guardrail dropdown
underneath a position: fixed shell at z-index 1000) was one instance of a
class of bug: pages picking their own z-index numbers above the portalled
popup layer. This removes the class.

- globals.css defines the only z-index values in the dashboard as Tailwind
  utilities: z-raised, z-chrome, z-sticky, z-sticky-pinned, z-floating,
  z-overlay, z-popup; every numeric, arbitrary and inline z-index across
  src is migrated onto them and tailwind-merge learns the tokens
- new local/no-ad-hoc-z-index ESLint rule bans z-<n>, z-[...], z-(...) and
  inline zIndex everywhere, and reserves z-popup for the portalled
  primitives in components/ui (and the DataTable menus)
- the Flow Builder renders in the dashboard content area instead of as a
  fixed full-screen overlay, so it has no stacking level at all
- the guardrail content-filter Add keyword / Add pattern / Custom pattern
  dialogs drop the leftover z-[1100] (renamed from ABOVE_ANTD_MODAL when
  antd was removed) that hid their own Action select and pattern combobox
  behind the dialog, the same bug as LIT-6143
2026-08-25 17:02:35 -07:00
yuneng-jiang
6147b3ce6e
refactor(ui): install the shadcn field primitive (#38126)
* refactor(ui): install the shadcn field primitive

`components/shared/form/field.tsx` was the upstream base-vega `field` source
living outside `components/ui/`. It exported the same ten symbols as upstream,
so `npx shadcn add` could never update it and it had already drifted: its
`FieldLabel` was missing the hover and focus-visible ring utilities upstream
now ships for labels that wrap a nested field.

Install the primitive and point the 77 importers at it. The copy is deleted
rather than kept as a wrapper because it added nothing beyond `forwardRef`,
which React 19 makes unnecessary since `ref` arrives as an ordinary prop.

`field.test.tsx` moves next to the primitive with no edits to its contents,
and its nineteen tests, ref assertions included, pass against the generated
file. That is the evidence the swap is behaviour-preserving.

Two nested-field call sites pick up the upstream hover and focus-visible
styling that the stale copy had been missing.

(cherry picked from commit 947f7fa674c83bfc57f43ad8bfc89c894da947a2)

* test(ui): cover the nested-field interaction cues FieldLabel had lost

The stale copy of `field` was missing the hover, focus-visible and disabled
selectors upstream applies to a label that wraps a nested field, so installing
the primitive restored them with nothing asserting they stay.

Assert the class contract rather than the rendered effect. jsdom evaluates
neither `:has()` nor `:focus-visible`, and Tailwind is not compiled under
vitest, so a test that clicked or tabbed would pass on an element with no
styling at all. Checking the utilities are present is the assertion that
actually fails when they go missing, which is the way they were lost before.

Verified by stripping the four selectors from the primitive: both tests fail,
and both pass once it is restored.

(cherry picked from commit 5a5dbf64270d9d1285dbc4a7af76bb3d927778a8)
2026-08-24 14:30:32 -07:00
yuneng-jiang
47c988e05c
refactor(ui): move the dashboard onto class-variance-authority (#38125)
The dashboard used `cva@1.0.0-beta.4` with the object-argument API behind
`@/lib/cva.config`, while shadcn emits `class-variance-authority` with the
positional API. Every `shadcn add` of a cva-based primitive therefore needed a
hand fix-up before it compiled, which meant `components/ui/` could never match
a fresh CLI run and `shadcn add <name> --diff` reported the whole file as
changed instead of showing real upstream drift.

Swap the dependency, and regenerate `badge`, `button`, `button-group`,
`input-group` and `tabs` straight from the base-vega registry so they are now
byte-identical to the CLI output plus prettier.

Two primitives could not be regenerated because they are local code rather
than registry items, so they move out of `components/ui/`: `sidebar` (203
lines against upstream's 730, and only `leftnav` consumes it) and `meter`
(no registry entry at all, it wraps Base UI's Meter).

The customisations that were baked into the regenerated files move to
wrappers, following the rule that `components/ui/` holds CLI output and
anything on top of it lives outside:

- badge carried info, success and warning variants that duplicated the
  existing `StatusBadge` tone map, so its five call sites now use
  `StatusBadge`, which gains an optional `className`
- input-group's addon focuses `[data-slot=input-group-control]` rather than
  upstream's `input`, which matters because the chat composer puts a textarea
  there. That handler now sits at the one call site that needs it

`cx` keeps its previous twMerge behaviour. It came from the old
`defineConfig({hooks: {onComplete: twMerge}})`, and CVA's own `cx` is plain
clsx, so pointing it at `cn` avoids silently dropping conflict resolution in
the six files that use it.

`Sidebar.test.tsx` covers the failure mode this migration can hide: passing
the object form to the positional API is accepted by clsx and renders the
literal class string "base variants defaultVariants", so the component loses
every style while the type checker and the existing suite stay green.
2026-08-24 14:10:31 -07:00
yuneng-jiang
5f56be3294
fix(ui): theme the created-key box so it follows dark mode (#37985)
The virtual key shown after creating a key sits in a div with a
hardcoded #f8f8f8 inline background, so in dark mode the box keeps
the light background while the key text inherits the light foreground
color, leaving the key nearly unreadable. Swap the inline styles for
the bg-muted and text-foreground tokens, which resolve per theme.
2026-08-24 10:12:16 -07:00
tin-berri
0c50286a55
feat(ui): add per-key Savings tab to key detail page (#37693)
* 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.
2026-08-21 14:50:02 -07:00
tin-berri
60e03bedcf
fix(ui): surface the paginated fallback on Cost Optimization (#37659)
* fix(ui): surface the paginated fallback on Cost Optimization

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.

* fix(ui): gate the cache leakage streaming note on isFetchingMore only

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.
2026-08-20 14:55:00 -07:00
yuneng-jiang
7ac95b1cee
fix(ui): make hardcoded palette surfaces theme-aware (#37650)
* fix(ui): make hardcoded palette surfaces theme-aware

Twenty-one dashboard files painted fills from the raw Tailwind palette with no
dark counterpart, so in dark mode they rendered as near-white islands carrying
dark text: unreadable. The route sweep caught them on teams, access-groups,
policies, users, skills, guardrails-monitor, logs, compliance, playground,
fallbacks and the AI hub.

Where the hue already had a semantic token, the surface moves onto it. Every one
of these lines had a token on its border and a palette class on its fill, so
this finishes a migration that had stalled halfway: bg-blue-50 next to
border-info/20 becomes bg-info, bg-gray-50 becomes bg-muted, DocLink's bg-white
becomes bg-card, and the Alert error variant drops text-red-800 and text-red-600
for the destructive token its sibling variants already use.

Purple, violet and indigo have no token in the system, which is exactly why the
maps in PluginTableColumns, GuardrailsOverview, AccessGroupsTableColumns and
teamTableColumns had migrated every other entry and left those behind. Rather
than mint a brand token here, they take the dark palette step, matching what
TeamGuardrailsTab, add_agent_form, MCPToolsetsTab and mcp_connect already do.
Gradient stops get the same treatment since bg-linear stops have no token form.

Light mode is unchanged apart from the four surfaces that moved onto a token,
and those stay inside the same colour family.

The #1e1e1e code slabs in guardrail_info and CustomCodeModal are deliberately
left alone: they are intentionally dark editors in both themes, and their
gray-200 text stays legible either way.

* fix(ui): give dark surfaces a readable foreground step

The dark fills added for the purple and indigo surfaces left three nested
foregrounds on their original light-palette step, so the text and icon sitting
on those new fills dropped below readable contrast in dark mode.

text-purple-800 on purple-950 measured 1.72:1, text-indigo-600 on indigo-950
2.54:1, and text-purple-600 on the blue-950 gradient stop 2.73:1. Each now
takes the purple-300 / indigo-300 step this PR already uses elsewhere, which
lands them at 8.48:1, 8.02:1 and 8.31:1.

The pricing calculator renders the same cost expression twice, so both copies
move together rather than leaving one half-migrated.

* fix(ui): keep the guardrail chip remove button visible on hover

The chip itself moved to the dark indigo fill, but its remove button still
darkened to indigo-900 on hover, which against indigo-950 measures 1.40:1 and
makes the X vanish under the cursor in dark mode.

Dark mode now brightens to indigo-100 on hover instead, mirroring the light
theme where hover darkens away from the resting colour.
2026-08-20 18:03:15 +00:00
yuneng-jiang
c794dcb91d
fix(ui): give status colours a readable foreground and drop the muted 70% step (#37649)
* fix(ui): give status colours a readable foreground and drop the muted 70% step

The four status tokens are lightened for dark mode, which is correct when they are used as text
and wrong for the 27 places that use them as a background under `text-white`. Every one of those
passes in light and fails in dark: success 1.78:1, warning 1.72:1, info 2.64:1, destructive
2.89:1. The cause is not 27 authoring mistakes, it is that no `--success-foreground` and no
sibling ever existed, so `text-white` was the only thing available to write. Adding the four
companions and registering them in `@theme` makes the correct pairing expressible, and the call
sites then read `text-success-foreground` instead of a hardcoded colour. Dark lands at 9.98, 10.31,
6.72 and 6.15.

Light is deliberately pure white rather than the near-white the other `-foreground` tokens use, so
the four ratios stay at exactly the 4.95, 5.03, 5.25 and 4.77 they are today instead of drifting
down to 4.73, 4.81, 5.02 and 4.56.

Separately `text-muted-foreground/70` measures 2.75:1 on a light page and 4.31:1 on a dark one,
so the same 183 occurrences fail AA in light and sit under it in dark. Dropping the opacity step
takes them to 4.84:1 and 7.34:1. The identical step on the placeholder base rule goes with them,
which is what put every input's placeholder at 2.75:1 in light.

Residual, not addressed here: `text-muted-foreground` over `bg-muted` reaches 4.39:1 in light,
still short of 4.5. Closing that needs `--muted-foreground` itself to move, which changes every
secondary label in the product and is a design call rather than a defect fix.

* fix(ui): finish the status-foreground swap and repoint no-op muted hovers

Four sites still forced text-white on a status fill because the class sat on
a child element rather than on the filled container, so the earlier sweep did
not reach them. The compliance quick-test bubble was worse: it paired bg-info
with text-success-foreground and its paragraph kept text-white on top, so the
dark-theme contrast the PR set out to fix was still reachable there

Dropping the /70 step also turned 21 existing "text-muted-foreground/70
hover:text-muted-foreground" pairs into hovers that change nothing, which
local/no-noop-hover-variant flags as an error. The affordance was "brighten on
hover", so these now hover to text-foreground, matching the 74 places that
already spell it that way

The remaining churn is prettier reflowing the handful of lines whose length
changed, since the token names are longer than text-white

* fix(ui): let the approve/reject confirm button pick the token its fill uses

Both submission review dialogs put text-success-foreground on the shared
button class while the fill below it swings between bg-success for Approve and
bg-destructive for Reject, so Reject drew a success token over a destructive
fill. The two tokens resolve to the same value today, so nothing looks wrong,
but the pairing only holds by coincidence and would break the moment either
token moves. Moving the token into the branch makes it track the fill

* fix(ui): drop the last 70% placeholders, still live on the legacy utility

Four inputs spell their placeholder colour with Tailwind's older
placeholder-<colour> utility rather than placeholder:text-<colour>, so the
sweep that dropped the 70% step passed over them. Tailwind 4.3 still emits
that utility, and utilities sit after base in the layer order, so those four
kept overriding the new input::placeholder rule and kept rendering at 70% in
dark mode, which is the contrast failure this PR set out to close

They now spell it the same way as the three placeholders the PR already
converted, which both removes the step and settles on one spelling
2026-08-20 10:58:32 -07:00
tin-berri
d2d158f271
feat(ui): multi-key shadow eval picker and per-key breakdown (#37389)
Stacked on the multi-key shadow eval backend. The key picker becomes a
paginated multi-select with chips, built on the base-ui combobox chips
primitives, with the pagination and debounced-search logic extracted into a
shared usePaginatedCombobox hook that PaginatedSearchSelect now also uses.
The detail view gains a per key table showing each key's own status, judged
turns against its budget, and win rates from the by_key slice, and the job
headline pluralises to "N keys" for multi-key jobs
2026-08-20 17:43:27 +00:00
yuneng-jiang
93c1461074
fix(ui): restore hover feedback and dark-mode variants lost in the token migration (#37579)
* fix(ui): restore hover feedback and dark-mode variants lost in the token migration

PR #37576 mapped hardcoded Tailwind palette classes onto semantic tokens. Two-tone
hover pairs collapsed onto a single token, so 116 hover utilities across 49 files
became identical to their base class and produced no visible feedback, and in seven
files a dark: variant was dropped while its hardcoded light partner survived, leaving
those elements stuck light in dark mode.

Hover states now follow the alpha-step idiom the shadcn primitives already use
(hover:bg-primary/80, hover:bg-success/20): a duplicated hover:text-X or hover:bg-X
becomes /80, hover:border-border becomes hover:border-ring, and a duplicate is
dropped where another hover utility on the element already carries the change. One
transition-colors that no longer animated anything is removed.

For the dark-mode gaps, indigo maps onto info and amber onto warning. There is no
purple token in globals.css, so the purple sites keep their palette classes and get
their dark: partner back.

* fix(ui): add an eslint rule that fails a hover: utility identical to its base

The token migration collapsed two-tone hover pairs by hand, so nothing catches
the next one. `local/no-noop-hover-variant` reads every string literal and
template chunk and errors when a `hover:X` sits alongside a bare `X`, which is
exactly the shape that renders no hover feedback. It ships at error with no
suppression baseline, so the eleven sites that already carried a dead hover
before the migration are fixed here too.

The rule reads one class string at a time, so a base class supplied by a
different ternary branch than its hover partner is left alone: a selected row
whose resting colour already matches its hover colour is deliberate, not a bug.
2026-08-19 22:52:36 -07:00
ryan-crabbe-berri
57b328ff96
refactor(ui): map hardcoded Tailwind palette classes onto semantic tokens (#37576)
* refactor(ui): map hardcoded Tailwind palette classes onto semantic tokens

The dashboard painted itself with literal palette utilities (text-gray-500,
bg-blue-50, border-red-200) that resolve to one fixed color regardless of
theme, so the shadcn token layer and its .dark block could never take effect.

A codemod (scripts/codemod-color-tokens.mjs) rewrites 3,232 of those across
254 files onto the existing token scale: neutrals become foreground /
muted-foreground / muted / border / card, and red, green, amber and blue
collapse into destructive, success, warning and info, with the pale -50 to
-300 tints expressed as opacity modifiers on the same token. Hover and focus
variants map to accent so they lift rather than recess. 210 now-redundant
dark: variants are dropped since the tokens carry both modes.

The .dark palette is retuned to a neutral gray ramp with the sidebar recessed
below the content canvas, replacing the blue-tinted shadcn default where the
sidebar read as a full-height card floating on a near-black page.

Nothing sets the .dark class yet, so light mode is unchanged and dark mode
stays inert until a theme toggle lands.

* chore(ui): drop the one-shot color-token codemod script
2026-08-19 22:11:44 -07:00
Yuneng Jiang
f99eec5ecb
Merge branch 'litellm_internal_staging' into litellm_standard_page_header
Teams.tsx and Teams.test.tsx both conflicted with staging's antd -> shadcn
migration of the team create form.

Teams.tsx: took staging's rewritten import block and dropped `theme` from the
antd import, since this branch replaced `<Content style={{ padding: token... }}>`
with the Tailwind inset. Dropped both `const { Text } = Typography` (staging
removed its last use) and `const { token } = theme.useToken()` (this branch
removed its last use).

Teams.test.tsx: took this branch's PageHeader-shaped assertions over staging's
older tab-bar lookup, and restored the `within` import that staging had dropped.

Removed the `toHaveClass` snapshot of the antd tab-bar Tailwind classes and the
`.closest(".ant-tabs")` lookup: staging added local/no-antd-class-selectors as a
zero-violation error rule, and those assertions are inert in jsdom anyway. Every
behavioural assertion in that test is unchanged.
2026-08-19 18:45:04 -07:00
ryan-crabbe-berri
0ab1725757
refactor(ui): migrate the model and router settings pages off antd (#37523)
* refactor(ui): migrate the model and router settings pages off antd

Converts the add model flow, credential panels, model settings and router
settings onto the shadcn primitives, moves the mapping table onto the
shared DataTable, and drops the dead uploadProps prop chain that only
existed to carry antd's UploadProps type.

* fix(ui): split comma-separated custom technical keywords into one term each
2026-08-20 01:19:09 +00:00
ryan-crabbe-berri
cac4870271
refactor(ui): migrate the MCP servers pages off antd (#37522)
* refactor(ui): migrate the MCP servers pages off antd

Converts the MCP server create, edit, connect and permission screens plus
the MCP tools and selector components onto the shadcn primitives, and
rewrites the test helpers that drove antd's select and collapse DOM.

* fix(ui): finish the MCP servers antd migration so the shared field rules have one contract

mcpFieldRules and MCPPermissionManagement were already flipped to the shadcn
prop shape, but CreateMCPServer and UserEnvVarsModal were still rendering antd,
so the create modal spread onValueChange onto an antd Select that ignores it and
passed searchValue props that no longer exist. Convert both off antd, drop the
searchValue plumbing the MultiSelect now owns, and normalise tag values before
the tag list renders so a delimited or empty string cannot crash it.

Rewrite testUtils.selectOption to drive the shadcn listbox instead of
.ant-select, expand the collapsed permission panel before querying its switches,
and assert the dismiss case after a reopen now that Dialog unmounts closed
content.

* fix(ui): split multi-tag entries the MCP tag inputs commit as one value

The tag input hands back whatever the admin typed as a single custom value, so
"read,write" was stored verbatim and reached the backend as one malformed scope.
tagsControl already split delimited values on the way in; run the same
normalisation on the way out and dedupe, so both directions agree.

* fix(ui): stop splitting tag entries that are not scope lists

The previous commit split every tag field on whitespace and commas, but only a
scope list is delimited. A stdio arg or an access description item may contain
both characters as part of the value, so splitting them changed the argv the
process receives. Keep those entries verbatim and move the splitting behind
scopesControl, which the OAuth, token exchange and ID-JAG scope fields use.

* fix(ui): split tag entries on comma only, matching the antd token separator

Every tag field here was an antd Select carrying a comma token separator, so a
comma committed a tag and nothing else did. Splitting on whitespace as well
broke stdio args, and splitting neither left comma-separated extra headers and
access groups stored as one malformed value. Apply the comma rule in both
directions, trim each entry, and drop the scope-specific helper the previous
commit added, since the backend types scopes as a list rather than the
space-delimited string that helper assumed.

* fix(ui): stop rewriting stored tag values that an admin never edited

Stdio args are process argv, so a comma inside one argument and a
deliberately repeated flag both have to survive a round trip through the
edit modal. Two places were rewriting them. tagsControl split and deduped
the value it read back from the server, and MultiSelect re-split every
already-committed chip on each change rather than only the entry just
typed. Both now leave settled values alone, which keeps the antd token
separator applying to typing and nothing else.
2026-08-20 00:50:02 +00:00
ryan-crabbe-berri
fc8a6b2a8d
refactor(ui): migrate shared primitives and common components off antd (#37521)
* refactor(ui): migrate shared primitives and common components off antd

Adds the success variant to the shared Alert plus success, warning and
info variants to Badge, introduces UtcDateTimeInput to replace antd's
DatePicker, and converts the common components and key/team helpers onto
the shadcn primitives.

* fix(ui): keep MultiSelect and budget input faithful to their antd behaviour

Restore the clear-all control MultiSelect lost, split comma-separated
custom entries into one value per token, and stop rounding the budget
input on every keystroke so a fractional amount survives typing.

* test(ui): drive the access group picker through the migrated MultiSelect

AccessGroupSelector no longer renders an antd Select, so the placeholder
is an input label rather than a text node and the popup inerts the page
until it closes.
2026-08-20 00:25:09 +00:00
ryan-crabbe-berri
629d7683f2
refactor(ui): swap @ant-design/icons for lucide-react (#37553)
The dashboard drew its icons from two libraries at once: lucide-react,
which shadcn/ui ships with, and @ant-design/icons, left over from antd.
This moves the last 39 files onto lucide and drops the dependency, so
the icon set matches the component library everywhere.

antd icons sized themselves from the inherited font-size and rendered as
role="img" with an aria-label, neither of which a lucide svg does, so the
swap carries explicit size classes and gives the two icon-only plugin
buttons real accessible names.
2026-08-20 00:04:14 +00:00
ryan-crabbe-berri
5d6033f8b4
refactor(ui): migrate the remaining dashboard pages off antd (#37524)
* refactor(ui): migrate the remaining dashboard pages off antd

Converts the teams, usage, guardrails, vector stores, cost tracking,
agents, policies, login and onboarding screens onto the shadcn
primitives, including the team info tab shell and the virtual keys
hover cards.

* fix(ui): close out the antd migration's failing type checks and tests

Alert and Badge were missing the success and info variants their call
sites already used. Combobox dropped disabled because Base UI merges the
primitive's own props over the render child, so the flag never reached
the input, and the guardrails status filter had no accessible name, which
left two comboboxes indistinguishable to the tests.

The remaining test updates swap antd's title-based queries for the roles
the shadcn controls expose.
2026-08-19 22:46:55 +00:00
yuneng-jiang
4bb3152cc5
test(ui): drive fields with change events where the typing is not the behaviour (#37495)
user.type dispatches one event per character and re-renders the whole form on
each one, so a test that only needs a field to hold a value pays for every
keystroke. Where the value is all the test wants, fireEvent.change sets it in
one go.

Measured against a control on the same tree: the 100 converted files went from
654s to 619s of CPU, 5.4% cheaper, while the files nobody touched drifted 1.2%
the other way. Modest, and honest about it.

Rolled out one file at a time, running each before and after and keeping the
conversion only where the file stayed green. That rejected 35 files, all cases
where the keystrokes are the behaviour: Base UI comboboxes drive their filter
from real keyboard input and ignore a raw change event, and the same goes for
autocompletes, debounces and key handlers. Those keep user.type.
2026-08-19 12:11:37 -07:00
yuneng-jiang
b9a267c693
refactor(ui): migrate the teams form graph off antd Form onto react-hook-form (#37417)
* test(ui): pin the teams create and update payloads before the form migration

The teams graph (Teams.tsx, TeamInfo.tsx and the MetadataKeyValueFields child they
share) is next for the antd Form to react-hook-form migration, and its submit payload
is a function of which collapsible sections the user happened to open. Nine sections
across the two files use the shadcn Collapsible, none of them passes keepMounted, and
Base UI unmounts the closed branch, so a closed section registers nothing and its keys
never reach the request body.

That matters beyond parity. /team/update reads the body with exclude_unset, so an
omitted key is never written, while an explicitly null team member budget key reaches
clear_team_member_budget_fields and nulls max_budget, budget_duration, rpm_limit and
tpm_limit on the shared budget row. antd cannot reach that today because the field is
unregistered rather than null. A port that seeds those fields or coalesces on the way
into the payload would turn a save with the section never opened into a silent clear.

The coverage that shipped with the team modal reached one of the four gating sections
on the create side and asserted key sets rather than the request body, so a null where
antd sent undefined would have passed. These cases assert both the raw payload and its
JSON round trip with toStrictEqual, which is what separates absent from null from
undefined, and they cover every gating section on both screens.

Also pinned, because each is a live behaviour a port can quietly change:

- the create path sends max_budget, tpm_limit and rpm_limit as strings, while
  team_member_budget arrives as a number through its normalize prop
- an invalid secret manager config blocks the create with its rule message suppressed
  by the item's help prop, so nothing is shown to the user
- the disable global guardrails switch is inert for a non premium user
- a value typed into a section survives collapsing and re-expanding it

Verified by adding keepMounted to all nine panels, which is the change a porter reaches
for on noticing that fields go missing: 35 of 118 went red, including every one of these
cases. The files were restored byte identical afterwards.

No production file changes here. 145 tests pass across the three files.

* refactor(ui): migrate the teams form graph off antd Form onto react-hook-form

Teams.tsx and TeamInfo.tsx were the last large antd `Form` graph in the
dashboard. Both now use `useZodForm` + `FormField`, with the shared
`MetadataKeyValueFields` child converted to a `useFieldArray`.

antd only returns the mounted registered fields from `onFinish`, so a
closed collapsible contributed no keys at all. react-hook-form keeps
unmounted values in the store (and `shouldUnregister: true` would lose
them on re-expand), so both forms project the submitted values through
the currently mounted section list before handing them to the existing
payload builders. Closed sections therefore still produce absent keys
rather than nulls, which matters at /team/update where an explicit null
clears the shared budget row.

Widgets that had no shadcn equivalent are replaced with the existing
shared ones: SearchSelect for the organization pickers, MultiSelect for
default member models, TagsInput for guardrails/policies, and a new
GuardrailsSelect for the grouped global/other guardrail dropdown.

* refactor(ui): forward the field ref to NumericalInput in the teams forms

staging turned NumericalInput into a forwardRef, so the teams graph can stop
dropping the react-hook-form ref on the floor.

* test(ui): pin the capability gate, required rules and guardrail kill switch

A mutation run over the ported teams forms found five survivors the payload
cases did not reach: the viewPolicies gate on both forms, the team name rule
on both forms, the guardrail kill switch resync, and the number coercion on a
typed model rate limit. Six cases close them.
2026-08-18 22:37:04 -07:00
yuneng-jiang
1bc1a5ed81
refactor(ui): migrate the key edit form off Ant Design onto react-hook-form (#37398)
* refactor(ui): migrate the key edit form off Ant Design onto react-hook-form

The key editor was the last antd Form in the key flow. antd's store decided
the saved payload implicitly: onFinish reported whatever mounted Form.Items
happened to be registered, so a control could stop feeding the request
without anything failing.

The form now runs on react-hook-form with the dashboard's own field
primitives, and the payload is projected explicitly in keyEditFormValues so
every saved key is written out by name. That reproduces the old payload
exactly, including the keys antd sent holding undefined and the two
role-gated keys it dropped entirely when the field was not mounted.

The existing suite is kept as the contract and passes unedited apart from
the selects, whose queries moved from Ant Design class selectors to roles
and labels. MultiSelect now forwards a per-option disabled flag, which the
Models select needs to keep greying out individual models once the
all-proxy-models sentinel is picked.

* fix(ui): keep the key edit prompts control gated behind premium

The antd control carried disabled={!premiumUser} and a tooltip saying
prompts by key are premium. The port kept the premium placeholder but
dropped the gate, so a non-premium admin could type a prompt and have the
whole save rejected by the endpoint.

TagsInput had no disabled prop at all, which is why the gate could not
survive the port; it now takes one and passes it to the combobox. An audit
of every disabled expression against the antd original shows this was the
only gate lost.

Also pins the two payload keys that are assembled in the submit handler
from React state rather than bound fields, budget_fallbacks and
tag_rpm_limit. A field-driven suite cannot see them, and tag_rpm_limit's
only previous appearance was an empty map that reads the same whether the
assignment works or is deleted. The cases were written against the antd
implementation by another lane and are added unchanged.
2026-08-18 19:40:37 -07:00