diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e8b914b0..c99beeeffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### ✨ New Features + +- **usage (quota percentages + provider USD drilldown):** `@@om-usage` and the HTTP usage endpoint now report personal API-key quotas as **remaining percentages** (USD amounts stay out of the command output), provider quota remaining is scaled by the configured quota cutoff so the protected reserve reads as 0% left, and the quota dashboard regains a **provider USD cost drilldown** (`/api/usage/provider-window-costs` + `ProviderUsdCostModal`, management-auth gated). Also honors **observed provider quota resets**: a same-`resetAt` reset (usage dropping back to the reset floor) is detected and preferred over stale recorded weekly events for provider USD windows and API-key USD quotas. New `src/lib/usage/providerWindowCosts.ts`. Regression guards: `tests/unit/provider-window-costs.test.ts`, `tests/unit/internal-usage-command.test.ts`, `tests/unit/api-key-usage-limits.test.ts`, `tests/unit/lib/quota-reset-events.test.ts`. Extracted from [#5863](https://github.com/diegosouzapw/OmniRoute/pull/5863) by [@Witroch4](https://github.com/Witroch4). + ### 🔧 Bug Fixes - **fix(kiro):** bound the Claude model-id dash→dot normalization to a 1–2 digit minor so date-suffixed ids (e.g. claude-opus-4-20250514) are no longer corrupted. (thanks @voravitl) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderUsdCostModal.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderUsdCostModal.tsx new file mode 100644 index 0000000000..b5c911a0f0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderUsdCostModal.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +interface ProviderWindowCostRow { + apiKeyKey: string; + apiKeyId: string | null; + apiKeyName: string; + requests: number; + totalTokens: number; + costUsd: number; + limitUsd: number | null; + limitPeriod: string | null; + limitUsedPercent: number | null; + budgetResetAt: string | null; + lastUsed: string | null; +} + +interface ProviderWindowCostPayload { + provider: string; + connectionId: string | null; + windowStartAt: string; + windowResetAt: string | null; + windowSource: "provider_weekly_reset" | "fallback_rolling_7d"; + windowStartSource: + | "recorded_reset_event" + | "observed_snapshot_reset" + | "inferred_from_reset_at" + | "fallback_rolling_7d"; + quotaName: string | null; + quotaUsedPercent: number | null; + quotaRemainingPercent: number | null; + totalCostUsd: number; + estimatedFullQuotaUsd: number | null; + rows: ProviderWindowCostRow[]; +} + +interface Props { + isOpen: boolean; + onClose: () => void; + connection: any; + providerLabel: string; + accountLabel: string; +} + +function formatUsd(value: number | null | undefined): string { + const numeric = Number(value || 0); + const abs = Math.abs(numeric); + const digits = abs > 0 && abs < 0.01 ? 6 : abs < 1 ? 4 : 2; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(numeric); +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) return "unknown"; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return "unknown"; + return date.toLocaleString([], { + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatPercent(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "n/a"; + return `${value.toFixed(value % 1 === 0 ? 0 : 1)}%`; +} + +export default function ProviderUsdCostModal({ + isOpen, + onClose, + connection, + providerLabel, + accountLabel, +}: Props) { + const [payload, setPayload] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [simulatedPercent, setSimulatedPercent] = useState(25); + + useEffect(() => { + if (!isOpen || !connection?.provider) return; + let alive = true; + + async function load() { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ provider: String(connection.provider) }); + if (connection.id) params.set("connectionId", String(connection.id)); + const response = await fetch(`/api/usage/provider-window-costs?${params.toString()}`); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${response.status}`); + } + const data = (await response.json()) as ProviderWindowCostPayload; + if (alive) setPayload(data); + } catch (loadError) { + if (alive) { + setError(loadError instanceof Error ? loadError.message : "Failed to load USD costs"); + setPayload(null); + } + } finally { + if (alive) setLoading(false); + } + } + + void load(); + return () => { + alive = false; + }; + }, [isOpen, connection?.id, connection?.provider]); + + const maxCost = useMemo( + () => Math.max(...(payload?.rows || []).map((row) => row.costUsd), 0), + [payload] + ); + const simulatedUsd = + payload?.estimatedFullQuotaUsd !== null && payload?.estimatedFullQuotaUsd !== undefined + ? (payload.estimatedFullQuotaUsd * simulatedPercent) / 100 + : null; + + if (!isOpen) return null; + + return ( +
+
event.stopPropagation()} + > +
+
+

USD Cost

+

+ {providerLabel} · {accountLabel || connection?.id} +

