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:
@@ -189,6 +189,8 @@ export type {
|
|||||||
PluginJobRecord,
|
PluginJobRecord,
|
||||||
PluginJobRunRecord,
|
PluginJobRunRecord,
|
||||||
PluginWebhookDeliveryRecord,
|
PluginWebhookDeliveryRecord,
|
||||||
|
QuotaWindow,
|
||||||
|
ProviderQuotaResult,
|
||||||
} from "./types/index.js";
|
} from "./types/index.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export type {
|
|||||||
JoinRequest,
|
JoinRequest,
|
||||||
InstanceUserRoleGrant,
|
InstanceUserRoleGrant,
|
||||||
} from "./access.js";
|
} from "./access.js";
|
||||||
|
export type { QuotaWindow, ProviderQuotaResult } from "./quota.js";
|
||||||
export type {
|
export type {
|
||||||
CompanyPortabilityInclude,
|
CompanyPortabilityInclude,
|
||||||
CompanyPortabilitySecretRequirement,
|
CompanyPortabilitySecretRequirement,
|
||||||
|
|||||||
22
packages/shared/src/types/quota.ts
Normal file
22
packages/shared/src/types/quota.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** a single rate-limit or usage window returned by a provider quota API */
|
||||||
|
export interface QuotaWindow {
|
||||||
|
/** human label, e.g. "5h", "7d", "Sonnet 7d", "Credits" */
|
||||||
|
label: string;
|
||||||
|
/** percent of the window already consumed (0-100), null when not reported */
|
||||||
|
usedPercent: number | null;
|
||||||
|
/** iso timestamp when this window resets, null when not reported */
|
||||||
|
resetsAt: string | null;
|
||||||
|
/** free-form value label for credit-style windows, e.g. "$4.20 remaining" */
|
||||||
|
valueLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** result for one provider from the quota-windows endpoint */
|
||||||
|
export interface ProviderQuotaResult {
|
||||||
|
/** provider slug, e.g. "anthropic", "openai" */
|
||||||
|
provider: string;
|
||||||
|
/** true when the fetch succeeded and windows is populated */
|
||||||
|
ok: boolean;
|
||||||
|
/** error message when ok is false */
|
||||||
|
error?: string;
|
||||||
|
windows: QuotaWindow[];
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared";
|
|||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { costService, companyService, agentService, logActivity } from "../services/index.js";
|
import { costService, companyService, agentService, logActivity } from "../services/index.js";
|
||||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||||
|
|
||||||
export function costRoutes(db: Db) {
|
export function costRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -77,6 +78,12 @@ export function costRoutes(db: Db) {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/costs/quota-windows", async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
|
const results = await fetchAllQuotaWindows();
|
||||||
|
res.json(results);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|||||||
242
server/src/services/quota-windows.ts
Normal file
242
server/src/services/quota-windows.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
// ---------- claude ----------
|
||||||
|
|
||||||
|
function claudeConfigDir(): string {
|
||||||
|
const fromEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||||
|
return path.join(os.homedir(), ".claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readClaudeToken(): Promise<string | null> {
|
||||||
|
const credPath = path.join(claudeConfigDir(), "credentials.json");
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.readFile(credPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed !== "object" || parsed === null) return null;
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
const oauth = obj["claudeAiOauth"];
|
||||||
|
if (typeof oauth !== "object" || oauth === null) return null;
|
||||||
|
const token = (oauth as Record<string, unknown>)["accessToken"];
|
||||||
|
return typeof token === "string" && token.length > 0 ? token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnthropicUsageWindow {
|
||||||
|
utilization?: number | null;
|
||||||
|
resets_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnthropicUsageResponse {
|
||||||
|
five_hour?: AnthropicUsageWindow | null;
|
||||||
|
seven_day?: AnthropicUsageWindow | null;
|
||||||
|
seven_day_sonnet?: AnthropicUsageWindow | null;
|
||||||
|
seven_day_opus?: AnthropicUsageWindow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPercent(utilization: number | null | undefined): number | null {
|
||||||
|
if (utilization == null) return null;
|
||||||
|
// utilization is 0-1 fraction
|
||||||
|
return Math.round(utilization * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||||
|
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"anthropic-beta": "oauth-2025-04-20",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`anthropic usage api returned ${resp.status}`);
|
||||||
|
const body = (await resp.json()) as AnthropicUsageResponse;
|
||||||
|
const windows: QuotaWindow[] = [];
|
||||||
|
|
||||||
|
if (body.five_hour != null) {
|
||||||
|
windows.push({
|
||||||
|
label: "5h",
|
||||||
|
usedPercent: toPercent(body.five_hour.utilization),
|
||||||
|
resetsAt: body.five_hour.resets_at ?? null,
|
||||||
|
valueLabel: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (body.seven_day != null) {
|
||||||
|
windows.push({
|
||||||
|
label: "7d",
|
||||||
|
usedPercent: toPercent(body.seven_day.utilization),
|
||||||
|
resetsAt: body.seven_day.resets_at ?? null,
|
||||||
|
valueLabel: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (body.seven_day_sonnet != null) {
|
||||||
|
windows.push({
|
||||||
|
label: "Sonnet 7d",
|
||||||
|
usedPercent: toPercent(body.seven_day_sonnet.utilization),
|
||||||
|
resetsAt: body.seven_day_sonnet.resets_at ?? null,
|
||||||
|
valueLabel: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (body.seven_day_opus != null) {
|
||||||
|
windows.push({
|
||||||
|
label: "Opus 7d",
|
||||||
|
usedPercent: toPercent(body.seven_day_opus.utilization),
|
||||||
|
resetsAt: body.seven_day_opus.resets_at ?? null,
|
||||||
|
valueLabel: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- codex / openai ----------
|
||||||
|
|
||||||
|
function codexHomeDir(): string {
|
||||||
|
const fromEnv = process.env.CODEX_HOME;
|
||||||
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||||
|
return path.join(os.homedir(), ".codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexAuthFile {
|
||||||
|
accessToken?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCodexToken(): Promise<{ token: string; accountId: string | null } | null> {
|
||||||
|
const authPath = path.join(codexHomeDir(), "auth.json");
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.readFile(authPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed !== "object" || parsed === null) return null;
|
||||||
|
const obj = parsed as CodexAuthFile;
|
||||||
|
const token = obj.accessToken;
|
||||||
|
if (typeof token !== "string" || token.length === 0) return null;
|
||||||
|
const accountId = typeof obj.accountId === "string" && obj.accountId.length > 0
|
||||||
|
? obj.accountId
|
||||||
|
: null;
|
||||||
|
return { token, accountId };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhamWindow {
|
||||||
|
used_percent?: number | null;
|
||||||
|
limit_window_seconds?: number | null;
|
||||||
|
reset_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhamCredits {
|
||||||
|
balance?: number | null;
|
||||||
|
unlimited?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhamUsageResponse {
|
||||||
|
rate_limit?: {
|
||||||
|
primary_window?: WhamWindow | null;
|
||||||
|
secondary_window?: WhamWindow | null;
|
||||||
|
} | null;
|
||||||
|
credits?: WhamCredits | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondsToWindowLabel(seconds: number | null | undefined): string {
|
||||||
|
if (seconds == null) return "Window";
|
||||||
|
const hours = seconds / 3600;
|
||||||
|
if (hours <= 6) return "5h";
|
||||||
|
if (hours <= 30) return "24h";
|
||||||
|
return "Weekly";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCodexQuota(token: string, accountId: string | null): Promise<QuotaWindow[]> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||||
|
|
||||||
|
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers });
|
||||||
|
if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
|
||||||
|
const body = (await resp.json()) as WhamUsageResponse;
|
||||||
|
const windows: QuotaWindow[] = [];
|
||||||
|
|
||||||
|
const rateLimit = body.rate_limit;
|
||||||
|
if (rateLimit?.primary_window != null) {
|
||||||
|
const w = rateLimit.primary_window;
|
||||||
|
windows.push({
|
||||||
|
label: secondsToWindowLabel(w.limit_window_seconds),
|
||||||
|
usedPercent: w.used_percent ?? null,
|
||||||
|
resetsAt: w.reset_at ?? null,
|
||||||
|
valueLabel: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rateLimit?.secondary_window != null) {
|
||||||
|
const w = rateLimit.secondary_window;
|
||||||
|
windows.push({
|
||||||
|
label: "Weekly",
|
||||||
|
usedPercent: w.used_percent ?? null,
|
||||||
|
resetsAt: w.reset_at ?? null,
|
||||||
|
valueLabel: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (body.credits != null && body.credits.unlimited !== true) {
|
||||||
|
const balance = body.credits.balance;
|
||||||
|
const valueLabel = balance != null
|
||||||
|
? `$${(balance / 100).toFixed(2)} remaining`
|
||||||
|
: null;
|
||||||
|
windows.push({
|
||||||
|
label: "Credits",
|
||||||
|
usedPercent: null,
|
||||||
|
resetsAt: null,
|
||||||
|
valueLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- aggregate ----------
|
||||||
|
|
||||||
|
export async function fetchAllQuotaWindows(): Promise<ProviderQuotaResult[]> {
|
||||||
|
const results: ProviderQuotaResult[] = [];
|
||||||
|
|
||||||
|
const [claudeResult, codexResult] = await Promise.allSettled([
|
||||||
|
(async (): Promise<ProviderQuotaResult> => {
|
||||||
|
const token = await readClaudeToken();
|
||||||
|
if (!token) return { provider: "anthropic", ok: false, error: "no local claude auth token", windows: [] };
|
||||||
|
const windows = await fetchClaudeQuota(token);
|
||||||
|
return { provider: "anthropic", ok: true, windows };
|
||||||
|
})(),
|
||||||
|
(async (): Promise<ProviderQuotaResult> => {
|
||||||
|
const auth = await readCodexToken();
|
||||||
|
if (!auth) return { provider: "openai", ok: false, error: "no local codex auth token", windows: [] };
|
||||||
|
const windows = await fetchCodexQuota(auth.token, auth.accountId);
|
||||||
|
return { provider: "openai", ok: true, windows };
|
||||||
|
})(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (claudeResult.status === "fulfilled") {
|
||||||
|
results.push(claudeResult.value);
|
||||||
|
} else {
|
||||||
|
results.push({ provider: "anthropic", ok: false, error: String(claudeResult.reason), windows: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codexResult.status === "fulfilled") {
|
||||||
|
results.push(codexResult.value);
|
||||||
|
} else {
|
||||||
|
results.push({ provider: "openai", ok: false, error: String(codexResult.reason), windows: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -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";
|
import { api } from "./client";
|
||||||
|
|
||||||
export interface CostByProject {
|
export interface CostByProject {
|
||||||
@@ -28,4 +28,6 @@ export const costsApi = {
|
|||||||
api.get<CostByProviderModel[]>(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
|
api.get<CostByProviderModel[]>(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
|
||||||
windowSpend: (companyId: string) =>
|
windowSpend: (companyId: string) =>
|
||||||
api.get<CostWindowSpendRow[]>(`/companies/${companyId}/costs/window-spend`),
|
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { QuotaBar } from "./QuotaBar";
|
import { QuotaBar } from "./QuotaBar";
|
||||||
import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
||||||
@@ -15,6 +15,8 @@ interface ProviderQuotaCardProps {
|
|||||||
/** rolling window rows for this provider: 5h, 24h, 7d */
|
/** rolling window rows for this provider: 5h, 24h, 7d */
|
||||||
windowRows: CostWindowSpendRow[];
|
windowRows: CostWindowSpendRow[];
|
||||||
showDeficitNotch: boolean;
|
showDeficitNotch: boolean;
|
||||||
|
/** live subscription quota windows from the provider's own api */
|
||||||
|
quotaWindows?: QuotaWindow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderQuotaCard({
|
export function ProviderQuotaCard({
|
||||||
@@ -25,6 +27,7 @@ export function ProviderQuotaCard({
|
|||||||
weekSpendCents,
|
weekSpendCents,
|
||||||
windowRows,
|
windowRows,
|
||||||
showDeficitNotch,
|
showDeficitNotch,
|
||||||
|
quotaWindows = [],
|
||||||
}: ProviderQuotaCardProps) {
|
}: ProviderQuotaCardProps) {
|
||||||
const totalInputTokens = rows.reduce((s, r) => s + r.inputTokens, 0);
|
const totalInputTokens = rows.reduce((s, r) => s + r.inputTokens, 0);
|
||||||
const totalOutputTokens = rows.reduce((s, r) => s + r.outputTokens, 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 */}
|
{/* subscription usage — shown when any subscription-billed runs exist */}
|
||||||
{totalSubRuns > 0 && (
|
{totalSubRuns > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ function invalidateActivityQueries(
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.usageQuotaWindows(companyId) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export const queryKeys = {
|
|||||||
["usage-by-provider", companyId, from, to] as const,
|
["usage-by-provider", companyId, from, to] as const,
|
||||||
usageWindowSpend: (companyId: string) =>
|
usageWindowSpend: (companyId: string) =>
|
||||||
["usage-window-spend", companyId] as const,
|
["usage-window-spend", companyId] as const,
|
||||||
|
usageQuotaWindows: (companyId: string) =>
|
||||||
|
["usage-quota-windows", companyId] as const,
|
||||||
heartbeats: (companyId: string, agentId?: string) =>
|
heartbeats: (companyId: string, agentId?: string) =>
|
||||||
["heartbeats", companyId, agentId] as const,
|
["heartbeats", companyId, agentId] as const,
|
||||||
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { costsApi } from "../api/costs";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
@@ -150,6 +150,15 @@ export function Usage() {
|
|||||||
staleTime: 10_000,
|
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
|
// rows grouped by provider
|
||||||
const byProvider = useMemo(() => {
|
const byProvider = useMemo(() => {
|
||||||
const map = new Map<string, CostByProviderModel[]>();
|
const map = new Map<string, CostByProviderModel[]>();
|
||||||
@@ -181,6 +190,17 @@ export function Usage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [windowData]);
|
}, [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
|
// 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.
|
// and flags when that projection exceeds the provider's pro-rata budget share.
|
||||||
function providerDeficitNotch(providerKey: string): boolean {
|
function providerDeficitNotch(providerKey: string): boolean {
|
||||||
@@ -292,6 +312,7 @@ export function Usage() {
|
|||||||
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||||
windowRows={windowSpendByProvider.get(p) ?? []}
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||||
showDeficitNotch={providerDeficitNotch(p)}
|
showDeficitNotch={providerDeficitNotch(p)}
|
||||||
|
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -308,6 +329,7 @@ export function Usage() {
|
|||||||
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||||
windowRows={windowSpendByProvider.get(p) ?? []}
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||||
showDeficitNotch={providerDeficitNotch(p)}
|
showDeficitNotch={providerDeficitNotch(p)}
|
||||||
|
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user