feat(usage): add subscription quota windows per provider on /usage page
reads local claude and codex auth files server-side, calls provider quota apis (anthropic oauth usage, chatgpt wham/usage), and surfaces live usedPercent per window in ProviderQuotaCard with threshold fill colors
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
|
||||
import type { CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow, ProviderQuotaResult } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CostByProject {
|
||||
@@ -28,4 +28,6 @@ export const costsApi = {
|
||||
api.get<CostByProviderModel[]>(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
|
||||
windowSpend: (companyId: string) =>
|
||||
api.get<CostWindowSpendRow[]>(`/companies/${companyId}/costs/window-spend`),
|
||||
quotaWindows: (companyId: string) =>
|
||||
api.get<ProviderQuotaResult[]>(`/companies/${companyId}/costs/quota-windows`),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
|
||||
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
||||
@@ -15,6 +15,8 @@ interface ProviderQuotaCardProps {
|
||||
/** rolling window rows for this provider: 5h, 24h, 7d */
|
||||
windowRows: CostWindowSpendRow[];
|
||||
showDeficitNotch: boolean;
|
||||
/** live subscription quota windows from the provider's own api */
|
||||
quotaWindows?: QuotaWindow[];
|
||||
}
|
||||
|
||||
export function ProviderQuotaCard({
|
||||
@@ -25,6 +27,7 @@ export function ProviderQuotaCard({
|
||||
weekSpendCents,
|
||||
windowRows,
|
||||
showDeficitNotch,
|
||||
quotaWindows = [],
|
||||
}: ProviderQuotaCardProps) {
|
||||
const totalInputTokens = rows.reduce((s, r) => s + r.inputTokens, 0);
|
||||
const totalOutputTokens = rows.reduce((s, r) => s + r.outputTokens, 0);
|
||||
@@ -150,6 +153,55 @@ export function ProviderQuotaCard({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* subscription quota windows from provider api — shown when data is available */}
|
||||
{quotaWindows.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Subscription quota
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{quotaWindows.map((qw) => {
|
||||
const pct = qw.usedPercent ?? 0;
|
||||
const fillColor =
|
||||
pct >= 90
|
||||
? "bg-red-400"
|
||||
: pct >= 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400";
|
||||
return (
|
||||
<div key={qw.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||
<span className="flex-1" />
|
||||
{qw.valueLabel != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
|
||||
) : qw.usedPercent != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
||||
) : null}
|
||||
</div>
|
||||
{qw.usedPercent != null && (
|
||||
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{qw.resetsAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription usage — shown when any subscription-billed runs exist */}
|
||||
{totalSubRuns > 0 && (
|
||||
<>
|
||||
|
||||
@@ -415,6 +415,7 @@ function invalidateActivityQueries(
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageQuotaWindows(companyId) });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ export const queryKeys = {
|
||||
["usage-by-provider", companyId, from, to] as const,
|
||||
usageWindowSpend: (companyId: string) =>
|
||||
["usage-window-spend", companyId] as const,
|
||||
usageQuotaWindows: (companyId: string) =>
|
||||
["usage-quota-windows", companyId] as const,
|
||||
heartbeats: (companyId: string, agentId?: string) =>
|
||||
["heartbeats", companyId, agentId] as const,
|
||||
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
|
||||
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
||||
import { costsApi } from "../api/costs";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
@@ -150,6 +150,15 @@ export function Usage() {
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: quotaData } = useQuery({
|
||||
queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!),
|
||||
queryFn: () => costsApi.quotaWindows(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
// quota windows change infrequently; refresh every 5 minutes
|
||||
refetchInterval: 300_000,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// rows grouped by provider
|
||||
const byProvider = useMemo(() => {
|
||||
const map = new Map<string, CostByProviderModel[]>();
|
||||
@@ -181,6 +190,17 @@ export function Usage() {
|
||||
return map;
|
||||
}, [windowData]);
|
||||
|
||||
// quota windows from the provider's own api, keyed by provider
|
||||
const quotaWindowsByProvider = useMemo(() => {
|
||||
const map = new Map<string, QuotaWindow[]>();
|
||||
for (const result of quotaData ?? []) {
|
||||
if (result.ok && result.windows.length > 0) {
|
||||
map.set(result.provider, result.windows);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [quotaData]);
|
||||
|
||||
// compute deficit notch per provider: only meaningful for mtd — projects spend to month end
|
||||
// and flags when that projection exceeds the provider's pro-rata budget share.
|
||||
function providerDeficitNotch(providerKey: string): boolean {
|
||||
@@ -292,6 +312,7 @@ export function Usage() {
|
||||
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||
showDeficitNotch={providerDeficitNotch(p)}
|
||||
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -308,6 +329,7 @@ export function Usage() {
|
||||
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||
showDeficitNotch={providerDeficitNotch(p)}
|
||||
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user