+
+ +
+ +
+ {loading ? ( +
+ + progress_activity + + Loading USD costs +
+ ) : error ? ( +
+ error + {error} +
+ ) : payload ? ( +
+
+
+
Used
+
+ {formatUsd(payload.totalCostUsd)} +
+
+
+
+ Quota used +
+
+ {formatPercent(payload.quotaUsedPercent)} +
+
+
+
+ Est. 100% +
+
+ {payload.estimatedFullQuotaUsd === null + ? "n/a" + : formatUsd(payload.estimatedFullQuotaUsd)} +
+
+
+
Rows
+
+ {payload.rows.length} +
+
+
+ +
+
+ + Window: {formatDateTime(payload.windowStartAt)} →{" "} + {formatDateTime(payload.windowResetAt)} + + + {payload.windowStartSource === "recorded_reset_event" + ? `From recorded ${payload.quotaName || "weekly quota"} reset` + : payload.windowStartSource === "observed_snapshot_reset" + ? `From observed ${payload.quotaName || "weekly quota"} reset` + : payload.windowSource === "provider_weekly_reset" + ? `From ${payload.quotaName || "weekly quota"} reset` + : "Fallback rolling 7d"} + +
+
+
+ Quota estimator + + {simulatedPercent}% ={" "} + {simulatedUsd === null ? "n/a" : formatUsd(simulatedUsd)} + +
+ setSimulatedPercent(Number(event.target.value))} + className="w-full accent-[var(--color-primary,#E54D5E)]" + disabled={payload.estimatedFullQuotaUsd === null} + /> +
+
+ + {payload.rows.length === 0 ? ( +
+ No API key usage in this provider window. +
+ ) : ( +
+ {payload.rows.map((row) => { + const barPercent = maxCost > 0 ? Math.max(4, (row.costUsd / maxCost) * 100) : 0; + return ( +
+
+
+
+ {row.apiKeyName} +
+
+ {row.requests.toLocaleString()} requests ·{" "} + {row.totalTokens.toLocaleString()} tokens +
+
+
+
+ {formatUsd(row.costUsd)} +
+ {row.limitUsd ? ( +
+ {formatPercent(row.limitUsedPercent)} of {formatUsd(row.limitUsd)} + {row.limitPeriod ? ` ${row.limitPeriod}` : ""} +
+ ) : ( +
No USD limit
+ )} +
+
+
+
+
+
+ ); + })} +
+ )} +
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index 9c42bb0647..852cfda219 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -1,10 +1,12 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import Card from "@/shared/components/Card"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; import { normalizePlanTier, resolvePlanValue, worstStatus, type CardStatus } from "./utils"; import QuotaCardHeader from "./parts/QuotaCardHeader"; import QuotaCardExpanded from "./parts/QuotaCardExpanded"; +import ProviderUsdCostModal from "./ProviderUsdCostModal"; const STATUS_BORDER: Record = { critical: "#ef4444", @@ -50,6 +52,7 @@ export default function QuotaCard({ togglingActive, }: QuotaCardProps) { const isActive = connection.isActive ?? true; + const [costModalOpen, setCostModalOpen] = useState(false); const quotas = quota?.quotas ?? EMPTY_QUOTAS; const cardStatus = useMemo(() => worstStatus(quotas), [quotas]); const tierMeta = useMemo( @@ -63,6 +66,17 @@ export default function QuotaCard({ () => resolvePlanValue(quota?.plan ?? null, connection.providerSpecificData ?? null), [quota?.plan, connection.providerSpecificData] ); + const accountLabel = useMemo( + () => + pickDisplayValue( + [connection.name, connection.displayName, connection.email], + emailsVisible, + connection.provider + ) || + connection.id || + connection.provider, + [connection, emailsVisible] + ); const overrides = (connection.quotaWindowThresholds as Record | null) || null; const hasOverrides = !!overrides && Object.keys(overrides).length > 0; @@ -96,9 +110,17 @@ export default function QuotaCard({ hasStaleData={hasStaleData} onRefresh={onRefresh} onOpenCutoff={onOpenCutoff} + onOpenCost={() => setCostModalOpen(true)} canEditCutoff={canEditCutoff} hasCutoffOverrides={hasOverrides} /> + setCostModalOpen(false)} + connection={connection} + providerLabel={providerLabel} + accountLabel={accountLabel} + /> ); } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 47641ec896..58b509f078 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -30,6 +30,7 @@ interface Props { hasStaleData: boolean; onRefresh: () => void; onOpenCutoff: () => void; + onOpenCost: () => void; canEditCutoff: boolean; hasCutoffOverrides: boolean; } @@ -115,6 +116,7 @@ export default function QuotaCardExpanded({ hasStaleData, onRefresh, onOpenCutoff, + onOpenCost, canEditCutoff, hasCutoffOverrides, }: Props) { @@ -189,6 +191,17 @@ export default function QuotaCardExpanded({ tune {tr("editCutoffs", "Edit cutoffs")} +