From f14b6e449ffd82a3aec876de750042662db7d2b2 Mon Sep 17 00:00:00 2001 From: Sai Shankar Date: Sun, 8 Mar 2026 16:35:14 +0530 Subject: [PATCH] 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 --- packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/quota.ts | 22 +++ server/src/routes/costs.ts | 7 + server/src/services/quota-windows.ts | 242 ++++++++++++++++++++++++ ui/src/api/costs.ts | 4 +- ui/src/components/ProviderQuotaCard.tsx | 54 +++++- ui/src/context/LiveUpdatesProvider.tsx | 1 + ui/src/lib/queryKeys.ts | 2 + ui/src/pages/Usage.tsx | 24 ++- 10 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/types/quota.ts create mode 100644 server/src/services/quota-windows.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 40ddbc0f..a8df3802 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -189,6 +189,8 @@ export type { PluginJobRecord, PluginJobRunRecord, PluginWebhookDeliveryRecord, + QuotaWindow, + ProviderQuotaResult, } from "./types/index.js"; export { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7eae528b..1564614c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -66,6 +66,7 @@ export type { JoinRequest, InstanceUserRoleGrant, } from "./access.js"; +export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; export type { CompanyPortabilityInclude, CompanyPortabilitySecretRequirement, diff --git a/packages/shared/src/types/quota.ts b/packages/shared/src/types/quota.ts new file mode 100644 index 00000000..f5e5a391 --- /dev/null +++ b/packages/shared/src/types/quota.ts @@ -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[]; +} diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index e6bb6785..dd1662b7 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -4,6 +4,7 @@ import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { costService, companyService, agentService, logActivity } from "../services/index.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { fetchAllQuotaWindows } from "../services/quota-windows.js"; export function costRoutes(db: Db) { const router = Router(); @@ -77,6 +78,12 @@ export function costRoutes(db: Db) { 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) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/quota-windows.ts b/server/src/services/quota-windows.ts new file mode 100644 index 00000000..0394a0ff --- /dev/null +++ b/server/src/services/quota-windows.ts @@ -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 { + 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; + const oauth = obj["claudeAiOauth"]; + if (typeof oauth !== "object" || oauth === null) return null; + const token = (oauth as Record)["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 { + 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 { + const headers: Record = { + "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 { + const results: ProviderQuotaResult[] = []; + + const [claudeResult, codexResult] = await Promise.allSettled([ + (async (): Promise => { + 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 => { + 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; +} diff --git a/ui/src/api/costs.ts b/ui/src/api/costs.ts index ca08c31a..ba515934 100644 --- a/ui/src/api/costs.ts +++ b/ui/src/api/costs.ts @@ -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(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`), windowSpend: (companyId: string) => api.get(`/companies/${companyId}/costs/window-spend`), + quotaWindows: (companyId: string) => + api.get(`/companies/${companyId}/costs/quota-windows`), }; diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx index b17b54b7..b46baa56 100644 --- a/ui/src/components/ProviderQuotaCard.tsx +++ b/ui/src/components/ProviderQuotaCard.tsx @@ -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 && ( + <> +
+
+

+ Subscription quota +

+
+ {quotaWindows.map((qw) => { + const pct = qw.usedPercent ?? 0; + const fillColor = + pct >= 90 + ? "bg-red-400" + : pct >= 70 + ? "bg-yellow-400" + : "bg-green-400"; + return ( +
+
+ {qw.label} + + {qw.valueLabel != null ? ( + {qw.valueLabel} + ) : qw.usedPercent != null ? ( + {qw.usedPercent}% used + ) : null} +
+ {qw.usedPercent != null && ( +
+
+
+ )} + {qw.resetsAt && ( +

+ resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })} +

+ )} +
+ ); + })} +
+
+ + )} + {/* subscription usage — shown when any subscription-billed runs exist */} {totalSubRuns > 0 && ( <> diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 33f7c90f..22a2dc49 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -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; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 75285e0c..3ae44c70 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/Usage.tsx b/ui/src/pages/Usage.tsx index c102ea03..ee5bf2aa 100644 --- a/ui/src/pages/Usage.tsx +++ b/ui/src/pages/Usage.tsx @@ -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(); @@ -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(); + 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) ?? []} /> ))}
@@ -308,6 +329,7 @@ export function Usage() { weekSpendCents={weekSpendByProvider.get(p) ?? 0} windowRows={windowSpendByProvider.get(p) ?? []} showDeficitNotch={providerDeficitNotch(p)} + quotaWindows={quotaWindowsByProvider.get(p) ?? []} /> ))}