feat(credentials): add relay provider usage panel for AI providers#321
feat(credentials): add relay provider usage panel for AI providers#321Guoyin-Wen wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new feature to query and display relay provider usage (GLM, MiniMax, Kimi, and DeepSeek) directly, isolating it from the OAuth quota path. It adds backend adapters, matchers, and a service along with frontend UI panels, state management, and translations. The review feedback is highly constructive and should be addressed: it suggests preventing goroutine blocking on context cancellation in the Go service, avoiding disabling React hooks lint rules, caching Intl.NumberFormat instances to optimize performance, displaying raw unclamped percentage values in the UI, and improving accessibility by closing the platform switcher on Escape key press.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| go func(i int, identityID string) { | ||
| defer wg.Done() | ||
| s.workerSem <- struct{}{} | ||
| defer func() { <-s.workerSem }() | ||
| items[i] = s.fetchOne(ctx, identityID, overrides) | ||
| }(idx, id) |
There was a problem hiding this comment.
When the context is cancelled, goroutines waiting to acquire the worker semaphore will block unnecessarily until they can acquire it, even though the context is already cancelled. Using a select statement to race the semaphore acquisition against ctx.Done() prevents this blocking and allows immediate cleanup.
go func(i int, identityID string) {
defer wg.Done()
select {
case s.workerSem <- struct{}{}:
defer func() { <-s.workerSem }()
items[i] = s.fetchOne(ctx, identityID, overrides)
case <-ctx.Done():
items[i] = UsageItem{IdentityID: identityID, Skipped: ctx.Err().Error()}
}
}(idx, id)| useEffect(() => { | ||
| void loadAssignments(identityIds) | ||
| void refreshUsage(identityIds) | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [idsKey, loadAssignments, refreshUsage]) |
There was a problem hiding this comment.
Instead of disabling the react-hooks/exhaustive-deps lint rule, you can parse the idsKey inside the useEffect hook. This keeps the dependency array clean and compliant with standard React linting rules.
| useEffect(() => { | |
| void loadAssignments(identityIds) | |
| void refreshUsage(identityIds) | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [idsKey, loadAssignments, refreshUsage]) | |
| useEffect(() => { | |
| const ids = idsKey ? idsKey.split(',') : [] | |
| void loadAssignments(ids) | |
| void refreshUsage(ids) | |
| }, [idsKey, loadAssignments, refreshUsage]) |
| function formatRelayAmount(value: number, currency: string): string { | ||
| const amount = Number.isFinite(value) ? value : 0 | ||
| try { | ||
| return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount) | ||
| } catch { | ||
| return `${amount.toFixed(2)} ${currency}` | ||
| } | ||
| } |
There was a problem hiding this comment.
Constructing a new Intl.NumberFormat instance on every call to formatRelayAmount is highly inefficient and can cause significant garbage collection pressure and CPU overhead under heavy load. Caching the formatters by currency avoids this overhead.
| function formatRelayAmount(value: number, currency: string): string { | |
| const amount = Number.isFinite(value) ? value : 0 | |
| try { | |
| return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount) | |
| } catch { | |
| return `${amount.toFixed(2)} ${currency}` | |
| } | |
| } | |
| const relayAmountFormatters = new Map<string, Intl.NumberFormat>() | |
| function getRelayAmountFormatter(currency: string): Intl.NumberFormat { | |
| let formatter = relayAmountFormatters.get(currency) | |
| if (!formatter) { | |
| formatter = new Intl.NumberFormat('zh-CN', { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }) | |
| relayAmountFormatters.set(currency, formatter) | |
| } | |
| return formatter | |
| } | |
| function formatRelayAmount(value: number, currency: string): string { | |
| const amount = Number.isFinite(value) ? value : 0 | |
| try { | |
| return getRelayAmountFormatter(currency).format(amount) | |
| } catch { | |
| return amount.toFixed(2) + " " + currency | |
| } | |
| } |
| const percent = quota.barPercent ?? 0 | ||
| const width = `${Math.max(0, Math.min(100, percent))}%` | ||
| const percentLabel = quota.barPercent === null ? '—' : `${Math.round(quota.barPercent)}%` |
There was a problem hiding this comment.
To ensure upstream data anomalies remain visible, calculated metrics (such as percentages) should display their raw, unclamped values, while only the visual progress bar width should be clamped to prevent layout breakage.
| const percent = quota.barPercent ?? 0 | |
| const width = `${Math.max(0, Math.min(100, percent))}%` | |
| const percentLabel = quota.barPercent === null ? '—' : `${Math.round(quota.barPercent)}%` | |
| const percent = quota.barPercent ?? 0 | |
| const width = Math.max(0, Math.min(100, percent)) + "%" | |
| const rawPercent = quota.percent !== undefined && quota.percent !== null | |
| ? (quota.percentKind === 'used' ? quota.percent : 100 - quota.percent) | |
| : null | |
| const percentLabel = rawPercent === null ? '—' : Math.round(rawPercent) + "%" |
| useEffect(() => { | ||
| if (!open) return | ||
| const handler = (event: MouseEvent) => { | ||
| if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false) | ||
| } | ||
| document.addEventListener('mousedown', handler) | ||
| return () => document.removeEventListener('mousedown', handler) | ||
| }, [open]) |
There was a problem hiding this comment.
For better usability and accessibility, the platform switcher dropdown menu should also close when the user presses the Escape key.
useEffect(() => {
if (!open) return
const handleOutsideClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
Query upstream relay providers (GLM, MiniMax, Kimi, DeepSeek) for balance and quota-window usage, surfaced as an optional "Usage" column on the AI Provider credentials page. - internal/relayusage: per-provider adapters with shared matcher/normalize - internal/api: relay usage endpoint wired into the router - web: AiProviderUsagePanel + useRelayProviderUsage hook, i18n keys (en/zh/tw) - CredentialRowShell/TableHeader gain an optional usage column - move formatQuotaResetDuration to CredentialSectionShell and localize it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2a26598 to
c6621db
Compare
- internal/relayusage: listen to ctx.Done() when acquiring worker - web: fix exhaustive-deps lint by moving id parsing into useEffect - web: cache Intl.NumberFormat instances by currency - web: only clamp UI width, display raw percent value - web: add Escape key to close platform dropdown
62c7d49 to
33bf410
Compare
|
Thanks Gemini for the suggestions! All 5 medium-priority changes have been applied:
All tests passing. |
|
Thank you for the contribution. The core idea is valuable, but the current UI and interaction design do not align with the project’s established patterns. The Auth Files section already supports quota queries and uses a section-level switch to toggle between quota and health views. This PR introduces a separate, permanently visible Usage column, which is inconsistent with the rest of the credentials page. |
What
Adds a relay provider usage panel that queries upstream relay providers (GLM, MiniMax, Kimi, DeepSeek) for balance and quota-window usage, shown as an optional Usage column on the AI Provider credentials page.
Why
AI Provider credentials currently surface only request activity. For providers that expose a usage/balance endpoint, showing live quota-window usage (e.g. 5h tokens, weekly tokens, monthly MCP) alongside health helps operators spot accounts near their limits without leaving the dashboard.
Changes
Backend
internal/relayusage: per-provider adapters (GLM, MiniMax, Kimi, DeepSeek) behind a sharedservice+matcher+normalizelayerinternal/api/relay_usage.go: endpoint wired into the routerFrontend
AiProviderUsagePanel+useRelayProviderUsagehookCredentialRowShell/CredentialTableHeadergain an optionalusagecolumn (off by default; only rendered when the host passesonSetRelayPlatform)formatQuotaResetDurationmoved toCredentialSectionShelland localizedTesting
go test ./cmd/... ./internal/...— pass (incl. newinternal/relayusageadapter/matcher tests)npm run typecheck / lint / build— passnpm run test— pass (credential style + quota-reset tests updated to match the new row structure)Notes
The "Usage" column is opt-in: it only renders when
onSetRelayPlatformis provided, so sections that don't opt in keep the original three-column layout.Built on top of the latest
main(includes the credential health visualization and speed-mode changes).