Split for the usage (UsagePage) segment. Most of the folder is the usage page's
own view, but four pieces are reused elsewhere and stay in @/components/UsagePage:
TopKeyView (old-usage), KeyModelUsageView and value_formatters (activity_metrics),
and the shared types (activity_metrics, chartUtils). The other 21 files move into
usage/_components, preserving the folder structure.
The external consumers import only the retained files, so they are untouched. The
moved files' imports of the retained files become @/components/UsagePage paths,
other escaping relative imports are absolutized, and lint suppressions are re-keyed
for moved files only. No behavior change.
* feat(proxy): add expires filter to GET /key/list
Add an opt-in expires query param to GET /key/list so callers can fetch
only expired or only active keys without paginating every page and
filtering client-side. 'expired' matches keys whose expires is in the
past (NULL expires excluded); 'active' matches keys that never expire or
expire in the future. Omitting the param preserves existing behavior for
every caller. An unrecognized value returns HTTP 400 rather than silently
returning all keys.
The filter is pushed to the database via the existing Prisma where
builder so callers avoid pulling the full key table into application
memory.
Resolves LIT-3387
* refactor(proxy): declare VALID_EXPIRES_FILTER_VALUES before its first use
max_tokens=1 makes reasoning models (o1/o3/...) return a 400 "max_tokens
reached" because reasoning tokens count against the cap, so a reachable
reasoning tier showed a false failure in Test Connection. Live-verified: o3
400s with the cap and succeeds without it.
Extract the request shape into a pure buildModelGroupTestRequest and cover it
with a test asserting the chat body carries no max_tokens (or
max_completion_tokens), so this regression is caught in unit tests instead of
only against a live reasoning model.
* refactor(ui): convert entity usage and usage page charts to shadcn/recharts
Swap the tremor BarChart/DonutChart render sites in EntityUsage,
SpendByProvider, TopKeyView, TopModelView, KeyModelUsageView and
UsagePageView to the shared shadcn/recharts wrappers. Convert the two
sole-chart Daily Spend cards and the KeyModelUsageView card to the
shadcn Card primitives.
Close the donut parity gap with strictly additive optional DonutChart
props: showLabel/label render a center total (tremor showed
valueFormatter(sum) by default) and startAngle/endAngle forward to the
Pie so both provider donuts keep tremor's clockwise-from-12 layout.
Defaults preserve the previous wrapper behavior.
DailyData and two site-local row types move from interface to type
alias so they satisfy the wrappers' Record<string, unknown> constraint;
interfaces lack implicit index signatures.
Tests now assert on real recharts output: bar/sector counts, cyan
fills, axis labels, donut center totals, and the TopKeyView bar-click
drill-down into the key info modal. The dead tremor chart mocks in
UsagePageView.test.tsx are removed and lint metrics/suppressions are
regenerated for the dropped tremor imports.
* fix(ui): compute donut center label only when shown and assert Model Usage renders as a card title
Live testing showed the first cut was broken: /health/test_connection merges
{...configParams, ...requestParams}, so passing the public model_group name as
the request model overrode the resolved provider model and every tier failed
with "LLM Provider NOT provided". The frontend only has the public group name,
not the underlying litellm_params, so it cannot build the request that endpoint
needs.
Switch to testing each model group the way production actually routes it: send a
minimal request to /v1/chat/completions (or /v1/embeddings for the embedding
model) by public group name through the shared apiClient. The router resolves
the group, credentials, and provider itself, so a green row means the tier is
genuinely reachable. Verified live: voyage embedding returns 200, a tier with a
bad key returns the real provider auth error.
Also address Greptile feedback: rows now update progressively as each probe
settles instead of all at once, and TIER_ORDER is derived through a
`satisfies Record<keyof ComplexityTiers, null>` guard so adding a tier without
listing it is a compile error.
* fix(ui): show sidebar copy confirmation only on a successful write
The sidebar account menu's copy button switched to the checkmark
synchronously, before the clipboard write settled, so it confirmed a
copy that never happened when navigator.clipboard was undefined on
non-secure origins or when writeText rejected. The handler now guards
navigator.clipboard, awaits the write, and flips to the checkmark only
on success
Also updates the header accent emoji in the same menu
* refactor(ui): extract a shared CopyButton for the sidebar account menu
The copy-icon-to-checkmark pattern was hand-rolled in several places,
including the sidebar account menu whose private copy button held the
false-confirmation bug. Extract a single canonical CopyButton into
components/shared, built on the Button primitive with a guarded and
awaited clipboard write so the checkmark appears only on a real
success, and have SidebarAccountMenu consume it
The success and failure-mode coverage now lives in the shared
component's own test; the sidebar test keeps one case asserting the
email row is wired to it
The consolidated auto-router tab dropped the Test Connection button because
the shared prepareModelAddRequest helper returns an empty array for an auto
router (it has no model_mappings), so the caller crashed destructuring
result[0].litellmParamsObj. That is the crash in #31590 and the open PR
#31794. #31794 only silenced the crash by pointing the test at
auto_router/complexity_router, which is not a provider model, so the
/health/test_connection health check (a real litellm.ahealth_check
completion) would still error.
Bring the button back and make it meaningful: an auto router dispatches to
saved model groups, so Test Connection now probes those directly. It builds
a deduped target list from the configured tiers (tiers sharing a model group
collapse to one probe) plus the embedding model when semantic keyword
matching is on, then runs a live /health/test_connection against each and
shows per-target pass/fail. This never touches prepareModelAddRequest, so the
original destructure crash cannot recur.
Scope is the recommended complexity router only; the to-be-deprecated
semantic router is untouched. No backend changes.
Supersedes #31794. Resolves#31590.
* refactor(ui): convert user agent and per-user usage charts to shadcn/recharts
Swap the tremor BarChart import for the shared shadcn/recharts wrapper in
user_agent_activity.tsx (DAU/WAU/MAU charts) and per_user_usage.tsx (usage
distribution histogram). All chart props are unchanged; the wrapper exposes
the same tremor prop surface with matching defaults.
Extend user_agent_activity.test.tsx and add per_user_usage.test.tsx with
parity assertions on the real recharts SVG output: bar series per category,
stacked x positions, resolved fill colors, axis bucket labels, legend text,
and value formatter output on axis ticks. Remove the dead ResizeObserver
polyfill in user_agent_activity.test.tsx now that the scoped global mock in
tests/setupTests.ts renders charts, which also lowers the no-explicit-any
metric by one.
* test(ui): harden bar x-position parsing against recharts path format
* fix(guardrails): filter Add-Guardrail mode dropdown per provider
The GET /guardrails/ui/add_guardrail_settings endpoint returned every
GuardrailEventHooks value in one flat supported_modes list, so the Admin
UI rendered pre_mcp_call as a selectable Mode for every guardrail. Saving
Content Filter or Tool Permission with pre_mcp_call then failed with a
400 because those guardrails' server-side supported_event_hooks list
excludes it.
Expose each guardrail's supported hooks as a get_supported_event_hooks
classmethod on CustomGuardrail (mirrors the existing get_config_model
pattern) and have the endpoint iterate guardrail_class_registry to build
a supported_modes_by_provider map. The UI Mode dropdown filters by that
map when the selected provider is known and falls back to the global
list otherwise. __init__ now sources its own supported_event_hooks list
from the classmethod so the two sides can't drift.
Also register BedrockGuardrail, ToolPermissionGuardrail, lakera,
lakera_v2, and presidio in guardrail_class_registry so they participate
in the map (they were previously only in guardrail_initializer_registry
and had no class-registry entry).
Behavior change: guardrails that previously had no supported_event_hooks
declared (aim, javelin, azure/text_moderation, cato_networks,
crowdstrike_aidr, headroom, hiddenlayer, lasso, noma, onyx,
prompt_security, qualifire, repelloai, zscaler_ai_guard, aporia_ai,
lakera_ai, lakera_ai_v2, mcp_jwt_signer, model_armor, presidio) now
validate the configured mode at instantiation. Existing configs where
the mode was silently a no-op will fail at proxy startup with a clear
validation error rather than running as a broken guardrail.
Resolves LIT-4226
* fix(guardrails): add LITELLM_STRICT_GUARDRAIL_MODES escape hatch, preserve current mode in edit form
Address Greptile P1 (startup break) and P2 (edit form UX):
LITELLM_STRICT_GUARDRAIL_MODES defaults to true (raise on unsupported
event_hook, unchanged behavior for the guardrails validated pre-PR).
Setting it to false logs a warning and continues, giving deployments an
opt-out while they fix configs that now surface as errors instead of
silently no-op'ing. Regression test covers both modes.
Edit form now surfaces the currently-saved mode even when it is not in
the filtered per-provider list, so a legacy row (e.g. content_filter
saved with pre_mcp_call before this fix) no longer disappears from the
dropdown; the option renders with a 'not supported by <provider>' note
so the user knows to pick another.
* fix(guardrails): correct audited hook lists, prune stale modes on provider switch, clean form lint
Audited every get_supported_event_hooks classmethod against the hooks
each guardrail's own tests exercise and its handler methods. Five were
too narrow and their tests caught it in CI: rubrik gains pre_call,
presidio gains during_call and pre_mcp_call, prompt_security, onyx and
qualifire gain during_call. The remaining classes match either their
original __init__ declarations or their exercised modes exactly.
Cursor review fixes: the Add form now drops selected modes the new
provider does not support when the user switches providers, so a
pre_mcp_call selection cannot ride along into a provider that rejects
it at save; the edit form handles list-shaped stored modes instead of
treating mode as always a string.
Extracted shared toModeArray and getSupportedModesForProvider helpers
into guardrail_info_helpers so both forms use one implementation, typed
the remaining any usages in both forms, removed nested ternaries, and
committed the ratcheted-down eslint metrics and pruned suppressions
* feat(ui): add redesigned sidebar account menu
Introduce SidebarAccountMenu, a sidebar-only account/logout menu built on
shadcn Popover/Switch/Badge/Separator/Button, and wire it into leftnav in
place of the shared UserDropdown. The panel has a LiteLLM header with the
bouncing moon and a clickable version tag, Tier/Role/Email/User ID rows
with copy actions, the five display toggles, and Logout.
UserDropdown is left untouched so the control-plane / chat navbar keeps
its existing menu. The version tag links to the same release notes page
as the navbar tag, and the bouncing icon reuses the existing header
animation gated by the Hide Bouncing Icon toggle.
* test(ui): point account-menu e2e specs at the migrated sidebar menu
The sidebar account menu moved from an antd Dropdown to a Base UI popover
(SidebarAccountMenu), so the login, logout, proxy-logout-url, and internal
user identity specs were still waiting on antd-era locators
(.ant-dropdown, the popupRender wrapper class, the user-dropdown-panel test
id, and a menuitem-role Logout). Point them at the new panel test id
(sidebar-account-menu-panel) and the button-role Logout instead. The logout
behavior is unchanged since both menus call the same useLogout handler.
Pin the sidebar header to the same 56px height as the dashboard topnav and
give it a matching bottom border, so the two borders sit flush and read as one
continuous line. Revert to auto height when the rail is collapsed so the
stacked logo and toggle are not clipped.
* refactor(ui): convert activity metrics charts to shadcn/recharts
Swap the seven tremor AreaChart/BarChart sites in activity_metrics.tsx to
the shared shadcn/recharts wrappers and switch CustomLegend/CustomTooltip
to the ported versions in shared/charts. Chart props, colors, formatters,
and legend behavior are unchanged; tests now assert on real recharts SVG
output instead of tremor mocks.
* fix(ui): restore tremor No data placeholder for empty AreaChart data
* test(ui): scope activity metrics chart assertions to card titles instead of render order
- config: KeywordTierRule now strips and drops blank/whitespace keywords (a stray
"" makes _keyword_matches match every prompt, silently forcing that tier for all
traffic); still requires at least one real keyword to remain
- frontend build_complexity_router_config: trim keywords and drop rules left empty so
an unfilled "Add keyword rule" row no longer ships a rule the backend rejects with a
400 in the heuristic (non-semantic) flow, where the client-side semantic guard doesn't run
- proxy clear_cache / delete_model: the auto_router/ prefix also covers quality_router/
and adaptive_router/, so pop the model_name from all four router registries (no-op
where absent) instead of only auto/complexity; otherwise a DB quality_router's stale
entry made reload raise "already exists" and abort, and adaptive left a leak
- frontend ComplexityRouterConfig: only render the Keyword Tier Overrides and Semantic
keyword matching sections when their change handlers are provided, so the edit-auto-
router modal (which omits them) no longer shows interactive-but-dead controls
The eslint-metrics.json snapshot duplicated the violation counts already
enforced by eslint-budgets.json. Keeping it current added a CI drift check,
a pre-commit regenerate-and-flag step, and a standalone npm run lint:metrics
script, none of which caught anything the budget gate did not, yet all of
which failed noisily whenever the snapshot went stale. This drops the file
and that machinery while leaving eslint-budgets.json as the actual ratchet
gate
* feat(ui): add the typed openapi-fetch client (fetchClient) as the dashboard fetch foundation
Introduces fetchClient (openapi-fetch) bound to schema.d.ts, used inside ordinary TanStack Query hooks so path/query/body types come from the proxy's OpenAPI spec. A small runtime registry feeds the client the base URL and auth header name (registered by networking) and the session token (published by AuthContext), so call sites carry no token plumbing; auth-header injection and ApiError mapping live in openapi-fetch middleware reusing deriveErrorMessage/ApiError from client.ts, and non-2xx maps to a thrown ApiError so query functions just read .data.
The base URL default resolves from NEXT_PUBLIC_BASE_URL so a request still targets the right origin if it fires before networking registers its getter. AuthContext clears accessToken alongside the token on logout so no query fires unauthenticated after the session ends.
Foundation only; callers migrate one at a time, each fully typed, in follow-up changes.
* feat(ui): migrate useCustomers to the typed fetchClient
Converts useCustomers from allEndUsersCall to fetchClient.GET("/customer/list"); the response is typed as LiteLLM_EndUserTable[] from the schema, so the hand-written Customer/CustomersResponse types are deleted. They were also inaccurate (allowed_model_region was string but is "eu"|"us", and a budget_id the table has no field for). No cast; the schema type flows to the one consumer. First caller on the new pattern.
* fix(ui): route typed-client errors through the session-expiry handler
The typed fetchClient middleware threw ApiError without invoking the
handleError side effect that the legacy createApiClient wires via
onError, so a migrated caller hitting an expired key no longer triggered
the auto-logout. Add an error-handler seam to runtime.ts, register
handleError from networking.tsx alongside the base-url/header getters,
and call it in the middleware before throwing so both clients behave the
same. Regression test asserts the handler fires with the derived message
on non-2xx and stays silent on success
* fix(ui): point the customers EndUser type at CustomerResponse
The /customer/list response model was renamed to CustomerResponse on
staging; the merged branch still aliased EndUser to LiteLLM_EndUserTable,
so the exported type and its test mock had drifted from what the schema
actually returns. CustomerResponse is also the accurate shape (it types
allowed_model_region as 'eu' | 'us' and carries budget_id)
* chore(ui): refresh eslint-metrics baseline after staging merge
The recorded baseline predated the litellm_internal_staging merge, so its
no-explicit-any and no-large-inline-object-arg counts were higher than the
merged tree actually has. Regenerate via npm run lint:metrics so the gate
reflects current reality
* refactor(ui): source the typed client token from the session cookie, not AuthContext
The typed client read its bearer from a runtime value that AuthContext pushed
via setAuthToken, but migrated hooks gate enabled on useAuthorized, which
decodes the cookie directly. Two independent derivations of the same cookie with
different timing: on first load the query fires (useAuthorized sees the token)
before AuthContext's async effect publishes it, so the first request goes out
unauthenticated and only succeeds on a React Query retry.
Make the token a registered getter like the base-url and header-name getters,
reading the same cookie useAuthorized decodes, so the client's token and the
gate can't diverge. Revert the AuthContext changes entirely; nothing is pushed
from React state anymore.
Replace lodash/debounce in TeamVirtualKeysTable with useDebouncedValue from
@tanstack/react-pacer, matching the sibling VirtualKeysTable and
PaginatedKeyAliasSelect which already debounce their key-alias search that way.
Pacer is already a dependency, so this drops the odd-one-out lodash usage and
keeps the search-debounce pattern consistent across the key tables.
The AI Gateway selector now always renders at the breadcrumb root, even with no plugins and Chat UI disabled, so the Chat feature stays discoverable. The Chat entry is always listed: clickable when enabled, and disabled with an "Admins can enable in Settings" hint when it is off.
Since the selector is now unconditional, the useViewSwitcherVisible hook and the section-crumb fallback added in the previous commit are removed
The AI Gateway select (ViewSwitcher) now sits at the root of the DashboardHeader breadcrumb instead of on the right, so the top bar reads [AI Gateway select] > Page to match the redesign. It keeps the same dropdown, including the Chat / Chat UI options.
When no plugins are registered and Chat UI is disabled there is nothing to switch between, so the breadcrumb falls back to the static section crumb rather than rendering a dangling leading separator
Add deterministic keyword-to-tier overrides and optional embedding-based
(semantic) keyword matching to the complexity router, and surface both in the
Add Auto Router UI behind a Router Type selector: "Auto-Router v2 [Recommended]"
(complexity tiers + keyword overrides + semantic matching, the default) and
"Semantic Router [to be deprecated]" (the existing utterance-based router,
unchanged). Keyword-to-tier overrides resolve to the highest tier matched
rather than the first keyword matched, so match order no longer affects the
routing decision.
Backend:
- config: KeywordTierRule model plus keyword_tier_rules, semantic_keyword_matching,
embedding_model, and match_threshold on ComplexityRouterConfig, with a validator
requiring an embedding model and rules when semantic matching is on
- complexity_router: evaluate keyword rules before scoring; lexical matches escalate
to the most-severe matched tier (order-independent), and semantic mode reuses
LiteLLMRouterEncoder + SemanticRouter to match paraphrases by cosine similarity,
falling back to the scorer when nothing matches
- model management: clear complexity_routers on cache reload so config edits take effect
Frontend:
- Add Auto Router tab restores the Router Type radio (Auto-Router v2 recommended
by default, Semantic Router still available) and sends keyword_tier_rules plus
the semantic settings on the recommended path, instead of flattening keywords
into custom_technical_keywords
- client-side guard blocks submit when semantic matching is enabled without an
embedding model or without any keyword tier rules, mirroring the backend validator
- moved the "How Classification Works" explainer below Custom Technical Keywords
and above Keyword Tier Overrides
- remove the Test Connection action from the recommended flow, which can't build a
valid pre-save payload for a router (leaves a TODO for a JSON preview / config
test follow-up)
Tests cover lexical escalation, semantic matching via the real library with injected
embeddings, the semantic config guard, config validation, the reload-clear
regression, and the frontend payload builder
* feat(router): add LLM-based classifier option to complexity router
Adds classifier_type: "heuristic" | "llm" to complexity_router_config.
When set to "llm", the router calls a configured model (e.g. a small
model like haiku) via structured output to pick the complexity tier,
falling back to the existing regex/keyword scorer on any error, empty
response, or unparseable output.
* feat(ui): add classifier_type option to complexity router UI, fix edit flow
Adds an "Advanced: Classification Method" section to ComplexityRouterConfig
with a heuristic/LLM toggle, revealing a classifier model picker and timeout
when LLM is selected.
Also fixes the auto router edit modal, which never rendered the complexity
router UI at all (it only handled the semantic router), and the "Edit Auto
Router" button visibility check, which was gated on auto_router_config and
never matched complexity router deployments.
* fix(router): attribute classifier calls to caller, raise default timeout
Forwards the original request's litellm_metadata into the classifier's
acompletion call. Without it, the proxy's cost-tracking gate sees no
user_api_key/team_id/user_id and silently drops spend logging and budget
accounting for every classifier call, letting an authenticated user rack
up unaccounted provider spend via repeated requests.
Also raises the default classifier timeout from 400ms to 3000ms (400ms
undershoots real LLM latency and would silently degrade to the heuristic
scorer on most requests) and corrects the module/class docstrings, which
still claimed zero external API calls after the llm classifier path was
added.
* fix(ci): resolve ruff strict-budget and frontend-lint failures
- Use PEP 585 generics (dict/tuple/list) in the new aclassify/_classify_with_llm
signatures instead of typing.Dict/Tuple/List, and suppress BLE001 on the
intentionally broad except in aclassify's fallback path with a reason.
- Fix prettier formatting in ComplexityRouterConfig.tsx.
- Regenerate eslint-metrics.json (was stale after the classifier UI changes).
* fix(ci): regenerate stale eslint-metrics.json
* fix(router): strip parent budget reservation from classifier metadata
The classifier's internal acompletion call previously forwarded the
parent request's full litellm_metadata, including its budget
reservation (user_api_key_budget_reservation / user_api_key_auth).
That reservation belongs to the routed completion the classifier is
deciding on, not to the classifier call itself, so it's now stripped
while key/team attribution fields are still forwarded for spend
logging.
Add a RESTful PATCH /team/{team_id} that partially updates a team using RFC 7386 JSON Merge Patch. team_id comes from the path, and metadata is merged with the team's stored metadata instead of being replaced wholesale the way POST /team/update does: an omitted key is preserved, key: null deletes it, and any other value overwrites, recursing into nested objects. Every other field behaves the same as POST /team/update
The handler delegates to the existing update path, so authorization, budget checks, system-managed-key stripping, metadata encryption, cache refresh, and audit logging are shared rather than reimplemented. POST /team/update is untouched, so the change is purely additive
DataTableFilterField rendered a bare native label element. Swap it for the
shadcn Label primitive so the filter drawer's field labels share the same
typography and disabled-state handling as the rest of the form primitives
The workflow-runs Status filter leaked the internal "__all__" sentinel as its
displayed value because Base UI's Select.Value renders the raw value when no
items map or children function is given. Drop the sentinel and use Base UI's
native null handling: a null "All statuses" item plus a placeholder, with an
items map so a real selection renders its capitalized label rather than the
raw status string
The shared DataTable rendered every loading-skeleton cell as one identical
half-width bar, which read as a rigid grid instead of the table beneath it.
Vary the skeleton width per column and add a per-column skeleton shape hint
(text or twoLine) on ColumnMeta so identity columns like the workflow "Run"
cell get a two-line skeleton that matches their real content
Adds server- and client-side column filtering to the shared DataTable via a
filterMode prop that mirrors the existing sorting and pagination modes, a global
search filter, a staged filter drawer ("Apply Filters" / "Reset") built on the
shadcn Sheet primitive (added via `npx shadcn add sheet`), and a table-aware
toolbar (search, refresh, active-filter chips, and a Columns menu). Also adds
skeleton loading rows, an icon empty state, and renders toolbar, table, and
pagination as a single card.
Migrates the two pilot tables onto the design as a proof of concept:
TeamVirtualKeysTable drives server-side filtering through the list API (key alias
via the search box, user via the drawer), and WorkflowRuns filters client-side in
memory. Both keep their existing behavioral tests green, with new coverage for the
filter drawer, the toolbar, the global search, and the server-filter query mapping.
* fix(proxy): build redis usage cache from REDIS_* env when cache backend is not Redis
Selecting a semantic (or any non-Redis-KV) response cache left
redis_usage_cache unset, silently downgrading cross-pod rate limits,
parallel-request limits, spend coordination, and the pod lock manager
to per-pod in-memory state. Fall back to a standalone RedisCache built
from REDIS_* environment variables, mirroring the existing
use_redis_transaction_buffer escape hatch, which now shares the same
helper.
Resolves LIT-3861
* feat(proxy): configure the coordination redis independently of the response cache
Adds general_settings.coordination_redis, an explicit block for the Redis
the proxy uses for cross-pod rate limits, parallel-request limits, spend
tracking, the pod lock manager, and shared health checks. Resolution order
is the explicit block, then a plain-Redis response-cache backend, then the
REDIS_* environment. Cluster and sentinel targets are supported, and a
cluster target now builds a RedisClusterCache so cluster-aware consumers
take the cluster path.
Admins can configure it from the Caching page of the dashboard via
/coordination_redis/settings, which reports which source is in effect,
redacts credentials on read, and offers a connection test. Settings saved
there are read back at startup so they take effect on restart.
Also fixes redis client construction so an explicitly configured host
outranks REDIS_URL in the environment. Previously the url branch stripped
the caller's host and port, so an explicit block, or a connection test
typed into the dashboard, silently targeted whatever REDIS_URL named
* fix(ui): move coordination_redis_settings into renamed _components directory
---------
Co-authored-by: Yucheng Zhu <yucheng@berri.ai>
A plain optional label implies the field is persisted like any other form
field; the qualifier was only there to contrast with the removed not-saved
wording
Render DcrBridgeToggle inside PassthroughAuthorizeSection, after the OAuth
client ID/secret fields and just before the Authorize & Fetch Tools button,
in both the create and edit flows. Also update the section copy to say a
configured OAuth app is saved with the server, using the same wording as the
credential lifecycle rework in #32752 so whichever PR lands second rebases
cleanly