From 83a970ed3838e26596125744e96f45a10de681ab Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 20:39:27 +0000 Subject: [PATCH 01/10] feat(dashboard): add plus burn metric card --- .dockerignore | 4 + .../dashboard/components/stats-grid.test.tsx | 8 +- .../dashboard/components/stats-grid.tsx | 3 +- frontend/src/features/dashboard/utils.test.ts | 33 ++++++++ frontend/src/features/dashboard/utils.ts | 76 ++++++++++++++++++- 5 files changed, 118 insertions(+), 6 deletions(-) diff --git a/.dockerignore b/.dockerignore index dbc82d28..d1dd0b94 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,3 +22,7 @@ htmlcov/ refs/ docs/ tests/ + +# Explicit frontend excludes to keep Docker context small +frontend/node_modules/ +**/node_modules/ diff --git a/frontend/src/features/dashboard/components/stats-grid.test.tsx b/frontend/src/features/dashboard/components/stats-grid.test.tsx index 5756f75e..01a6d1ee 100644 --- a/frontend/src/features/dashboard/components/stats-grid.test.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.test.tsx @@ -1,5 +1,6 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; -import { Activity, AlertTriangle, Coins, DollarSign } from "lucide-react"; +import { Activity, AlertTriangle, Coins, DollarSign, Flame } from "lucide-react"; import { describe, expect, it } from "vitest"; import { StatsGrid } from "@/features/dashboard/components/stats-grid"; @@ -8,13 +9,14 @@ const EMPTY_TREND: { value: number }[] = []; const SAMPLE_TREND = [{ value: 1 }, { value: 2 }, { value: 3 }]; describe("StatsGrid", () => { - it("renders four metric cards with values", () => { + it("renders five metric cards with values", () => { render( , @@ -26,6 +28,8 @@ describe("StatsGrid", () => { expect(screen.getByText("45K")).toBeInTheDocument(); expect(screen.getByText("Cost (7d)")).toBeInTheDocument(); expect(screen.getByText("Avg/hr $0.01")).toBeInTheDocument(); + expect(screen.getByText("Plus Burn (5h/7d)")).toBeInTheDocument(); + expect(screen.getByText("Primary 0.7 acc/5h · Secondary 0.8 acc/7d")).toBeInTheDocument(); expect(screen.getByText("Error rate")).toBeInTheDocument(); expect(screen.getByText("Top: rate_limit_exceeded")).toBeInTheDocument(); }); diff --git a/frontend/src/features/dashboard/components/stats-grid.tsx b/frontend/src/features/dashboard/components/stats-grid.tsx index 4600fa3b..3439d2fc 100644 --- a/frontend/src/features/dashboard/components/stats-grid.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.tsx @@ -6,6 +6,7 @@ const ACCENT_STYLES = [ "bg-blue-500/10 text-blue-600 dark:bg-blue-500/15 dark:text-blue-400", "bg-violet-500/10 text-violet-600 dark:bg-violet-500/15 dark:text-violet-400", "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400", + "bg-rose-500/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400", "bg-amber-500/10 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400", ]; @@ -15,7 +16,7 @@ export type StatsGridProps = { export function StatsGrid({ stats }: StatsGridProps) { return ( -
+
{stats.map((stat, index) => { const Icon = stat.icon; const accent = ACCENT_STYLES[index % ACCENT_STYLES.length]; diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index 76242b81..161dcdea 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { + buildDashboardView, buildDepletionView, buildRemainingItems, } from "@/features/dashboard/utils"; import type { AccountSummary, Depletion } from "@/features/dashboard/schemas"; +import { createDashboardOverview, createDefaultRequestLogs } from "@/test/mocks/factories"; import { formatCompactAccountId } from "@/utils/account-identifiers"; function account(overrides: Partial & Pick): AccountSummary { @@ -123,3 +125,34 @@ describe("buildRemainingItems", () => { expect(items[2].isEmail).toBe(true); }); }); + +describe("buildDashboardView", () => { + it("adds plus-burn stat between cost and error rate", () => { + const overview = createDashboardOverview(); + const logs = createDefaultRequestLogs(); + + const view = buildDashboardView(overview, logs); + + expect(view.stats[2].label).toBe("Cost (7d)"); + expect(view.stats[3].label).toBe("Plus Burn (5h/7d)"); + expect(view.stats[3].value).toBe("0.7 / 0.8"); + expect(view.stats[3].meta).toBe("Primary 0.7 acc/5h · Secondary 0.8 acc/7d"); + expect(view.stats[3].trend.length).toBeGreaterThan(0); + expect(view.stats[4].label).toBe("Error rate"); + }); + + it("falls back to placeholders when depletion data is missing", () => { + const overview = createDashboardOverview({ + depletionPrimary: null, + depletionSecondary: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.label).toBe("Plus Burn (5h/7d)"); + expect(burn.value).toBe("-- / --"); + expect(burn.meta).toBe("Primary -- acc/5h · Secondary -- acc/7d"); + expect(burn.trend).toEqual([]); + }); +}); diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index cc5a030c..bbf4ae13 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -1,4 +1,4 @@ -import { Activity, AlertTriangle, Coins, DollarSign } from "lucide-react"; +import { Activity, AlertTriangle, Coins, DollarSign, Flame } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { buildDonutPalette } from "@/utils/colors"; @@ -20,6 +20,11 @@ import type { UsageWindow, } from "@/features/dashboard/schemas"; +const PLUS_DEFAULT_CAPACITY = { + primary: 225, + secondary: 7560, +} as const; + export type RemainingItem = { accountId: string; label: string; @@ -123,7 +128,55 @@ export function avgPerHour(cost7d: number, hours = 24 * 7): number { return cost7d / hours; } -const TREND_COLORS = ["#3b82f6", "#8b5cf6", "#10b981", "#f59e0b"]; +function isPositiveFinite(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function plusAccountsBurnEquivalent( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + const summaryWindow = windowKey === "primary" ? overview.summary.primaryWindow : overview.summary.secondaryWindow; + const depletion = windowKey === "primary" ? overview.depletionPrimary : overview.depletionSecondary; + + if (!summaryWindow || !depletion) { + return null; + } + + const remainingCredits = summaryWindow.remainingCredits; + const burnRate = depletion.burnRate; + + if (!isPositiveFinite(remainingCredits) || !isPositiveFinite(burnRate)) { + return null; + } + + const plusCapacity = PLUS_DEFAULT_CAPACITY[windowKey]; + const equivalent = (remainingCredits * burnRate) / plusCapacity; + return Number.isFinite(equivalent) ? Math.max(0, equivalent) : null; +} + +function formatBurnEquivalent(value: number | null): string { + if (value === null || !Number.isFinite(value)) { + return "--"; + } + return value.toFixed(1); +} + +function buildBurnTrend(points: TrendPoint[], currentValue: number | null): { value: number }[] { + if (currentValue === null || !Number.isFinite(currentValue) || currentValue <= 0 || points.length === 0) { + return []; + } + + const lastPoint = points[points.length - 1]?.v ?? 0; + if (!Number.isFinite(lastPoint) || lastPoint <= 0) { + return points.map(() => ({ value: currentValue })); + } + + const scale = currentValue / lastPoint; + return points.map((point) => ({ value: Math.max(0, point.v * scale) })); +} + +const TREND_COLORS = ["#3b82f6", "#8b5cf6", "#10b981", "#ef4444", "#f59e0b"]; function trendPointsToValues(points: TrendPoint[]): { value: number }[] { return points.map((p) => ({ value: p.v })); @@ -139,8 +192,17 @@ export function buildDashboardView( const metrics = overview.summary.metrics; const cost = overview.summary.cost.totalUsd7d; const secondaryLabel = formatWindowLabel("secondary", secondaryWindow?.windowMinutes ?? null); + const primaryBurnLabel = formatWindowLabel("primary", overview.summary.primaryWindow.windowMinutes ?? null); + const secondaryBurnLabel = formatWindowLabel("secondary", overview.summary.secondaryWindow?.windowMinutes ?? null); const trends = overview.trends; + const primaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "primary"); + const secondaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "secondary"); + const combinedBurnEquivalent = + (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) > 0 + ? (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) + : null; + const stats: DashboardStat[] = [ { label: "Requests (7d)", @@ -166,6 +228,14 @@ export function buildDashboardView( trend: trendPointsToValues(trends.cost), trendColor: TREND_COLORS[2], }, + { + label: `Plus Burn (${primaryBurnLabel}/${secondaryBurnLabel})`, + value: `${formatBurnEquivalent(primaryBurnEquivalent)} / ${formatBurnEquivalent(secondaryBurnEquivalent)}`, + meta: `Primary ${formatBurnEquivalent(primaryBurnEquivalent)} acc/${primaryBurnLabel} · Secondary ${formatBurnEquivalent(secondaryBurnEquivalent)} acc/${secondaryBurnLabel}`, + icon: Flame, + trend: buildBurnTrend(trends.tokens, combinedBurnEquivalent), + trendColor: TREND_COLORS[3], + }, { label: "Error rate", value: formatRate(metrics?.errorRate7d ?? null), @@ -174,7 +244,7 @@ export function buildDashboardView( : `~${formatCompactNumber(Math.round((metrics?.errorRate7d ?? 0) * (metrics?.requests7d ?? 0)))} errors in 7d`, icon: AlertTriangle, trend: trendPointsToValues(trends.errorRate), - trendColor: TREND_COLORS[3], + trendColor: TREND_COLORS[4], }, ]; From f4ec4b5f6cb5cf5747c49126302005a9519e030b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 07:43:18 +0000 Subject: [PATCH 02/10] fix(dashboard): stabilize plus burn secondary estimate --- frontend/src/features/dashboard/utils.test.ts | 50 +++++++++-- frontend/src/features/dashboard/utils.ts | 83 ++++++++++++++++--- 2 files changed, 117 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index 161dcdea..3eff2790 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -135,13 +135,13 @@ describe("buildDashboardView", () => { expect(view.stats[2].label).toBe("Cost (7d)"); expect(view.stats[3].label).toBe("Plus Burn (5h/7d)"); - expect(view.stats[3].value).toBe("0.7 / 0.8"); - expect(view.stats[3].meta).toBe("Primary 0.7 acc/5h · Secondary 0.8 acc/7d"); + expect(view.stats[3].value).toBe("0.7 / 1.2"); + expect(view.stats[3].meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); expect(view.stats[3].trend.length).toBeGreaterThan(0); expect(view.stats[4].label).toBe("Error rate"); }); - it("falls back to placeholders when depletion data is missing", () => { + it("falls back to usage equivalents when depletion data is missing", () => { const overview = createDashboardOverview({ depletionPrimary: null, depletionSecondary: null, @@ -151,8 +151,46 @@ describe("buildDashboardView", () => { const burn = view.stats[3]; expect(burn.label).toBe("Plus Burn (5h/7d)"); - expect(burn.value).toBe("-- / --"); - expect(burn.meta).toBe("Primary -- acc/5h · Secondary -- acc/7d"); - expect(burn.trend).toEqual([]); + expect(burn.value).toBe("0.7 / 1.2"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + expect(burn.trend.length).toBeGreaterThan(0); + }); + + it("uses usage-equivalent fallback when burn rate is zero", () => { + const overview = createDashboardOverview({ + depletionSecondary: { + risk: 1, + riskLevel: "critical", + burnRate: 0, + safeUsagePercent: 98, + projectedExhaustionAt: null, + secondsUntilExhaustion: null, + }, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.7 / 1.2"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + }); + + it("caps burn-equivalent to available account count per window", () => { + const overview = createDashboardOverview({ + depletionSecondary: { + risk: 1, + riskLevel: "critical", + burnRate: 999, + safeUsagePercent: 98, + projectedExhaustionAt: null, + secondsUntilExhaustion: null, + }, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.7 / 2.0"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 2.0 acc/7d"); }); }); diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index bbf4ae13..77afb3f2 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -128,8 +128,56 @@ export function avgPerHour(cost7d: number, hours = 24 * 7): number { return cost7d / hours; } -function isPositiveFinite(value: number | null | undefined): value is number { - return typeof value === "number" && Number.isFinite(value) && value > 0; +function isFiniteNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function windowUsedAccountEquivalents( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + let usedEquivalent = 0; + let includedAccounts = 0; + + for (const account of overview.accounts) { + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent)) { + continue; + } + + usedEquivalent += (100 - clampPercent(remainingPercent)) / 100; + includedAccounts += 1; + } + + return includedAccounts > 0 ? usedEquivalent : null; +} + +function windowIncludedAccountCount( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number { + let includedAccounts = 0; + + for (const account of overview.accounts) { + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent)) { + continue; + } + + includedAccounts += 1; + } + + return includedAccounts; } function plusAccountsBurnEquivalent( @@ -138,21 +186,36 @@ function plusAccountsBurnEquivalent( ): number | null { const summaryWindow = windowKey === "primary" ? overview.summary.primaryWindow : overview.summary.secondaryWindow; const depletion = windowKey === "primary" ? overview.depletionPrimary : overview.depletionSecondary; + const fallbackUsedEquivalent = windowUsedAccountEquivalents(overview, windowKey); - if (!summaryWindow || !depletion) { - return null; + if (!summaryWindow) { + return fallbackUsedEquivalent; } const remainingCredits = summaryWindow.remainingCredits; - const burnRate = depletion.burnRate; + const burnRate = depletion?.burnRate; + let burnEquivalent: number | null = null; + + if (isFiniteNumber(remainingCredits) && remainingCredits >= 0 && isFiniteNumber(burnRate) && burnRate > 0) { + const plusCapacity = PLUS_DEFAULT_CAPACITY[windowKey]; + const equivalent = (remainingCredits * burnRate) / plusCapacity; + if (isFiniteNumber(equivalent)) { + burnEquivalent = Math.max(0, equivalent); + } + } + + if (burnEquivalent !== null) { + const maxEquivalent = windowIncludedAccountCount(overview, windowKey); + if (maxEquivalent > 0) { + burnEquivalent = Math.min(burnEquivalent, maxEquivalent); + } + } - if (!isPositiveFinite(remainingCredits) || !isPositiveFinite(burnRate)) { - return null; + if (windowKey === "secondary" && isFiniteNumber(fallbackUsedEquivalent)) { + return burnEquivalent === null ? fallbackUsedEquivalent : Math.max(burnEquivalent, fallbackUsedEquivalent); } - const plusCapacity = PLUS_DEFAULT_CAPACITY[windowKey]; - const equivalent = (remainingCredits * burnRate) / plusCapacity; - return Number.isFinite(equivalent) ? Math.max(0, equivalent) : null; + return burnEquivalent ?? fallbackUsedEquivalent; } function formatBurnEquivalent(value: number | null): string { From 4d5503129dc0d6031ba78e9a869b23ab86b3d4dd Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 09:48:05 +0000 Subject: [PATCH 03/10] feat(settings): add live account burnrate toggle --- .../dashboard/components/dashboard-page.tsx | 9 ++- .../dashboard/components/stats-grid.tsx | 4 +- frontend/src/features/dashboard/utils.ts | 51 ++++++++++++----- .../components/appearance-settings.tsx | 56 ++++++++++++------- .../src/hooks/use-dashboard-preferences.ts | 45 +++++++++++++++ frontend/src/main.tsx | 2 + 6 files changed, 129 insertions(+), 38 deletions(-) create mode 100644 frontend/src/hooks/use-dashboard-preferences.ts diff --git a/frontend/src/features/dashboard/components/dashboard-page.tsx b/frontend/src/features/dashboard/components/dashboard-page.tsx index fd59ee55..38115236 100644 --- a/frontend/src/features/dashboard/components/dashboard-page.tsx +++ b/frontend/src/features/dashboard/components/dashboard-page.tsx @@ -15,6 +15,7 @@ import { useDashboard } from "@/features/dashboard/hooks/use-dashboard"; import { useRequestLogs } from "@/features/dashboard/hooks/use-request-logs"; import { buildDashboardView } from "@/features/dashboard/utils"; import type { AccountSummary } from "@/features/dashboard/schemas"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { useThemeStore } from "@/hooks/use-theme"; import { REQUEST_STATUS_LABELS } from "@/utils/constants"; import { formatModelLabel, formatSlug } from "@/utils/formatters"; @@ -25,6 +26,7 @@ export function DashboardPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const isDark = useThemeStore((s) => s.theme === "dark"); + const showAccountBurnrate = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled); const dashboardQuery = useDashboard(); const { filters, logsQuery, optionsQuery, updateFilters } = useRequestLogs(); const { resumeMutation } = useAccountMutations(); @@ -59,8 +61,11 @@ export function DashboardPage() { if (!overview || !logPage) { return null; } - return buildDashboardView(overview, logPage.requests, isDark); - }, [overview, logPage, isDark]); + return buildDashboardView(overview, logPage.requests, { + isDark, + showAccountBurnrate, + }); + }, [overview, logPage, isDark, showAccountBurnrate]); const accountOptions = useMemo(() => { const entries = new Map(); diff --git a/frontend/src/features/dashboard/components/stats-grid.tsx b/frontend/src/features/dashboard/components/stats-grid.tsx index 3439d2fc..7944f704 100644 --- a/frontend/src/features/dashboard/components/stats-grid.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.tsx @@ -15,8 +15,10 @@ export type StatsGridProps = { }; export function StatsGrid({ stats }: StatsGridProps) { + const columnsClass = stats.length >= 5 ? "xl:grid-cols-5" : "xl:grid-cols-4"; + return ( -
+
{stats.map((stat, index) => { const Icon = stat.icon; const accent = ACCENT_STYLES[index % ACCENT_STYLES.length]; diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index 77afb3f2..194c5f7b 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -60,6 +60,24 @@ export type DashboardView = { safeLineSecondary: SafeLineView | null; }; +type DashboardViewOptions = { + isDark?: boolean; + showAccountBurnrate?: boolean; +}; + +function resolveDashboardViewOptions(optionsOrIsDark: DashboardViewOptions | boolean): Required { + if (typeof optionsOrIsDark === "boolean") { + return { + isDark: optionsOrIsDark, + showAccountBurnrate: true, + }; + } + return { + isDark: optionsOrIsDark.isDark ?? false, + showAccountBurnrate: optionsOrIsDark.showAccountBurnrate ?? true, + }; +} + export function buildDepletionView(depletion: Depletion | null | undefined): SafeLineView | null { if (!depletion || depletion.riskLevel === "safe") return null; return { safePercent: depletion.safeUsagePercent, riskLevel: depletion.riskLevel }; @@ -248,8 +266,9 @@ function trendPointsToValues(points: TrendPoint[]): { value: number }[] { export function buildDashboardView( overview: DashboardOverview, requestLogs: RequestLog[], - isDark = false, + optionsOrIsDark: DashboardViewOptions | boolean = false, ): DashboardView { + const { isDark, showAccountBurnrate } = resolveDashboardViewOptions(optionsOrIsDark); const primaryWindow = overview.windows.primary; const secondaryWindow = overview.windows.secondary; const metrics = overview.summary.metrics; @@ -291,25 +310,29 @@ export function buildDashboardView( trend: trendPointsToValues(trends.cost), trendColor: TREND_COLORS[2], }, - { + ]; + + if (showAccountBurnrate) { + stats.push({ label: `Plus Burn (${primaryBurnLabel}/${secondaryBurnLabel})`, value: `${formatBurnEquivalent(primaryBurnEquivalent)} / ${formatBurnEquivalent(secondaryBurnEquivalent)}`, meta: `Primary ${formatBurnEquivalent(primaryBurnEquivalent)} acc/${primaryBurnLabel} · Secondary ${formatBurnEquivalent(secondaryBurnEquivalent)} acc/${secondaryBurnLabel}`, icon: Flame, trend: buildBurnTrend(trends.tokens, combinedBurnEquivalent), trendColor: TREND_COLORS[3], - }, - { - label: "Error rate", - value: formatRate(metrics?.errorRate7d ?? null), - meta: metrics?.topError - ? `Top: ${metrics.topError}` - : `~${formatCompactNumber(Math.round((metrics?.errorRate7d ?? 0) * (metrics?.requests7d ?? 0)))} errors in 7d`, - icon: AlertTriangle, - trend: trendPointsToValues(trends.errorRate), - trendColor: TREND_COLORS[4], - }, - ]; + }); + } + + stats.push({ + label: "Error rate", + value: formatRate(metrics?.errorRate7d ?? null), + meta: metrics?.topError + ? `Top: ${metrics.topError}` + : `~${formatCompactNumber(Math.round((metrics?.errorRate7d ?? 0) * (metrics?.requests7d ?? 0)))} errors in 7d`, + icon: AlertTriangle, + trend: trendPointsToValues(trends.errorRate), + trendColor: TREND_COLORS[4], + }); return { stats, diff --git a/frontend/src/features/settings/components/appearance-settings.tsx b/frontend/src/features/settings/components/appearance-settings.tsx index 045eb304..7add0d8d 100644 --- a/frontend/src/features/settings/components/appearance-settings.tsx +++ b/frontend/src/features/settings/components/appearance-settings.tsx @@ -1,5 +1,7 @@ import { Monitor, Moon, Palette, Sun } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { useThemeStore, type ThemePreference } from "@/hooks/use-theme"; import { cn } from "@/lib/utils"; @@ -12,6 +14,8 @@ const THEME_OPTIONS: { value: ThemePreference; label: string; icon: typeof Sun } export function AppearanceSettings() { const preference = useThemeStore((s) => s.preference); const setTheme = useThemeStore((s) => s.setTheme); + const accountBurnrateEnabled = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled); + const setAccountBurnrateEnabled = useDashboardPreferencesStore((s) => s.setAccountBurnrateEnabled); return (
@@ -28,28 +32,38 @@ export function AppearanceSettings() {
-
-
-

Theme

-

Select your preferred color scheme.

+
+
+
+

Theme

+

Select your preferred color scheme.

+
+
+ {THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( + + ))} +
-
- {THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( - - ))} + +
+
+

Account Burnrate

+

Show the plus burnrate card on the dashboard.

+
+
diff --git a/frontend/src/hooks/use-dashboard-preferences.ts b/frontend/src/hooks/use-dashboard-preferences.ts new file mode 100644 index 00000000..67145b15 --- /dev/null +++ b/frontend/src/hooks/use-dashboard-preferences.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; + +const ACCOUNT_BURNRATE_STORAGE_KEY = "codex-lb-account-burnrate-enabled"; + +type DashboardPreferencesState = { + accountBurnrateEnabled: boolean; + initialized: boolean; + initializePreferences: () => void; + setAccountBurnrateEnabled: (enabled: boolean) => void; +}; + +function readStoredAccountBurnrateEnabled(): boolean | null { + if (typeof window === "undefined") { + return null; + } + const stored = window.localStorage.getItem(ACCOUNT_BURNRATE_STORAGE_KEY); + if (stored === "true") { + return true; + } + if (stored === "false") { + return false; + } + return null; +} + +function persistAccountBurnrateEnabled(enabled: boolean): void { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(ACCOUNT_BURNRATE_STORAGE_KEY, String(enabled)); +} + +export const useDashboardPreferencesStore = create((set) => ({ + accountBurnrateEnabled: true, + initialized: false, + initializePreferences: () => { + const accountBurnrateEnabled = readStoredAccountBurnrateEnabled() ?? true; + persistAccountBurnrateEnabled(accountBurnrateEnabled); + set({ accountBurnrateEnabled, initialized: true }); + }, + setAccountBurnrateEnabled: (enabled) => { + persistAccountBurnrateEnabled(enabled); + set({ accountBurnrateEnabled: enabled, initialized: true }); + }, +})); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 72c85c24..cca098cd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,12 +4,14 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App.tsx"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { queryClient } from "@/lib/query-client"; import { useThemeStore } from "@/hooks/use-theme"; import "./index.css"; useThemeStore.getState().initializeTheme(); +useDashboardPreferencesStore.getState().initializePreferences(); createRoot(document.getElementById("root")!).render( From f2ccca2b91ab85956eaf3511715d3b91fbc4c262 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 12:54:42 +0000 Subject: [PATCH 04/10] chore(ui): rename plus burn to account burn rate --- .../src/features/dashboard/components/stats-grid.test.tsx | 4 ++-- frontend/src/features/dashboard/utils.test.ts | 4 ++-- frontend/src/features/dashboard/utils.ts | 2 +- .../src/features/settings/components/appearance-settings.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/dashboard/components/stats-grid.test.tsx b/frontend/src/features/dashboard/components/stats-grid.test.tsx index 01a6d1ee..f902ca06 100644 --- a/frontend/src/features/dashboard/components/stats-grid.test.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.test.tsx @@ -16,7 +16,7 @@ describe("StatsGrid", () => { { label: "Requests (7d)", value: "228", icon: Activity, trend: SAMPLE_TREND, trendColor: "#3b82f6" }, { label: "Tokens (7d)", value: "45K", icon: Coins, trend: SAMPLE_TREND, trendColor: "#8b5cf6" }, { label: "Cost (7d)", value: "$1.82", meta: "Avg/hr $0.01", icon: DollarSign, trend: SAMPLE_TREND, trendColor: "#10b981" }, - { label: "Plus Burn (5h/7d)", value: "0.7 / 0.8", meta: "Primary 0.7 acc/5h · Secondary 0.8 acc/7d", icon: Flame, trend: SAMPLE_TREND, trendColor: "#ef4444" }, + { label: "Account burn rate (5h/7d)", value: "0.7 / 0.8", meta: "Primary 0.7 acc/5h · Secondary 0.8 acc/7d", icon: Flame, trend: SAMPLE_TREND, trendColor: "#ef4444" }, { label: "Error rate", value: "2.8%", meta: "Top: rate_limit_exceeded", icon: AlertTriangle, trend: SAMPLE_TREND, trendColor: "#f59e0b" }, ]} />, @@ -28,7 +28,7 @@ describe("StatsGrid", () => { expect(screen.getByText("45K")).toBeInTheDocument(); expect(screen.getByText("Cost (7d)")).toBeInTheDocument(); expect(screen.getByText("Avg/hr $0.01")).toBeInTheDocument(); - expect(screen.getByText("Plus Burn (5h/7d)")).toBeInTheDocument(); + expect(screen.getByText("Account burn rate (5h/7d)")).toBeInTheDocument(); expect(screen.getByText("Primary 0.7 acc/5h · Secondary 0.8 acc/7d")).toBeInTheDocument(); expect(screen.getByText("Error rate")).toBeInTheDocument(); expect(screen.getByText("Top: rate_limit_exceeded")).toBeInTheDocument(); diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index 3eff2790..bdff0eb5 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -134,7 +134,7 @@ describe("buildDashboardView", () => { const view = buildDashboardView(overview, logs); expect(view.stats[2].label).toBe("Cost (7d)"); - expect(view.stats[3].label).toBe("Plus Burn (5h/7d)"); + expect(view.stats[3].label).toBe("Account burn rate (5h/7d)"); expect(view.stats[3].value).toBe("0.7 / 1.2"); expect(view.stats[3].meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); expect(view.stats[3].trend.length).toBeGreaterThan(0); @@ -150,7 +150,7 @@ describe("buildDashboardView", () => { const view = buildDashboardView(overview, createDefaultRequestLogs()); const burn = view.stats[3]; - expect(burn.label).toBe("Plus Burn (5h/7d)"); + expect(burn.label).toBe("Account burn rate (5h/7d)"); expect(burn.value).toBe("0.7 / 1.2"); expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); expect(burn.trend.length).toBeGreaterThan(0); diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index 194c5f7b..16aa317c 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -314,7 +314,7 @@ export function buildDashboardView( if (showAccountBurnrate) { stats.push({ - label: `Plus Burn (${primaryBurnLabel}/${secondaryBurnLabel})`, + label: `Account burn rate (${primaryBurnLabel}/${secondaryBurnLabel})`, value: `${formatBurnEquivalent(primaryBurnEquivalent)} / ${formatBurnEquivalent(secondaryBurnEquivalent)}`, meta: `Primary ${formatBurnEquivalent(primaryBurnEquivalent)} acc/${primaryBurnLabel} · Secondary ${formatBurnEquivalent(secondaryBurnEquivalent)} acc/${secondaryBurnLabel}`, icon: Flame, diff --git a/frontend/src/features/settings/components/appearance-settings.tsx b/frontend/src/features/settings/components/appearance-settings.tsx index 7add0d8d..01e7a997 100644 --- a/frontend/src/features/settings/components/appearance-settings.tsx +++ b/frontend/src/features/settings/components/appearance-settings.tsx @@ -60,8 +60,8 @@ export function AppearanceSettings() {
-

Account Burnrate

-

Show the plus burnrate card on the dashboard.

+

Account burn rate

+

Show the account burn rate card on the dashboard.

From 3f96423a62951153730422ea700c4ba492d70cfa Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 13:04:41 +0000 Subject: [PATCH 05/10] fix(burnrate): project window burn to full period --- frontend/src/features/dashboard/utils.test.ts | 38 ++++++++++++- frontend/src/features/dashboard/utils.ts | 54 +++++++++++++++++-- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index bdff0eb5..5200d331 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -6,7 +6,7 @@ import { buildRemainingItems, } from "@/features/dashboard/utils"; import type { AccountSummary, Depletion } from "@/features/dashboard/schemas"; -import { createDashboardOverview, createDefaultRequestLogs } from "@/test/mocks/factories"; +import { createAccountSummary, createDashboardOverview, createDefaultRequestLogs } from "@/test/mocks/factories"; import { formatCompactAccountId } from "@/utils/account-identifiers"; function account(overrides: Partial & Pick): AccountSummary { @@ -156,6 +156,42 @@ describe("buildDashboardView", () => { expect(burn.trend.length).toBeGreaterThan(0); }); + it("projects burn rate to full 7d window when reset is still in the future", () => { + const now = Date.now(); + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-idle", + email: "idle@example.com", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 100, + }, + resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + createAccountSummary({ + accountId: "acc-hot", + email: "hot@example.com", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 16, + }, + resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.0 / 1.5"); + expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 1.5 acc/7d"); + }); + it("uses usage-equivalent fallback when burn rate is zero", () => { const overview = createDashboardOverview({ depletionSecondary: { diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index 16aa317c..2917cf6d 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -177,6 +177,46 @@ function windowUsedAccountEquivalents( return includedAccounts > 0 ? usedEquivalent : null; } +function windowProjectedAccountEquivalents( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + let projectedEquivalent = 0; + let includedAccounts = 0; + const nowMs = Date.now(); + + for (const account of overview.accounts) { + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + const resetAt = windowKey === "primary" ? account.resetAtPrimary : account.resetAtSecondary; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent) || windowMinutes <= 0) { + continue; + } + + const usedEquivalent = (100 - clampPercent(remainingPercent)) / 100; + let projected = usedEquivalent; + + if (resetAt) { + const resetAtMs = Date.parse(resetAt); + if (Number.isFinite(resetAtMs)) { + const windowSeconds = windowMinutes * 60; + const secondsUntilReset = Math.max(0, (resetAtMs - nowMs) / 1000); + const elapsedSeconds = Math.max(0, windowSeconds - secondsUntilReset); + if (elapsedSeconds > 0) { + projected = usedEquivalent * (windowSeconds / elapsedSeconds); + } + } + } + + projectedEquivalent += projected; + includedAccounts += 1; + } + + return includedAccounts > 0 ? projectedEquivalent : null; +} + function windowIncludedAccountCount( overview: DashboardOverview, windowKey: "primary" | "secondary", @@ -204,10 +244,11 @@ function plusAccountsBurnEquivalent( ): number | null { const summaryWindow = windowKey === "primary" ? overview.summary.primaryWindow : overview.summary.secondaryWindow; const depletion = windowKey === "primary" ? overview.depletionPrimary : overview.depletionSecondary; + const fallbackProjectedEquivalent = windowProjectedAccountEquivalents(overview, windowKey); const fallbackUsedEquivalent = windowUsedAccountEquivalents(overview, windowKey); if (!summaryWindow) { - return fallbackUsedEquivalent; + return fallbackProjectedEquivalent ?? fallbackUsedEquivalent; } const remainingCredits = summaryWindow.remainingCredits; @@ -229,11 +270,16 @@ function plusAccountsBurnEquivalent( } } - if (windowKey === "secondary" && isFiniteNumber(fallbackUsedEquivalent)) { - return burnEquivalent === null ? fallbackUsedEquivalent : Math.max(burnEquivalent, fallbackUsedEquivalent); + if (windowKey === "secondary") { + if (isFiniteNumber(fallbackProjectedEquivalent)) { + return burnEquivalent === null ? fallbackProjectedEquivalent : Math.max(burnEquivalent, fallbackProjectedEquivalent); + } + if (isFiniteNumber(fallbackUsedEquivalent)) { + return burnEquivalent === null ? fallbackUsedEquivalent : Math.max(burnEquivalent, fallbackUsedEquivalent); + } } - return burnEquivalent ?? fallbackUsedEquivalent; + return burnEquivalent ?? fallbackProjectedEquivalent ?? fallbackUsedEquivalent; } function formatBurnEquivalent(value: number | null): string { From 8952ebea3b74f464c6efbc36ecd0e7892edf0eb5 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 13:19:18 +0000 Subject: [PATCH 06/10] fix(burnrate): account for quota_exceeded in secondary projection --- frontend/src/features/dashboard/utils.test.ts | 38 +++++++++++++++ frontend/src/features/dashboard/utils.ts | 48 ++++++++++++++----- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index 5200d331..705fc9ac 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -192,6 +192,44 @@ describe("buildDashboardView", () => { expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 1.5 acc/7d"); }); + it("counts quota_exceeded secondary accounts as fully burned and caps to account count", () => { + const now = Date.now(); + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-quota", + email: "quota@example.com", + status: "quota_exceeded", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 100, + }, + resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + createAccountSummary({ + accountId: "acc-hot", + email: "hot@example.com", + status: "active", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 16, + }, + resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.0 / 2.0"); + expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 2.0 acc/7d"); + }); + it("uses usage-equivalent fallback when burn rate is zero", () => { const overview = createDashboardOverview({ depletionSecondary: { diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index 2917cf6d..ba4c315f 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -170,7 +170,12 @@ function windowUsedAccountEquivalents( continue; } - usedEquivalent += (100 - clampPercent(remainingPercent)) / 100; + let accountEquivalent = (100 - clampPercent(remainingPercent)) / 100; + if (windowKey === "secondary" && account.status === "quota_exceeded") { + accountEquivalent = Math.max(accountEquivalent, 1); + } + + usedEquivalent += accountEquivalent; includedAccounts += 1; } @@ -210,6 +215,10 @@ function windowProjectedAccountEquivalents( } } + if (windowKey === "secondary" && account.status === "quota_exceeded") { + projected = Math.max(projected, 1); + } + projectedEquivalent += projected; includedAccounts += 1; } @@ -238,14 +247,30 @@ function windowIncludedAccountCount( return includedAccounts; } +function clampBurnEquivalent(value: number | null, maxEquivalent: number): number | null { + if (!isFiniteNumber(value)) { + return null; + } + + const clamped = Math.max(0, value); + if (maxEquivalent <= 0) { + return clamped; + } + return Math.min(clamped, maxEquivalent); +} + function plusAccountsBurnEquivalent( overview: DashboardOverview, windowKey: "primary" | "secondary", ): number | null { const summaryWindow = windowKey === "primary" ? overview.summary.primaryWindow : overview.summary.secondaryWindow; const depletion = windowKey === "primary" ? overview.depletionPrimary : overview.depletionSecondary; - const fallbackProjectedEquivalent = windowProjectedAccountEquivalents(overview, windowKey); - const fallbackUsedEquivalent = windowUsedAccountEquivalents(overview, windowKey); + const maxEquivalent = windowIncludedAccountCount(overview, windowKey); + const fallbackProjectedEquivalent = clampBurnEquivalent( + windowProjectedAccountEquivalents(overview, windowKey), + maxEquivalent, + ); + const fallbackUsedEquivalent = clampBurnEquivalent(windowUsedAccountEquivalents(overview, windowKey), maxEquivalent); if (!summaryWindow) { return fallbackProjectedEquivalent ?? fallbackUsedEquivalent; @@ -263,19 +288,20 @@ function plusAccountsBurnEquivalent( } } - if (burnEquivalent !== null) { - const maxEquivalent = windowIncludedAccountCount(overview, windowKey); - if (maxEquivalent > 0) { - burnEquivalent = Math.min(burnEquivalent, maxEquivalent); - } - } + burnEquivalent = clampBurnEquivalent(burnEquivalent, maxEquivalent); if (windowKey === "secondary") { if (isFiniteNumber(fallbackProjectedEquivalent)) { - return burnEquivalent === null ? fallbackProjectedEquivalent : Math.max(burnEquivalent, fallbackProjectedEquivalent); + return clampBurnEquivalent( + burnEquivalent === null ? fallbackProjectedEquivalent : Math.max(burnEquivalent, fallbackProjectedEquivalent), + maxEquivalent, + ); } if (isFiniteNumber(fallbackUsedEquivalent)) { - return burnEquivalent === null ? fallbackUsedEquivalent : Math.max(burnEquivalent, fallbackUsedEquivalent); + return clampBurnEquivalent( + burnEquivalent === null ? fallbackUsedEquivalent : Math.max(burnEquivalent, fallbackUsedEquivalent), + maxEquivalent, + ); } } From 75eb5aefe1d60e2c7b602ec38764faff7f6fd928 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 20:14:56 +0000 Subject: [PATCH 07/10] fix(settings): support legacy settings payloads --- frontend/src/features/settings/schemas.test.ts | 15 +++++++++++++++ frontend/src/features/settings/schemas.ts | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/settings/schemas.test.ts b/frontend/src/features/settings/schemas.test.ts index 1ed32ea8..533c9ef3 100644 --- a/frontend/src/features/settings/schemas.test.ts +++ b/frontend/src/features/settings/schemas.test.ts @@ -26,6 +26,21 @@ describe("DashboardSettingsSchema", () => { expect(parsed.importWithoutOverwrite).toBe(true); expect(parsed.apiKeyAuthEnabled).toBe(true); }); + + it("parses legacy settings payload and applies defaults for missing routing fields", () => { + const parsed = DashboardSettingsSchema.parse({ + stickyThreadsEnabled: true, + preferEarlierResetAccounts: false, + importWithoutOverwrite: false, + totpRequiredOnLogin: false, + totpConfigured: false, + apiKeyAuthEnabled: true, + }); + + expect(parsed.upstreamStreamTransport).toBe("default"); + expect(parsed.routingStrategy).toBe("usage_weighted"); + expect(parsed.openaiCacheAffinityMaxAgeSeconds).toBe(300); + }); }); describe("SettingsUpdateRequestSchema", () => { diff --git a/frontend/src/features/settings/schemas.ts b/frontend/src/features/settings/schemas.ts index 53114d04..c272abb8 100644 --- a/frontend/src/features/settings/schemas.ts +++ b/frontend/src/features/settings/schemas.ts @@ -5,10 +5,10 @@ export const UpstreamStreamTransportSchema = z.enum(["default", "auto", "http", export const DashboardSettingsSchema = z.object({ stickyThreadsEnabled: z.boolean(), - upstreamStreamTransport: UpstreamStreamTransportSchema, + upstreamStreamTransport: UpstreamStreamTransportSchema.optional().default("default"), preferEarlierResetAccounts: z.boolean(), - routingStrategy: RoutingStrategySchema, - openaiCacheAffinityMaxAgeSeconds: z.number().int().positive(), + routingStrategy: RoutingStrategySchema.optional().default("usage_weighted"), + openaiCacheAffinityMaxAgeSeconds: z.number().int().positive().optional().default(300), importWithoutOverwrite: z.boolean(), totpRequiredOnLogin: z.boolean(), totpConfigured: z.boolean(), From 6f102b0173f609ca6cbdea1a53047e9284dccf7f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 14:13:28 +0000 Subject: [PATCH 08/10] feat(burnrate): compute and persist plus-equivalent burn snapshots --- app/core/usage/refresh_scheduler.py | 29 ++- .../20260319_130000_add_burn_rate_history.py | 69 +++++++ app/db/models.py | 28 +++ app/modules/dashboard/repository.py | 9 +- app/modules/dashboard/schemas.py | 15 ++ app/modules/dashboard/service.py | 49 ++++- app/modules/usage/burnrate.py | 183 ++++++++++++++++++ app/modules/usage/repository.py | 45 ++++- frontend/src/features/dashboard/schemas.ts | 15 ++ frontend/src/features/dashboard/utils.test.ts | 64 ++++++ frontend/src/features/dashboard/utils.ts | 62 ++++-- frontend/src/test/mocks/factories.ts | 13 ++ tests/unit/test_usage_burnrate.py | 114 +++++++++++ 13 files changed, 679 insertions(+), 16 deletions(-) create mode 100644 app/db/alembic/versions/20260319_130000_add_burn_rate_history.py create mode 100644 app/modules/usage/burnrate.py create mode 100644 tests/unit/test_usage_burnrate.py diff --git a/app/core/usage/refresh_scheduler.py b/app/core/usage/refresh_scheduler.py index a8a351ef..ab8d190c 100644 --- a/app/core/usage/refresh_scheduler.py +++ b/app/core/usage/refresh_scheduler.py @@ -6,10 +6,12 @@ from dataclasses import dataclass, field from app.core.config.settings import get_settings +from app.core.utils.time import utcnow from app.db.session import get_background_session from app.modules.accounts.repository import AccountsRepository from app.modules.proxy.rate_limit_cache import get_rate_limit_headers_cache -from app.modules.usage.repository import AdditionalUsageRepository, UsageRepository +from app.modules.usage.burnrate import compute_burn_rate_snapshot +from app.modules.usage.repository import AdditionalUsageRepository, BurnRateHistoryRepository, UsageRepository from app.modules.usage.updater import UsageUpdater logger = logging.getLogger(__name__) @@ -59,6 +61,31 @@ async def _refresh_once(self) -> None: accounts = await accounts_repo.list_accounts() updater = UsageUpdater(usage_repo, accounts_repo, additional_usage_repo) await updater.refresh_accounts(accounts, latest_usage) + + accounts = await accounts_repo.list_accounts() + latest_primary = await usage_repo.latest_by_account(window="primary") + latest_secondary = await usage_repo.latest_by_account(window="secondary") + burn_snapshot = compute_burn_rate_snapshot( + accounts=accounts, + latest_primary_usage=latest_primary, + latest_secondary_usage=latest_secondary, + now=utcnow(), + ) + burn_rate_repo = BurnRateHistoryRepository(session) + await burn_rate_repo.add_entry( + primary_projected_plus_accounts=burn_snapshot.primary.projected_plus_accounts, + secondary_projected_plus_accounts=burn_snapshot.secondary.projected_plus_accounts, + primary_used_plus_accounts=burn_snapshot.primary.used_plus_accounts, + secondary_used_plus_accounts=burn_snapshot.secondary.used_plus_accounts, + primary_window_minutes=burn_snapshot.primary.window_minutes, + secondary_window_minutes=burn_snapshot.secondary.window_minutes, + primary_account_count=burn_snapshot.primary.included_account_count, + secondary_account_count=burn_snapshot.secondary.included_account_count, + primary_max_plus_equivalent_accounts=burn_snapshot.primary.max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=burn_snapshot.secondary.max_plus_equivalent_accounts, + recorded_at=burn_snapshot.recorded_at, + ) + await get_rate_limit_headers_cache().invalidate() except Exception: logger.exception("Usage refresh loop failed") diff --git a/app/db/alembic/versions/20260319_130000_add_burn_rate_history.py b/app/db/alembic/versions/20260319_130000_add_burn_rate_history.py new file mode 100644 index 00000000..9276b4a4 --- /dev/null +++ b/app/db/alembic/versions/20260319_130000_add_burn_rate_history.py @@ -0,0 +1,69 @@ +"""add burn_rate_history table + +Revision ID: 20260319_130000_add_burn_rate_history +Revises: 20260312_120000_add_dashboard_upstream_stream_transport +Create Date: 2026-03-19 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision = "20260319_130000_add_burn_rate_history" +down_revision = "20260312_120000_add_dashboard_upstream_stream_transport" +branch_labels = None +depends_on = None + + +def _table_exists(connection: Connection, table_name: str) -> bool: + inspector = sa.inspect(connection) + return inspector.has_table(table_name) + + +def _indexes(connection: Connection, table_name: str) -> set[str]: + inspector = sa.inspect(connection) + if not inspector.has_table(table_name): + return set() + return {str(index["name"]) for index in inspector.get_indexes(table_name) if index.get("name") is not None} + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "burn_rate_history"): + op.create_table( + "burn_rate_history", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("recorded_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("primary_projected_plus_accounts", sa.Float(), nullable=True), + sa.Column("secondary_projected_plus_accounts", sa.Float(), nullable=True), + sa.Column("primary_used_plus_accounts", sa.Float(), nullable=True), + sa.Column("secondary_used_plus_accounts", sa.Float(), nullable=True), + sa.Column("primary_window_minutes", sa.Integer(), nullable=True), + sa.Column("secondary_window_minutes", sa.Integer(), nullable=True), + sa.Column("primary_account_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("secondary_account_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column( + "primary_max_plus_equivalent_accounts", + sa.Float(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "secondary_max_plus_equivalent_accounts", + sa.Float(), + nullable=False, + server_default=sa.text("0"), + ), + ) + + existing_indexes = _indexes(bind, "burn_rate_history") + if "idx_burn_rate_recorded_at" not in existing_indexes: + op.create_index("idx_burn_rate_recorded_at", "burn_rate_history", ["recorded_at"]) + + +def downgrade() -> None: + op.drop_table("burn_rate_history") diff --git a/app/db/models.py b/app/db/models.py index 2d434165..7c544f5d 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -107,6 +107,33 @@ class AdditionalUsageHistory(Base): recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) +class BurnRateHistory(Base): + __tablename__ = "burn_rate_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + primary_projected_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + secondary_projected_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + primary_used_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + secondary_used_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + primary_window_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + secondary_window_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + primary_account_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) + secondary_account_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) + primary_max_plus_equivalent_accounts: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0.0, + server_default=text("0"), + ) + secondary_max_plus_equivalent_accounts: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0.0, + server_default=text("0"), + ) + + class RequestLog(Base): __tablename__ = "request_logs" @@ -361,6 +388,7 @@ class ApiKeyUsageReservationItem(Base): UsageHistory.recorded_at.desc(), UsageHistory.id.desc(), ) +Index("idx_burn_rate_recorded_at", BurnRateHistory.recorded_at) Index("idx_accounts_email", Account.email) Index("idx_logs_account_time", RequestLog.account_id, RequestLog.requested_at) Index("idx_logs_requested_at", RequestLog.requested_at) diff --git a/app/modules/dashboard/repository.py b/app/modules/dashboard/repository.py index b8f2d027..e5e603c4 100644 --- a/app/modules/dashboard/repository.py +++ b/app/modules/dashboard/repository.py @@ -6,10 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.usage.types import BucketModelAggregate -from app.db.models import Account, AdditionalUsageHistory, RequestLog, UsageHistory +from app.db.models import Account, AdditionalUsageHistory, BurnRateHistory, RequestLog, UsageHistory from app.modules.accounts.repository import AccountsRepository from app.modules.request_logs.repository import RequestLogsRepository -from app.modules.usage.repository import AdditionalUsageRepository, UsageRepository +from app.modules.usage.repository import AdditionalUsageRepository, BurnRateHistoryRepository, UsageRepository class DashboardRepository: @@ -18,6 +18,7 @@ def __init__(self, session: AsyncSession) -> None: self._usage_repo = UsageRepository(session) self._logs_repo = RequestLogsRepository(session) self._additional_usage_repo = AdditionalUsageRepository(session) + self._burn_rate_repo = BurnRateHistoryRepository(session) async def list_accounts(self) -> list[Account]: return await self._accounts_repo.list_accounts() @@ -69,3 +70,7 @@ async def latest_additional_usage_by_account( async def latest_additional_recorded_at(self) -> datetime | None: return await self._additional_usage_repo.latest_recorded_at() + + + async def latest_burn_rate_entry(self) -> BurnRateHistory | None: + return await self._burn_rate_repo.latest_entry() diff --git a/app/modules/dashboard/schemas.py b/app/modules/dashboard/schemas.py index 49cbdd73..cdc513a2 100644 --- a/app/modules/dashboard/schemas.py +++ b/app/modules/dashboard/schemas.py @@ -24,6 +24,20 @@ class DepletionResponse(DashboardModel): seconds_until_exhaustion: float | None = None +class BurnRateSnapshotResponse(DashboardModel): + recorded_at: datetime + primary_projected_plus_accounts: float | None = None + secondary_projected_plus_accounts: float | None = None + primary_used_plus_accounts: float | None = None + secondary_used_plus_accounts: float | None = None + primary_window_minutes: int | None = None + secondary_window_minutes: int | None = None + primary_account_count: int = 0 + secondary_account_count: int = 0 + primary_max_plus_equivalent_accounts: float = 0.0 + secondary_max_plus_equivalent_accounts: float = 0.0 + + class DashboardOverviewResponse(DashboardModel): last_sync_at: datetime | None = None accounts: List[AccountSummary] = Field(default_factory=list) @@ -32,3 +46,4 @@ class DashboardOverviewResponse(DashboardModel): trends: MetricsTrends depletion_primary: DepletionResponse | None = None depletion_secondary: DepletionResponse | None = None + burn_rate: BurnRateSnapshotResponse | None = None diff --git a/app/modules/dashboard/service.py b/app/modules/dashboard/service.py index 2a16a2c7..6745b16b 100644 --- a/app/modules/dashboard/service.py +++ b/app/modules/dashboard/service.py @@ -6,10 +6,11 @@ from app.core.crypto import TokenEncryptor from app.core.usage.types import UsageWindowRow from app.core.utils.time import utcnow -from app.db.models import UsageHistory +from app.db.models import BurnRateHistory, UsageHistory from app.modules.accounts.mappers import build_account_summaries from app.modules.dashboard.repository import DashboardRepository from app.modules.dashboard.schemas import ( + BurnRateSnapshotResponse, DashboardOverviewResponse, DashboardUsageWindows, DepletionResponse, @@ -19,6 +20,7 @@ build_usage_summary_response, build_usage_window_response, ) +from app.modules.usage.burnrate import BurnRateSnapshot, compute_burn_rate_snapshot from app.modules.usage.depletion_service import ( compute_aggregate_depletion, compute_depletion_for_account, @@ -190,6 +192,15 @@ async def get_overview(self) -> DashboardOverviewResponse: pri_depletion, sec_depletion = _build_depletion_by_window(primary_history, secondary_history, now) additional_ts = await self._repo.latest_additional_recorded_at() + latest_burn_rate_entry = await self._repo.latest_burn_rate_entry() + computed_burn_rate = compute_burn_rate_snapshot( + accounts=accounts, + latest_primary_usage=primary_usage, + latest_secondary_usage=secondary_usage, + now=now, + ) + burn_rate = _resolve_burn_rate_response(latest_burn_rate_entry, computed_burn_rate) + return DashboardOverviewResponse( last_sync_at=_latest_recorded_at(primary_usage, secondary_usage, additional_ts), accounts=account_summaries, @@ -198,6 +209,7 @@ async def get_overview(self) -> DashboardOverviewResponse: trends=trends, depletion_primary=pri_depletion, depletion_secondary=sec_depletion, + burn_rate=burn_rate, ) @@ -280,3 +292,38 @@ def _latest_recorded_at( if additional_ts is not None: timestamps.append(additional_ts) return max(timestamps) if timestamps else None + + + +def _resolve_burn_rate_response( + latest_entry: BurnRateHistory | None, + computed_snapshot: BurnRateSnapshot, +) -> BurnRateSnapshotResponse: + if latest_entry is not None: + return BurnRateSnapshotResponse( + recorded_at=latest_entry.recorded_at, + primary_projected_plus_accounts=latest_entry.primary_projected_plus_accounts, + secondary_projected_plus_accounts=latest_entry.secondary_projected_plus_accounts, + primary_used_plus_accounts=latest_entry.primary_used_plus_accounts, + secondary_used_plus_accounts=latest_entry.secondary_used_plus_accounts, + primary_window_minutes=latest_entry.primary_window_minutes, + secondary_window_minutes=latest_entry.secondary_window_minutes, + primary_account_count=latest_entry.primary_account_count, + secondary_account_count=latest_entry.secondary_account_count, + primary_max_plus_equivalent_accounts=latest_entry.primary_max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=latest_entry.secondary_max_plus_equivalent_accounts, + ) + + return BurnRateSnapshotResponse( + recorded_at=computed_snapshot.recorded_at, + primary_projected_plus_accounts=computed_snapshot.primary.projected_plus_accounts, + secondary_projected_plus_accounts=computed_snapshot.secondary.projected_plus_accounts, + primary_used_plus_accounts=computed_snapshot.primary.used_plus_accounts, + secondary_used_plus_accounts=computed_snapshot.secondary.used_plus_accounts, + primary_window_minutes=computed_snapshot.primary.window_minutes, + secondary_window_minutes=computed_snapshot.secondary.window_minutes, + primary_account_count=computed_snapshot.primary.included_account_count, + secondary_account_count=computed_snapshot.secondary.included_account_count, + primary_max_plus_equivalent_accounts=computed_snapshot.primary.max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=computed_snapshot.secondary.max_plus_equivalent_accounts, + ) diff --git a/app/modules/usage/burnrate.py b/app/modules/usage/burnrate.py new file mode 100644 index 00000000..16d4727a --- /dev/null +++ b/app/modules/usage/burnrate.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from datetime import datetime +from typing import Mapping + +from app.core import usage as usage_core +from app.core.usage.types import UsageWindowRow +from app.db.models import Account, AccountStatus, UsageHistory + +PLUS_CAPACITY_CREDITS = { + "primary": 225.0, + "secondary": 7560.0, +} + + +@dataclass(frozen=True, slots=True) +class BurnRateWindowSnapshot: + projected_plus_accounts: float | None + used_plus_accounts: float | None + included_account_count: int + max_plus_equivalent_accounts: float + window_minutes: int | None + + +@dataclass(frozen=True, slots=True) +class BurnRateSnapshot: + recorded_at: datetime + primary: BurnRateWindowSnapshot + secondary: BurnRateWindowSnapshot + + +def compute_burn_rate_snapshot( + *, + accounts: list[Account], + latest_primary_usage: Mapping[str, UsageHistory], + latest_secondary_usage: Mapping[str, UsageHistory], + now: datetime, +) -> BurnRateSnapshot: + primary_rows, secondary_rows = _normalize_latest_windows(latest_primary_usage, latest_secondary_usage) + accounts_by_id = {account.id: account for account in accounts} + now_epoch = int(now.timestamp()) + + primary_snapshot = _compute_window_snapshot( + accounts_by_id=accounts_by_id, + rows_by_account=primary_rows, + window="primary", + now_epoch=now_epoch, + ) + secondary_snapshot = _compute_window_snapshot( + accounts_by_id=accounts_by_id, + rows_by_account=secondary_rows, + window="secondary", + now_epoch=now_epoch, + ) + + return BurnRateSnapshot( + recorded_at=now, + primary=primary_snapshot, + secondary=secondary_snapshot, + ) + + +def _normalize_latest_windows( + latest_primary_usage: Mapping[str, UsageHistory], + latest_secondary_usage: Mapping[str, UsageHistory], +) -> tuple[dict[str, UsageWindowRow], dict[str, UsageWindowRow]]: + primary_rows_raw = [_usage_history_to_window_row(entry) for entry in latest_primary_usage.values()] + secondary_rows_raw = [_usage_history_to_window_row(entry) for entry in latest_secondary_usage.values()] + + primary_rows, secondary_rows = usage_core.normalize_weekly_only_rows(primary_rows_raw, secondary_rows_raw) + return ( + {row.account_id: row for row in primary_rows}, + {row.account_id: row for row in secondary_rows}, + ) + + +def _usage_history_to_window_row(entry: UsageHistory) -> UsageWindowRow: + return UsageWindowRow( + account_id=entry.account_id, + used_percent=entry.used_percent, + reset_at=entry.reset_at, + window_minutes=entry.window_minutes, + recorded_at=entry.recorded_at, + ) + + +def _compute_window_snapshot( + *, + accounts_by_id: dict[str, Account], + rows_by_account: dict[str, UsageWindowRow], + window: str, + now_epoch: int, +) -> BurnRateWindowSnapshot: + included_account_count = 0 + used_plus_accounts = 0.0 + projected_plus_accounts = 0.0 + max_plus_equivalent_accounts = 0.0 + + for account_id, row in rows_by_account.items(): + account = accounts_by_id.get(account_id) + if account is None: + continue + + used_percent = _normalize_used_percent(row.used_percent) + if used_percent is None: + continue + + weight = _plus_equivalent_weight(account.plan_type, window) + used_equivalent = (used_percent / 100.0) * weight + + projected_equivalent = used_equivalent + window_minutes = _effective_window_minutes(window, row.window_minutes) + if window_minutes is not None and window_minutes > 0 and row.reset_at is not None: + window_seconds = window_minutes * 60 + seconds_until_reset = max(0, row.reset_at - now_epoch) + elapsed_seconds = max(0, window_seconds - seconds_until_reset) + if elapsed_seconds > 0: + projected_equivalent = used_equivalent * (window_seconds / elapsed_seconds) + + if window == "secondary" and account.status == AccountStatus.QUOTA_EXCEEDED: + used_equivalent = max(used_equivalent, weight) + projected_equivalent = max(projected_equivalent, weight) + + included_account_count += 1 + used_plus_accounts += used_equivalent + projected_plus_accounts += projected_equivalent + max_plus_equivalent_accounts += weight + + if included_account_count == 0: + return BurnRateWindowSnapshot( + projected_plus_accounts=None, + used_plus_accounts=None, + included_account_count=0, + max_plus_equivalent_accounts=0.0, + window_minutes=usage_core.default_window_minutes(window), + ) + + used_plus_accounts = _clamp_equivalent(used_plus_accounts, max_plus_equivalent_accounts) + projected_plus_accounts = _clamp_equivalent(projected_plus_accounts, max_plus_equivalent_accounts) + window_minutes = usage_core.resolve_window_minutes(window, rows_by_account.values()) + + return BurnRateWindowSnapshot( + projected_plus_accounts=projected_plus_accounts, + used_plus_accounts=used_plus_accounts, + included_account_count=included_account_count, + max_plus_equivalent_accounts=max_plus_equivalent_accounts, + window_minutes=window_minutes, + ) + + +def _effective_window_minutes(window: str, raw_window_minutes: int | None) -> int | None: + if raw_window_minutes is not None and raw_window_minutes > 0: + return raw_window_minutes + return usage_core.default_window_minutes(window) + + +def _normalize_used_percent(value: float | None) -> float | None: + if value is None or not isinstance(value, (int, float)): + return None + number = float(value) + if not math.isfinite(number): + return None + return max(0.0, min(100.0, number)) + + +def _plus_equivalent_weight(plan_type: str | None, window: str) -> float: + plus_capacity = PLUS_CAPACITY_CREDITS[window] + plan_capacity = usage_core.capacity_for_plan(plan_type, window) + if plan_capacity is None or not math.isfinite(plan_capacity) or plan_capacity <= 0: + # Unknown plans (e.g. enterprise seats) are treated as plus-equivalent by default. + return 1.0 + return float(plan_capacity) / plus_capacity + + +def _clamp_equivalent(value: float, max_value: float) -> float: + if not math.isfinite(value): + return 0.0 + clamped = max(0.0, value) + if not math.isfinite(max_value) or max_value <= 0: + return clamped + return min(clamped, max_value) diff --git a/app/modules/usage/repository.py b/app/modules/usage/repository.py index 945e7c6d..53e1610a 100644 --- a/app/modules/usage/repository.py +++ b/app/modules/usage/repository.py @@ -8,7 +8,7 @@ from app.core.usage.types import UsageAggregateRow, UsageTrendBucket from app.core.utils.time import utcnow -from app.db.models import AdditionalUsageHistory, UsageHistory +from app.db.models import AdditionalUsageHistory, BurnRateHistory, UsageHistory from app.modules.usage.additional_quota_keys import ( AdditionalQuotaQueryScope, canonicalize_additional_quota_key, @@ -277,6 +277,49 @@ async def latest_window_minutes(self, window: str) -> int | None: return int(value) if value is not None else None +class BurnRateHistoryRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add_entry( + self, + *, + primary_projected_plus_accounts: float | None, + secondary_projected_plus_accounts: float | None, + primary_used_plus_accounts: float | None, + secondary_used_plus_accounts: float | None, + primary_window_minutes: int | None, + secondary_window_minutes: int | None, + primary_account_count: int, + secondary_account_count: int, + primary_max_plus_equivalent_accounts: float, + secondary_max_plus_equivalent_accounts: float, + recorded_at: datetime | None = None, + ) -> BurnRateHistory: + entry = BurnRateHistory( + primary_projected_plus_accounts=primary_projected_plus_accounts, + secondary_projected_plus_accounts=secondary_projected_plus_accounts, + primary_used_plus_accounts=primary_used_plus_accounts, + secondary_used_plus_accounts=secondary_used_plus_accounts, + primary_window_minutes=primary_window_minutes, + secondary_window_minutes=secondary_window_minutes, + primary_account_count=primary_account_count, + secondary_account_count=secondary_account_count, + primary_max_plus_equivalent_accounts=primary_max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=secondary_max_plus_equivalent_accounts, + recorded_at=recorded_at or utcnow(), + ) + self._session.add(entry) + await self._session.commit() + await self._session.refresh(entry) + return entry + + async def latest_entry(self) -> BurnRateHistory | None: + stmt = select(BurnRateHistory).order_by(BurnRateHistory.recorded_at.desc(), BurnRateHistory.id.desc()).limit(1) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + class AdditionalUsageRepository: def __init__(self, session: AsyncSession) -> None: self._session = session diff --git a/frontend/src/features/dashboard/schemas.ts b/frontend/src/features/dashboard/schemas.ts index 273aa21c..3709497e 100644 --- a/frontend/src/features/dashboard/schemas.ts +++ b/frontend/src/features/dashboard/schemas.ts @@ -62,6 +62,20 @@ export const DepletionSchema = z.object({ secondsUntilExhaustion: z.number().nullable().optional(), }); +export const BurnRateSnapshotSchema = z.object({ + recordedAt: z.string().datetime({ offset: true }), + primaryProjectedPlusAccounts: z.number().nullable(), + secondaryProjectedPlusAccounts: z.number().nullable(), + primaryUsedPlusAccounts: z.number().nullable(), + secondaryUsedPlusAccounts: z.number().nullable(), + primaryWindowMinutes: z.number().nullable(), + secondaryWindowMinutes: z.number().nullable(), + primaryAccountCount: z.number().int().nonnegative(), + secondaryAccountCount: z.number().int().nonnegative(), + primaryMaxPlusEquivalentAccounts: z.number(), + secondaryMaxPlusEquivalentAccounts: z.number(), +}); + export const DashboardOverviewSchema = z.object({ lastSyncAt: z.string().datetime({ offset: true }).nullable(), accounts: z.array(AccountSummarySchema), @@ -79,6 +93,7 @@ export const DashboardOverviewSchema = z.object({ additionalQuotas: z.array(AccountAdditionalQuotaSchema).default([]), depletionPrimary: DepletionSchema.nullable().optional(), depletionSecondary: DepletionSchema.nullable().optional(), + burnRate: BurnRateSnapshotSchema.nullable().optional(), }); export const RequestLogSchema = z.object({ diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index 705fc9ac..0b6d54fc 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -145,6 +145,7 @@ describe("buildDashboardView", () => { const overview = createDashboardOverview({ depletionPrimary: null, depletionSecondary: null, + burnRate: null, }); const view = buildDashboardView(overview, createDefaultRequestLogs()); @@ -183,6 +184,7 @@ describe("buildDashboardView", () => { ], depletionPrimary: null, depletionSecondary: null, + burnRate: null, }); const view = buildDashboardView(overview, createDefaultRequestLogs()); @@ -221,6 +223,7 @@ describe("buildDashboardView", () => { ], depletionPrimary: null, depletionSecondary: null, + burnRate: null, }); const view = buildDashboardView(overview, createDefaultRequestLogs()); @@ -240,6 +243,7 @@ describe("buildDashboardView", () => { projectedExhaustionAt: null, secondsUntilExhaustion: null, }, + burnRate: null, }); const view = buildDashboardView(overview, createDefaultRequestLogs()); @@ -249,6 +253,65 @@ describe("buildDashboardView", () => { expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); }); + it("treats unknown plans as plus-equivalent in burnrate fallbacks", () => { + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-enterprise", + email: "enterprise@example.com", + planType: "enterprise", + usage: { + primaryRemainingPercent: 10, + secondaryRemainingPercent: 10, + }, + }), + createAccountSummary({ + accountId: "acc-plus", + email: "plus@example.com", + planType: "plus", + usage: { + primaryRemainingPercent: 80, + secondaryRemainingPercent: 20, + }, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + burnRate: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("1.1 / 1.7"); + expect(burn.meta).toBe("Primary 1.1 acc/5h · Secondary 1.7 acc/7d"); + }); + + + it("uses backend burnrate snapshot when available", () => { + const overview = createDashboardOverview({ + burnRate: { + recordedAt: new Date().toISOString(), + primaryProjectedPlusAccounts: 0.3, + secondaryProjectedPlusAccounts: 0.9, + primaryUsedPlusAccounts: 0.3, + secondaryUsedPlusAccounts: 0.9, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10_080, + primaryAccountCount: 2, + secondaryAccountCount: 2, + primaryMaxPlusEquivalentAccounts: 2, + secondaryMaxPlusEquivalentAccounts: 2, + }, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.3 / 0.9"); + expect(burn.meta).toBe("Primary 0.3 acc/5h · Secondary 0.9 acc/7d"); + }); + it("caps burn-equivalent to available account count per window", () => { const overview = createDashboardOverview({ depletionSecondary: { @@ -259,6 +322,7 @@ describe("buildDashboardView", () => { projectedExhaustionAt: null, secondsUntilExhaustion: null, }, + burnRate: null, }); const view = buildDashboardView(overview, createDefaultRequestLogs()); diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index ba4c315f..35896cb0 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -25,6 +25,20 @@ const PLUS_DEFAULT_CAPACITY = { secondary: 7560, } as const; +const PRIMARY_PLUS_EQUIVALENT_BY_PLAN: Record = { + plus: 1, + business: 1, + team: 1, + pro: 1500 / PLUS_DEFAULT_CAPACITY.primary, +}; + +const SECONDARY_PLUS_EQUIVALENT_BY_PLAN: Record = { + plus: 1, + business: 1, + team: 1, + pro: 50400 / PLUS_DEFAULT_CAPACITY.secondary, +}; + export type RemainingItem = { accountId: string; label: string; @@ -154,6 +168,20 @@ function clampPercent(value: number): number { return Math.min(100, Math.max(0, value)); } +function plusEquivalentWeight(planType: string | null | undefined, windowKey: "primary" | "secondary"): number { + if (!planType) { + return 1; + } + const normalized = planType.trim().toLowerCase(); + if (!normalized) { + return 1; + } + if (windowKey === "primary") { + return PRIMARY_PLUS_EQUIVALENT_BY_PLAN[normalized] ?? 1; + } + return SECONDARY_PLUS_EQUIVALENT_BY_PLAN[normalized] ?? 1; +} + function windowUsedAccountEquivalents( overview: DashboardOverview, windowKey: "primary" | "secondary", @@ -162,6 +190,7 @@ function windowUsedAccountEquivalents( let includedAccounts = 0; for (const account of overview.accounts) { + const weight = plusEquivalentWeight(account.planType, windowKey); const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; const remainingPercent = windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; @@ -170,9 +199,9 @@ function windowUsedAccountEquivalents( continue; } - let accountEquivalent = (100 - clampPercent(remainingPercent)) / 100; + let accountEquivalent = ((100 - clampPercent(remainingPercent)) / 100) * weight; if (windowKey === "secondary" && account.status === "quota_exceeded") { - accountEquivalent = Math.max(accountEquivalent, 1); + accountEquivalent = Math.max(accountEquivalent, weight); } usedEquivalent += accountEquivalent; @@ -191,6 +220,7 @@ function windowProjectedAccountEquivalents( const nowMs = Date.now(); for (const account of overview.accounts) { + const weight = plusEquivalentWeight(account.planType, windowKey); const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; const remainingPercent = windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; @@ -200,7 +230,7 @@ function windowProjectedAccountEquivalents( continue; } - const usedEquivalent = (100 - clampPercent(remainingPercent)) / 100; + const usedEquivalent = ((100 - clampPercent(remainingPercent)) / 100) * weight; let projected = usedEquivalent; if (resetAt) { @@ -216,7 +246,7 @@ function windowProjectedAccountEquivalents( } if (windowKey === "secondary" && account.status === "quota_exceeded") { - projected = Math.max(projected, 1); + projected = Math.max(projected, weight); } projectedEquivalent += projected; @@ -230,9 +260,10 @@ function windowIncludedAccountCount( overview: DashboardOverview, windowKey: "primary" | "secondary", ): number { - let includedAccounts = 0; + let includedEquivalent = 0; for (const account of overview.accounts) { + const weight = plusEquivalentWeight(account.planType, windowKey); const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; const remainingPercent = windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; @@ -241,10 +272,10 @@ function windowIncludedAccountCount( continue; } - includedAccounts += 1; + includedEquivalent += weight; } - return includedAccounts; + return includedEquivalent; } function clampBurnEquivalent(value: number | null, maxEquivalent: number): number | null { @@ -346,12 +377,21 @@ export function buildDashboardView( const metrics = overview.summary.metrics; const cost = overview.summary.cost.totalUsd7d; const secondaryLabel = formatWindowLabel("secondary", secondaryWindow?.windowMinutes ?? null); - const primaryBurnLabel = formatWindowLabel("primary", overview.summary.primaryWindow.windowMinutes ?? null); - const secondaryBurnLabel = formatWindowLabel("secondary", overview.summary.secondaryWindow?.windowMinutes ?? null); + const burnRate = overview.burnRate; + const primaryBurnLabel = formatWindowLabel( + "primary", + burnRate?.primaryWindowMinutes ?? overview.summary.primaryWindow.windowMinutes ?? null, + ); + const secondaryBurnLabel = formatWindowLabel( + "secondary", + burnRate?.secondaryWindowMinutes ?? overview.summary.secondaryWindow?.windowMinutes ?? null, + ); const trends = overview.trends; - const primaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "primary"); - const secondaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "secondary"); + const fallbackPrimaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "primary"); + const fallbackSecondaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "secondary"); + const primaryBurnEquivalent = burnRate?.primaryProjectedPlusAccounts ?? fallbackPrimaryBurnEquivalent; + const secondaryBurnEquivalent = burnRate?.secondaryProjectedPlusAccounts ?? fallbackSecondaryBurnEquivalent; const combinedBurnEquivalent = (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) > 0 ? (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) diff --git a/frontend/src/test/mocks/factories.ts b/frontend/src/test/mocks/factories.ts index baad3fd2..67811cdf 100644 --- a/frontend/src/test/mocks/factories.ts +++ b/frontend/src/test/mocks/factories.ts @@ -181,6 +181,19 @@ export function createDashboardOverview(overrides: Partial = projectedExhaustionAt: null, secondsUntilExhaustion: null, }, + burnRate: { + recordedAt: offsetIso(-5), + primaryProjectedPlusAccounts: 0.7, + secondaryProjectedPlusAccounts: 1.2, + primaryUsedPlusAccounts: 0.7, + secondaryUsedPlusAccounts: 1.2, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10_080, + primaryAccountCount: 2, + secondaryAccountCount: 2, + primaryMaxPlusEquivalentAccounts: 2, + secondaryMaxPlusEquivalentAccounts: 2, + }, ...overrides, }; return DashboardOverviewSchema.parse(response); diff --git a/tests/unit/test_usage_burnrate.py b/tests/unit/test_usage_burnrate.py new file mode 100644 index 00000000..fd7f46ce --- /dev/null +++ b/tests/unit/test_usage_burnrate.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from app.core.crypto import TokenEncryptor +from app.core.utils.time import naive_utc_to_epoch, utcnow +from app.db.models import Account, AccountStatus, UsageHistory +from app.modules.usage.burnrate import compute_burn_rate_snapshot + +pytestmark = pytest.mark.unit + + +def _make_account(account_id: str, plan_type: str, status: AccountStatus = AccountStatus.ACTIVE) -> Account: + encryptor = TokenEncryptor() + return Account( + id=account_id, + email=f"{account_id}@example.com", + plan_type=plan_type, + access_token_encrypted=encryptor.encrypt("access"), + refresh_token_encrypted=encryptor.encrypt("refresh"), + id_token_encrypted=encryptor.encrypt("id"), + last_refresh=utcnow(), + status=status, + deactivation_reason=None, + ) + + +def _usage(account_id: str, used_percent: float, *, window_minutes: int, reset_at: int, window: str) -> UsageHistory: + return UsageHistory( + account_id=account_id, + used_percent=used_percent, + window=window, + window_minutes=window_minutes, + reset_at=reset_at, + recorded_at=utcnow(), + ) + + +def test_burnrate_unknown_plan_defaults_to_plus_equivalent_weight() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now)) + + account = _make_account("acc-enterprise", "enterprise") + primary = _usage(account.id, 50.0, window_minutes=300, reset_at=reset_epoch, window="primary") + secondary = _usage(account.id, 20.0, window_minutes=10080, reset_at=reset_epoch, window="secondary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={account.id: primary}, + latest_secondary_usage={account.id: secondary}, + now=now, + ) + + assert snapshot.primary.projected_plus_accounts == pytest.approx(0.5) + assert snapshot.secondary.projected_plus_accounts == pytest.approx(0.2) + assert snapshot.primary.max_plus_equivalent_accounts == pytest.approx(1.0) + assert snapshot.secondary.max_plus_equivalent_accounts == pytest.approx(1.0) + + +def test_burnrate_known_pro_plan_uses_plus_capacity_ratio() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now)) + + account = _make_account("acc-pro", "pro") + primary = _usage(account.id, 50.0, window_minutes=300, reset_at=reset_epoch, window="primary") + secondary = _usage(account.id, 50.0, window_minutes=10080, reset_at=reset_epoch, window="secondary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={account.id: primary}, + latest_secondary_usage={account.id: secondary}, + now=now, + ) + + assert snapshot.primary.projected_plus_accounts == pytest.approx(1500 / 225 * 0.5) + assert snapshot.secondary.projected_plus_accounts == pytest.approx(50400 / 7560 * 0.5) + + +def test_burnrate_secondary_quota_exceeded_is_counted_as_fully_burned() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now + timedelta(days=1))) + + account = _make_account("acc-plus", "plus", status=AccountStatus.QUOTA_EXCEEDED) + secondary = _usage(account.id, 0.0, window_minutes=10080, reset_at=reset_epoch, window="secondary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={}, + latest_secondary_usage={account.id: secondary}, + now=now, + ) + + assert snapshot.secondary.used_plus_accounts == pytest.approx(1.0) + assert snapshot.secondary.projected_plus_accounts == pytest.approx(1.0) + + +def test_burnrate_normalizes_weekly_only_primary_rows_into_secondary() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now)) + + account = _make_account("acc-weekly", "free") + weekly_primary = _usage(account.id, 20.0, window_minutes=10080, reset_at=reset_epoch, window="primary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={account.id: weekly_primary}, + latest_secondary_usage={}, + now=now, + ) + + assert snapshot.primary.projected_plus_accounts is None + assert snapshot.secondary.projected_plus_accounts == pytest.approx(0.2) From 2d4eb59633e1c7b641c8e74537c609679372b652 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 14:25:22 +0000 Subject: [PATCH 09/10] feat(request-logs): store and show burnrate 5h/7d columns --- ...0000_add_request_logs_burn_rate_columns.py | 51 +++++++++++++++++++ app/db/models.py | 2 + app/modules/proxy/service.py | 5 ++ app/modules/request_logs/mappers.py | 2 + app/modules/request_logs/repository.py | 24 ++++++++- app/modules/request_logs/schemas.py | 2 + .../components/recent-requests-table.test.tsx | 8 +++ .../components/recent-requests-table.tsx | 18 +++++-- .../src/features/dashboard/schemas.test.ts | 4 ++ frontend/src/features/dashboard/schemas.ts | 2 + frontend/src/test/mocks/factories.ts | 2 + tests/integration/test_request_logs_api.py | 6 +++ 12 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 app/db/alembic/versions/20260319_140000_add_request_logs_burn_rate_columns.py diff --git a/app/db/alembic/versions/20260319_140000_add_request_logs_burn_rate_columns.py b/app/db/alembic/versions/20260319_140000_add_request_logs_burn_rate_columns.py new file mode 100644 index 00000000..56d27792 --- /dev/null +++ b/app/db/alembic/versions/20260319_140000_add_request_logs_burn_rate_columns.py @@ -0,0 +1,51 @@ +"""add burn-rate columns to request_logs + +Revision ID: 20260319_140000_add_request_logs_burn_rate_columns +Revises: 20260319_130000_add_burn_rate_history +Create Date: 2026-03-19 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision = "20260319_140000_add_request_logs_burn_rate_columns" +down_revision = "20260319_130000_add_burn_rate_history" +branch_labels = None +depends_on = None + + +def _columns(connection: Connection, table_name: str) -> set[str]: + inspector = sa.inspect(connection) + if not inspector.has_table(table_name): + return set() + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + columns = _columns(bind, "request_logs") + if not columns: + return + + with op.batch_alter_table("request_logs") as batch_op: + if "burn_rate_5h_plus_accounts" not in columns: + batch_op.add_column(sa.Column("burn_rate_5h_plus_accounts", sa.Float(), nullable=True)) + if "burn_rate_7d_plus_accounts" not in columns: + batch_op.add_column(sa.Column("burn_rate_7d_plus_accounts", sa.Float(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + columns = _columns(bind, "request_logs") + if not columns: + return + + with op.batch_alter_table("request_logs") as batch_op: + if "burn_rate_7d_plus_accounts" in columns: + batch_op.drop_column("burn_rate_7d_plus_accounts") + if "burn_rate_5h_plus_accounts" in columns: + batch_op.drop_column("burn_rate_5h_plus_accounts") diff --git a/app/db/models.py b/app/db/models.py index 7c544f5d..14757a35 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -154,6 +154,8 @@ class RequestLog(Base): status: Mapped[str] = mapped_column(String, nullable=False) error_code: Mapped[str | None] = mapped_column(String, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + burn_rate_5h_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + burn_rate_7d_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) class StickySession(Base): diff --git a/app/modules/proxy/service.py b/app/modules/proxy/service.py index 1fad9f92..98a76ffd 100644 --- a/app/modules/proxy/service.py +++ b/app/modules/proxy/service.py @@ -2456,6 +2456,9 @@ async def _write_request_log( with anyio.CancelScope(shield=True): try: async with self._repo_factory() as repos: + burn_rate_5h_plus_accounts, burn_rate_7d_plus_accounts = ( + await repos.request_logs.latest_projected_burn_rates() + ) await repos.request_logs.add_log( account_id=account_id, api_key_id=api_key.id if api_key else None, @@ -2468,6 +2471,8 @@ async def _write_request_log( reasoning_effort=reasoning_effort, transport=transport, service_tier=service_tier, + burn_rate_5h_plus_accounts=burn_rate_5h_plus_accounts, + burn_rate_7d_plus_accounts=burn_rate_7d_plus_accounts, latency_ms=latency_ms, status=status, error_code=error_code, diff --git a/app/modules/request_logs/mappers.py b/app/modules/request_logs/mappers.py index 6fb83727..ab72e7d2 100644 --- a/app/modules/request_logs/mappers.py +++ b/app/modules/request_logs/mappers.py @@ -38,5 +38,7 @@ def to_request_log_entry(log: RequestLog, *, api_key_name: str | None = None) -> tokens=total_tokens_from_log(log), cached_input_tokens=cached_input_tokens_from_log(log), cost_usd=cost_from_log(log, precision=6), + burn_rate_5h_plus_accounts=log.burn_rate_5h_plus_accounts, + burn_rate_7d_plus_accounts=log.burn_rate_7d_plus_accounts, latency_ms=log.latency_ms, ) diff --git a/app/modules/request_logs/repository.py b/app/modules/request_logs/repository.py index a1b0efaa..f703b83a 100644 --- a/app/modules/request_logs/repository.py +++ b/app/modules/request_logs/repository.py @@ -11,7 +11,7 @@ from app.core.usage.types import BucketModelAggregate from app.core.utils.request_id import ensure_request_id from app.core.utils.time import utcnow -from app.db.models import Account, ApiKey, RequestLog +from app.db.models import Account, ApiKey, BurnRateHistory, RequestLog @dataclass(frozen=True, slots=True) @@ -92,6 +92,8 @@ async def add_log( reasoning_effort: str | None = None, service_tier: str | None = None, transport: str | None = None, + burn_rate_5h_plus_accounts: float | None = None, + burn_rate_7d_plus_accounts: float | None = None, api_key_id: str | None = None, ) -> RequestLog: resolved_request_id = ensure_request_id(request_id) @@ -111,6 +113,8 @@ async def add_log( status=status, error_code=error_code, error_message=error_message, + burn_rate_5h_plus_accounts=burn_rate_5h_plus_accounts, + burn_rate_7d_plus_accounts=burn_rate_7d_plus_accounts, requested_at=requested_at or utcnow(), ) self._session.add(log) @@ -124,6 +128,24 @@ async def add_log( await _safe_rollback(self._session) raise + async def latest_projected_burn_rates(self) -> tuple[float | None, float | None]: + stmt = ( + select( + BurnRateHistory.primary_projected_plus_accounts, + BurnRateHistory.secondary_projected_plus_accounts, + ) + .order_by(BurnRateHistory.recorded_at.desc(), BurnRateHistory.id.desc()) + .limit(1) + ) + try: + result = await self._session.execute(stmt) + except sa_exc.SQLAlchemyError: + return None, None + row = result.first() + if row is None: + return None, None + return row[0], row[1] + async def list_recent( self, limit: int = 50, diff --git a/app/modules/request_logs/schemas.py b/app/modules/request_logs/schemas.py index 31322e2f..90f24280 100644 --- a/app/modules/request_logs/schemas.py +++ b/app/modules/request_logs/schemas.py @@ -22,6 +22,8 @@ class RequestLogEntry(DashboardModel): cached_input_tokens: int | None = None reasoning_effort: str | None = None cost_usd: float | None = None + burn_rate_5h_plus_accounts: float | None = Field(default=None, serialization_alias="burnRate5hPlusAccounts") + burn_rate_7d_plus_accounts: float | None = Field(default=None, serialization_alias="burnRate7dPlusAccounts") latency_ms: int | None = None diff --git a/frontend/src/features/dashboard/components/recent-requests-table.test.tsx b/frontend/src/features/dashboard/components/recent-requests-table.test.tsx index d1597863..76bde59e 100644 --- a/frontend/src/features/dashboard/components/recent-requests-table.test.tsx +++ b/frontend/src/features/dashboard/components/recent-requests-table.test.tsx @@ -49,6 +49,8 @@ describe("RecentRequestsTable", () => { cachedInputTokens: 200, reasoningEffort: "high", costUsd: 0.01, + burnRate5hPlusAccounts: 0.3, + burnRate7dPlusAccounts: 1.8, latencyMs: 1000, }, ]} @@ -60,6 +62,10 @@ describe("RecentRequestsTable", () => { expect(screen.getByText("gpt-5.1 (high, priority)")).toBeInTheDocument(); expect(screen.getByText("WS")).toBeInTheDocument(); expect(screen.getByText("Rate limit")).toBeInTheDocument(); + expect(screen.getByText("Burn 5h")).toBeInTheDocument(); + expect(screen.getByText("Burn 7d")).toBeInTheDocument(); + expect(screen.getByText("0.3")).toBeInTheDocument(); + expect(screen.getByText("1.8")).toBeInTheDocument(); const viewButton = screen.getByRole("button", { name: "View" }); await user.click(viewButton); @@ -95,6 +101,8 @@ describe("RecentRequestsTable", () => { cachedInputTokens: null, reasoningEffort: null, costUsd: 0, + burnRate5hPlusAccounts: null, + burnRate7dPlusAccounts: null, latencyMs: 1, }, ]} diff --git a/frontend/src/features/dashboard/components/recent-requests-table.tsx b/frontend/src/features/dashboard/components/recent-requests-table.tsx index 7afe0733..74088348 100644 --- a/frontend/src/features/dashboard/components/recent-requests-table.tsx +++ b/frontend/src/features/dashboard/components/recent-requests-table.tsx @@ -27,7 +27,6 @@ import type { AccountSummary, RequestLog } from "@/features/dashboard/schemas"; import { REQUEST_STATUS_LABELS } from "@/utils/constants"; import { formatCompactNumber, - formatCurrency, formatModelLabel, formatTimeLong, } from "@/utils/formatters"; @@ -49,6 +48,13 @@ const TRANSPORT_CLASS_MAP: Record = { websocket: "bg-sky-500/15 text-sky-700 border-sky-500/20 hover:bg-sky-500/20 dark:text-sky-300", }; +function formatBurnRate(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) { + return "--"; + } + return value.toFixed(1); +} + export type RecentRequestsTableProps = { requests: RequestLog[]; accounts: AccountSummary[]; @@ -107,7 +113,7 @@ export function RecentRequestsTable({
- +
Time @@ -117,7 +123,8 @@ export function RecentRequestsTable({ Transport Status Tokens - Cost + Burn 5h + Burn 7d Error @@ -183,7 +190,10 @@ export function RecentRequestsTable({ - {formatCurrency(request.costUsd)} + {formatBurnRate(request.burnRate5hPlusAccounts)} + + + {formatBurnRate(request.burnRate7dPlusAccounts)}
diff --git a/frontend/src/features/dashboard/schemas.test.ts b/frontend/src/features/dashboard/schemas.test.ts index d2c7e8da..3b3dc13c 100644 --- a/frontend/src/features/dashboard/schemas.test.ts +++ b/frontend/src/features/dashboard/schemas.test.ts @@ -130,6 +130,8 @@ describe("RequestLogsResponseSchema", () => { cachedInputTokens: 0, reasoningEffort: null, costUsd: 0.001, + burnRate5hPlusAccounts: 0.4, + burnRate7dPlusAccounts: 1.6, latencyMs: 42, }, ], @@ -139,6 +141,8 @@ describe("RequestLogsResponseSchema", () => { expect(parsed.requests[0]?.apiKeyName).toBe("Key A"); expect(parsed.requests[0]?.transport).toBe("websocket"); + expect(parsed.requests[0]?.burnRate5hPlusAccounts).toBe(0.4); + expect(parsed.requests[0]?.burnRate7dPlusAccounts).toBe(1.6); }); }); diff --git a/frontend/src/features/dashboard/schemas.ts b/frontend/src/features/dashboard/schemas.ts index 3709497e..7c3726e7 100644 --- a/frontend/src/features/dashboard/schemas.ts +++ b/frontend/src/features/dashboard/schemas.ts @@ -111,6 +111,8 @@ export const RequestLogSchema = z.object({ cachedInputTokens: z.number().nullable(), reasoningEffort: z.string().nullable(), costUsd: z.number().nullable(), + burnRate5hPlusAccounts: z.number().nullable().optional().default(null), + burnRate7dPlusAccounts: z.number().nullable().optional().default(null), latencyMs: z.number().nullable(), }); diff --git a/frontend/src/test/mocks/factories.ts b/frontend/src/test/mocks/factories.ts index 67811cdf..114f762e 100644 --- a/frontend/src/test/mocks/factories.ts +++ b/frontend/src/test/mocks/factories.ts @@ -215,6 +215,8 @@ export function createRequestLogEntry(overrides: Partial = {}): cachedInputTokens: 320, reasoningEffort: null, costUsd: 0.0132, + burnRate5hPlusAccounts: 0.7, + burnRate7dPlusAccounts: 1.2, latencyMs: 920, ...overrides, }); diff --git a/tests/integration/test_request_logs_api.py b/tests/integration/test_request_logs_api.py index 26dda791..8fa67431 100644 --- a/tests/integration/test_request_logs_api.py +++ b/tests/integration/test_request_logs_api.py @@ -71,6 +71,8 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): requested_at=now, api_key_id="key_logs_1", transport="websocket", + burn_rate_5h_plus_accounts=0.5, + burn_rate_7d_plus_accounts=1.7, ) response = await async_client.get("/api/request-logs?limit=2") @@ -87,6 +89,8 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): assert latest["errorCode"] == "rate_limit_exceeded" assert latest["errorMessage"] == "Rate limit reached" assert latest["transport"] == "websocket" + assert latest["burnRate5hPlusAccounts"] == pytest.approx(0.5) + assert latest["burnRate7dPlusAccounts"] == pytest.approx(1.7) older = payload[1] assert older["status"] == "ok" @@ -94,3 +98,5 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): assert older["tokens"] == 300 assert older["cachedInputTokens"] is None assert older["transport"] == "http" + assert older["burnRate5hPlusAccounts"] is None + assert older["burnRate7dPlusAccounts"] is None From 36160fffb41abc276814ff80b19686a99114e5e4 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 18:33:12 +0000 Subject: [PATCH 10/10] Fix quota_exceeded handling when credits are available --- app/core/usage/quota.py | 42 +++++++++++++++++++++++++----- app/modules/proxy/load_balancer.py | 7 +++++ tests/unit/test_load_balancer.py | 38 +++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/app/core/usage/quota.py b/app/core/usage/quota.py index dc76079e..da0e6177 100644 --- a/app/core/usage/quota.py +++ b/app/core/usage/quota.py @@ -15,6 +15,9 @@ def apply_usage_quota( runtime_reset: float | None, secondary_used: float | None, secondary_reset: int | None, + credits_has: bool | None = None, + credits_unlimited: bool | None = None, + credits_balance: float | None = None, ) -> tuple[AccountStatus, float | None, float | None]: used_percent = primary_used reset_at = runtime_reset @@ -24,12 +27,21 @@ def apply_usage_quota( if secondary_used is not None: if secondary_used >= 100.0: - status = AccountStatus.QUOTA_EXCEEDED - used_percent = 100.0 - if secondary_reset is not None: - reset_at = secondary_reset - return status, used_percent, reset_at - if status == AccountStatus.QUOTA_EXCEEDED: + if _has_usable_credits( + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, + ): + if status == AccountStatus.QUOTA_EXCEEDED: + status = AccountStatus.ACTIVE + reset_at = None + else: + status = AccountStatus.QUOTA_EXCEEDED + used_percent = 100.0 + if secondary_reset is not None: + reset_at = secondary_reset + return status, used_percent, reset_at + elif status == AccountStatus.QUOTA_EXCEEDED: if runtime_reset and runtime_reset > time.time(): reset_at = runtime_reset else: @@ -62,3 +74,21 @@ def _fallback_primary_reset(primary_window_minutes: int | None) -> float | None: if not window_minutes: return None return time.time() + float(window_minutes) * 60.0 + + +def _has_usable_credits( + *, + credits_has: bool | None, + credits_unlimited: bool | None, + credits_balance: float | None, +) -> bool: + if credits_unlimited is True: + return True + if credits_has is True: + return True + if credits_balance is None: + return False + try: + return float(credits_balance) > 0.0 + except (TypeError, ValueError): + return False diff --git a/app/modules/proxy/load_balancer.py b/app/modules/proxy/load_balancer.py index 32a4c569..04cc799c 100644 --- a/app/modules/proxy/load_balancer.py +++ b/app/modules/proxy/load_balancer.py @@ -514,6 +514,10 @@ def _state_from_account( secondary_used = effective_secondary_entry.used_percent if effective_secondary_entry else None secondary_reset = effective_secondary_entry.reset_at if effective_secondary_entry else None + credits_source = primary_entry or effective_secondary_entry + credits_has = credits_source.credits_has if credits_source else None + credits_unlimited = credits_source.credits_unlimited if credits_source else None + credits_balance = credits_source.credits_balance if credits_source else None # Use account.reset_at from DB as the authoritative source for runtime reset # and to survive process restarts. @@ -528,6 +532,9 @@ def _state_from_account( runtime_reset=effective_runtime_reset, secondary_used=secondary_used, secondary_reset=secondary_reset, + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, ) return AccountState( diff --git a/tests/unit/test_load_balancer.py b/tests/unit/test_load_balancer.py index 7c5ac7c1..7d2d1d8a 100644 --- a/tests/unit/test_load_balancer.py +++ b/tests/unit/test_load_balancer.py @@ -255,6 +255,44 @@ def test_apply_usage_quota_sets_fallback_reset_for_primary_window(monkeypatch): assert reset_at == pytest.approx(now + 60.0) +def test_apply_usage_quota_secondary_exhausted_without_credits_sets_quota_exceeded(): + secondary_reset = 1_700_000_000 + status, used_percent, reset_at = apply_usage_quota( + status=AccountStatus.ACTIVE, + primary_used=40.0, + primary_reset=None, + primary_window_minutes=None, + runtime_reset=None, + secondary_used=100.0, + secondary_reset=secondary_reset, + credits_has=False, + credits_unlimited=False, + credits_balance=0.0, + ) + assert status == AccountStatus.QUOTA_EXCEEDED + assert used_percent == 100.0 + assert reset_at == secondary_reset + + +def test_apply_usage_quota_secondary_exhausted_with_credits_reactivates_account(): + future_reset = 1_700_000_000.0 + status, used_percent, reset_at = apply_usage_quota( + status=AccountStatus.QUOTA_EXCEEDED, + primary_used=40.0, + primary_reset=None, + primary_window_minutes=None, + runtime_reset=future_reset, + secondary_used=100.0, + secondary_reset=int(future_reset), + credits_has=False, + credits_unlimited=False, + credits_balance=25.0, + ) + assert status == AccountStatus.ACTIVE + assert used_percent == 40.0 + assert reset_at is None + + def test_handle_quota_exceeded_sets_used_percent(): state = AccountState("a", AccountStatus.ACTIVE, used_percent=5.0) handle_quota_exceeded(state, {})