-
- Free tier
-
+
+
+ {showUsdAllowance ? allowanceLabel : "Usage this period"}
+
+ {showUsdAllowance && !hasAccess && (
+
+ Exhausted
+
+ )}
+
-
-
- {fmt(used)}
-
- / {fmt(FREE_LIMIT)} calls
-
+ {showUsdAllowance ? (
+
+
+ ${microsToUsdDisplay(remaining)}
+
+
+ {" "}
+ / ${microsToUsdDisplay(granted)} remaining
+
+
+ ) : requestLimit ? (
+
+
+ {fmt(requestCount)}
+
+ / {fmt(requestLimit)} jobs
+
+ ) : (
+
+ {fmt(requestCount)}
+
+ )}
- resets {RESETS_AT}
+ resets {resetsAt}
- {/* Bar with forecast tick */}
-
-
-
-
+
+ {requestLimit && (
+
+ )}
-
+ )}
- {willExceed ? (
+ {requestLimit && willExceed ? (
- Forecast {fmt(forecast)} by{" "}
- {RESETS_AT} · over limit in{" "}
+ Forecast {fmt(forecast)} jobs
+ by {resetsAt} · over limit in{" "}
~{daysToLimit}d
) : (
- {fmt(left)} calls left · pace
- looks fine.
+ {fmt(requestCount)} signed
+ requests this period
+ {showUsdAllowance && (
+ <>
+ {" "}
+ ·{" "}
+
+ ${microsToUsdDisplay(usedUsd)}
+ {" "}
+ consumed
+ >
+ )}
)}
- Last period {fmt(priorPeriodTotal)} · {periodDelta >= 0 ? "+" : ""}
+ Last period {fmt(priorRequestCount)} · {periodDelta >= 0 ? "+" : ""}
{periodDelta.toFixed(0)}%
@@ -190,145 +192,147 @@ function UsageStrip({
);
}
-// ── Main view ───────────────────────────────────────────────────────────────
-
-export default function UsageView({
- weight = 1,
- filterName = "all environments",
-}: {
- /** Consumption scale for the active environment filter (1 = all envs). */
- weight?: number;
- /** Display name of the active filter, for the subtitle. */
- filterName?: string;
-}) {
- const freeUsed = Math.round(FREE_USED * weight);
-
- // 60-day series, scaled by the environment filter. Stable per (mount, weight)
- // via useMemo — random noise mustn't flicker between renders, but the series
- // re-scales when the filter changes.
- const caps = useMemo(
- () =>
- CAPABILITIES.map((c) => ({
- ...c,
- data60: genCapSeries(c.base * weight, c.drift, c.noise, 60),
- })),
- [weight]
+export default function UsageView() {
+ const { isConnected, user } = useAuth();
+ const usageState = useAccountUsage(isConnected, PERIOD_DAYS);
+ const [priceMin, setPriceMin] = useState(0);
+ const [priceMax, setPriceMax] = useState(100);
+
+ const capabilityRows = useMemo(() => {
+ if (usageState.status !== "ready") return [];
+ return buildUsageCapabilityRows({
+ current: usageState.data.current.pipelineModels,
+ prior: usageState.data.prior.pipelineModels,
+ period: usageState.data.period,
+ dailyByPipeline: usageState.data.current.dailyByPipeline,
+ });
+ }, [usageState]);
+
+ const dataMaxSpend = useMemo(
+ () => Math.max(...capabilityRows.map((c) => c.spendUsd), 0.01),
+ [capabilityRows]
);
- const sliced = caps.map((c) => ({
- ...c,
- data: c.data60.slice(-PERIOD_DAYS),
- prior: c.data60.slice(-PERIOD_DAYS * 2, -PERIOD_DAYS),
- }));
-
- const totals = sliced.map((c) => {
- const sum = c.data.reduce((a, b) => a + b, 0);
- const priorSum = c.prior.reduce((a, b) => a + b, 0);
- const delta = priorSum > 0 ? ((sum - priorSum) / priorSum) * 100 : 0;
- return { ...c, sum, priorSum, delta, spend: sum * c.price };
- });
- const grandReq = totals.reduce((a, c) => a + c.sum, 0);
- const grandSpend = totals.reduce((a, c) => a + c.spend, 0);
- const totalsByDay = sliced[0].data.map((_, i) =>
- sliced.reduce((a, c) => a + c.data[i], 0)
- );
- const priorTotalsByDay = sliced[0].prior.map((_, i) =>
- sliced.reduce((a, c) => a + c.prior[i], 0)
- );
- const priorPeriodTotal = Math.round(
- priorTotalsByDay.reduce((a, b) => a + b, 0)
- );
- const periodDelta =
- priorPeriodTotal > 0
- ? ((freeUsed - priorPeriodTotal) / priorPeriodTotal) * 100
- : 0;
+ const filteredRows = useMemo(() => {
+ return capabilityRows.filter((c) => {
+ const matchesPrice =
+ c.spendUsd >= (priceMin / 100) * dataMaxSpend &&
+ c.spendUsd <= (priceMax / 100) * dataMaxSpend;
+ return matchesPrice;
+ });
+ }, [capabilityRows, priceMin, priceMax, dataMaxSpend]);
+
+ const periodDayCount = useMemo(() => {
+ if (usageState.status !== "ready") return PERIOD_DAYS;
+ const first = capabilityRows[0]?.data.length;
+ return first && first > 0 ? first : PERIOD_DAYS;
+ }, [usageState, capabilityRows]);
+
+ const forecastStats = useMemo(() => {
+ if (usageState.status !== "ready") {
+ return {
+ forecast: 0,
+ willExceed: false,
+ daysToLimit: 0,
+ priorRequestCount: 0,
+ periodDelta: 0,
+ requestCount: 0,
+ };
+ }
+ const { current, prior } = usageState.data;
+ const dayCount = capabilityRows[0]?.data.length ?? PERIOD_DAYS;
+ const totalsByDay = Array.from({ length: dayCount }, (_, dayIndex) =>
+ capabilityRows.reduce((sum, row) => sum + (row.data[dayIndex] ?? 0), 0)
+ );
+ const last7Avg =
+ totalsByDay.slice(-7).reduce((a, b) => a + b, 0) /
+ Math.max(1, Math.min(7, totalsByDay.length));
+ const daysLeft = 6;
+ const forecast = Math.round(current.requestCount + last7Avg * daysLeft);
+ const grantedJobs = usageState.data.balance?.lifetimeGrantedUsdMicros
+ ? null
+ : 10_000;
+ const limit = grantedJobs ?? 10_000;
+ const willExceed = forecast > limit;
+ const left = limit - current.requestCount;
+ const daysToLimit =
+ left > 0 && last7Avg > 0 ? Math.max(0, Math.floor(left / last7Avg)) : 0;
+ const priorRequestCount = prior.requestCount;
+ const periodDelta =
+ priorRequestCount > 0
+ ? ((current.requestCount - priorRequestCount) / priorRequestCount) * 100
+ : 0;
+
+ return {
+ forecast,
+ willExceed,
+ daysToLimit,
+ priorRequestCount,
+ periodDelta,
+ requestCount: current.requestCount,
+ };
+ }, [usageState, capabilityRows]);
+
+ if (usageState.status === "loading" || usageState.status === "idle") {
+ return (
+
+
+
+ );
+ }
+
+ if (usageState.status === "error") {
+ return (
+
+
+
+ );
+ }
- // Forecast: trailing 7-day average × days remaining in period
- const last7Avg = totalsByDay.slice(-7).reduce((a, b) => a + b, 0) / 7;
- const forecast = Math.round(freeUsed + last7Avg * DAYS_LEFT_IN_PERIOD);
- const willExceed = forecast > FREE_LIMIT;
- const left = FREE_LIMIT - freeUsed;
- const daysToLimit =
- left > 0 && last7Avg > 0 ? Math.max(0, Math.floor(left / last7Avg)) : 0;
-
- // Breakdown table — sorted descending by total runs
- const sortedTotals = [...totals].sort((a, b) => b.sum - a.sum);
-
- // Limits — same shape as design
- const limits: {
- label: string;
- used: number;
- max: number;
- fmt: (v: number) => string;
- }[] = [
- {
- label: "Calls / month",
- used: freeUsed,
- max: FREE_LIMIT,
- fmt: (v) => v.toLocaleString("en-US"),
- },
- {
- label: "Concurrent streams",
- used: 2,
- max: 3,
- fmt: (v) => String(v),
- },
- {
- label: "Max video duration",
- used: 4,
- max: 5,
- fmt: (v) => `${v} min`,
- },
- {
- label: "Storage retained",
- used: 1.2,
- max: 5,
- fmt: (v) => `${v} GB`,
- },
- ];
+ const { data } = usageState;
+ const grandReq = filteredRows.reduce((a, c) => a + c.requestCount, 0);
+ const grandSpend = filteredRows.reduce((a, c) => a + c.spendUsd, 0);
+ const included: IncludedUsageSummary | null = null;
+ const resetsAt = formatPeriodResetLabel(data.period.end);
return (
- {/* Title — scope (Organization) is shown by the header chip. */}
-
-
- Usage
-
-
- Free-tier quota and spend
- {weight === 1 ? " across all environments" : ` in ${filterName}`}. For
- a per-environment breakdown, see your{" "}
-
- calls
-
- .
-
-
+
+ Account{user?.id ? ` · ${user.id}` : ""}
+
- {/* Free-tier strip */}
-
- {/* Jobs by capability — stacked area */}
-
Activity by app
+
Jobs by capability
- Last {PERIOD_LABEL.toLowerCase()} · {fmt(grandReq)} calls
+ {periodDayCount} days · {fmt(data.current.requestCount)} jobs ·
+ OpenMeter
- {sortedTotals.map((c) => (
+ {filteredRows.map((c) => (
-
-
({ name: c.name, data: c.data }))}
- colors={sliced.map((c) => c.color)}
- />
+
+ {filteredRows.length > 0 ? (
+
({ name: c.name, data: c.data }))}
+ colors={filteredRows.map((c) => c.color)}
+ dayKeys={data.periodDayKeys}
+ />
+ ) : (
+
+ No usage in this period.
+
+ )}
- {/* Breakdown table */}
+
{
+ setPriceMin(min);
+ setPriceMax(max);
+ }}
+ allRows={capabilityRows}
+ />
+
+
+
+ );
+}
+
+function BreakdownSection({
+ rows,
+ grandReq,
+ grandSpend,
+ priceMin,
+ priceMax,
+ dataMaxSpend,
+ onPriceChange,
+ allRows,
+}: {
+ rows: UsageCapabilityRow[];
+ grandReq: number;
+ grandSpend: number;
+ priceMin: number;
+ priceMax: number;
+ dataMaxSpend: number;
+ onPriceChange: (min: number, max: number) => void;
+ allRows: UsageCapabilityRow[];
+}) {
+ return (
+ <>
Breakdown
- {totals.length}
+ {rows.length}
-
-
- {/* Limits */}
-
-
-
-
Limits
-
- Free tier defaults · raise after adding payment
-
-
-
+ {allRows.length > 0 && (
+
+ Spend filter: ${((priceMin / 100) * dataMaxSpend).toFixed(3)} – $
+ {((priceMax / 100) * dataMaxSpend).toFixed(3)} (
+ onPriceChange(0, 100)}
>
- Compare plans
-
-
-
- {limits.map((l) => {
- const pct = Math.min(100, (l.used / l.max) * 100);
- const overWarn = pct > 80;
- return (
-
-
- {l.label}
-
- {l.fmt(l.used)}
- / {l.fmt(l.max)}
-
-
-
-
- );
- })}
-
-
-
+ reset
+
+ )
+
+ )}
+ >
);
}
-// ── Breakdown table ─────────────────────────────────────────────────────────
-
function BreakdownTable({
rows,
grandReq,
grandSpend,
}: {
- rows: (Capability & {
- sum: number;
- delta: number;
- spend: number;
- data: number[];
- })[];
+ rows: UsageCapabilityRow[];
grandReq: number;
grandSpend: number;
}) {
- // grid: Capability | Jobs · trend (sparkline) | Δ vs prior | Share | Unit price | Spend
const cols =
"grid grid-cols-[1.7fr_1.5fr_0.7fr_0.7fr_1fr_0.9fr] items-center gap-2 px-4";
@@ -442,90 +453,88 @@ function BreakdownTable({
return (
- {/* Head */}
-
App
-
Calls · trend
+
Capability
+
Jobs · trend
Δ vs prior
Share
-
Unit price
-
Spend
+
Network cost
+
Billable
- {/* Rows */}
- {rows.map((c) => {
- const share = (c.sum / grandReq) * 100;
- const dUp = c.delta > 0;
- return (
-
- {/* Capability */}
-
-
-
- {c.name}
-
-
-
- {/* Jobs · trend (number + inline sparkline) */}
-
-
- {fmt(c.sum)}
-
-
-
-
-
-
- {/* Δ vs prior */}
+ {rows.length === 0 ? (
+
+ No matching capabilities.
+
+ ) : (
+ rows.map((c) => {
+ const share = grandReq > 0 ? (c.requestCount / grandReq) * 100 : 0;
+ const unitCost = c.requestCount > 0 ? c.spendUsd / c.requestCount : 0;
+ return (
- {dUp ? "+" : ""}
- {c.delta.toFixed(0)}%
-
-
- {/* Share */}
-
- {share.toFixed(1)}%
-
-
- {/* Unit price */}
-
- ${c.price.toFixed(4)}
- /{c.unit}
-
-
- {/* Spend */}
-
- {fmtSpend(c.spend)}
+
+
+
+ {c.name}
+
+
+
+
+ {fmt(c.requestCount)}
+
+
+
+
+
+
+ {c.delta > 0 ? "+" : ""}
+ {c.delta.toFixed(0)}%
+
+
+ {share.toFixed(1)}%
+
+
+ ${microsToUsdDisplay(c.networkFeeUsdMicros)}
+
+
+ {fmtSpend(c.spendUsd)}
+ {unitCost > 0 && (
+
+ {" "}
+ · ${unitCost.toFixed(4)}/req
+
+ )}
+
-
- );
- })}
+ );
+ })
+ )}
- {/* Total */}
-
+
Total
· this period
@@ -544,3 +553,125 @@ function BreakdownTable({
);
}
+
+function LimitsPanel({
+ balance,
+ included,
+ networkFeeUsdMicros,
+ endUserBillableUsdMicros,
+ requestCount,
+}: {
+ balance: {
+ balanceUsdMicros: string;
+ consumedUsdMicros: string;
+ lifetimeGrantedUsdMicros: string;
+ hasAccess: boolean;
+ } | null;
+ included: IncludedUsageSummary | null;
+ networkFeeUsdMicros: string;
+ endUserBillableUsdMicros: string;
+ requestCount: number;
+}) {
+ const includedLimit = included
+ ? {
+ label: included.planName
+ ? `${included.planName} included usage`
+ : "Included usage",
+ used: microsToUsdDisplay(included.consumedUsdMicros),
+ max: `$${included.totalUsd}`,
+ pct:
+ BigInt(included.totalUsdMicros || "0") > BigInt(0)
+ ? Math.min(
+ 100,
+ Number(
+ (BigInt(included.consumedUsdMicros || "0") * BigInt(10000)) /
+ BigInt(included.totalUsdMicros || "1")
+ ) / 100
+ )
+ : 0,
+ }
+ : null;
+ const prepaidLimit = balance
+ ? {
+ label: "Prepaid credits",
+ used: `$${microsToUsdDisplay(balance.balanceUsdMicros)}`,
+ max: "—",
+ pct: balance.hasAccess ? 40 : 100,
+ }
+ : null;
+ const limits =
+ includedLimit || prepaidLimit
+ ? [includedLimit, prepaidLimit].filter(
+ (row): row is NonNullable
=> row !== null
+ )
+ : [
+ {
+ label: "Signed requests",
+ used: fmt(requestCount),
+ max: "—",
+ pct: 50,
+ },
+ ];
+
+ const extra = [
+ {
+ label: "Network cost (metered)",
+ used: `$${microsToUsdDisplay(networkFeeUsdMicros)}`,
+ max: "pass-through",
+ pct: 30,
+ },
+ {
+ label: "Billable (retail estimate)",
+ used: `$${microsToUsdDisplay(endUserBillableUsdMicros)}`,
+ max: "—",
+ pct: 45,
+ },
+ ];
+
+ return (
+
+
+
+
Limits & metering
+
+ OpenMeter subscription allowance · network_spend meter
+
+
+
+ Manage plan
+
+
+
+ {[...limits, ...extra].map((l) => {
+ const overWarn = l.pct > 80;
+ return (
+
+
+ {l.label}
+
+ {l.used}
+ / {l.max}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/lib/auth0.ts b/lib/auth0.ts
index 41ca7d6..ffa5458 100644
--- a/lib/auth0.ts
+++ b/lib/auth0.ts
@@ -1,3 +1,10 @@
import { Auth0Client } from "@auth0/nextjs-auth0/server";
+// Preview hosts change per deploy. A static APP_BASE_URL (or next.config bake
+// of VERCEL_BRANCH_URL) makes Auth0 set the `__txn_` cookie on one host and
+// return to another — "The state parameter is invalid".
+if (process.env.VERCEL_ENV === "preview") {
+ delete process.env.APP_BASE_URL;
+}
+
export const auth0 = new Auth0Client();
diff --git a/lib/console/account-usage-payload.test.ts b/lib/console/account-usage-payload.test.ts
new file mode 100644
index 0000000..dccf4b4
--- /dev/null
+++ b/lib/console/account-usage-payload.test.ts
@@ -0,0 +1,137 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ errorMessageFromBody,
+ isAccountUsagePayload,
+} from "./account-usage-payload";
+
+function validPayload(): Record {
+ return {
+ clientId: "app_dashboard",
+ period: {
+ start: "2026-07-08T00:00:00.000Z",
+ end: "2026-08-07T00:00:00.000Z",
+ },
+ periodDayKeys: ["2026-08-06", "2026-08-07"],
+ priorPeriod: {
+ start: "2026-06-08T00:00:00.000Z",
+ end: "2026-07-08T00:00:00.000Z",
+ },
+ balance: null,
+ current: {
+ requestCount: 3,
+ networkFeeUsdMicros: "1200",
+ endUserBillableUsdMicros: "2400",
+ pipelineModels: [
+ {
+ pipeline: "text-to-image",
+ modelId: "sd-3",
+ requestCount: 3,
+ networkFeeUsdMicros: "1200",
+ endUserBillableUsdMicros: "2400",
+ dailyRequests: [1, 2],
+ },
+ ],
+ dailyByPipeline: [
+ {
+ pipeline: "text-to-image",
+ modelId: "sd-3",
+ date: "2026-08-07",
+ requestCount: 2,
+ networkFeeUsdMicros: "800",
+ },
+ ],
+ },
+ prior: { requestCount: 1, pipelineModels: [] },
+ };
+}
+
+test("accepts a well-formed payload, with or without a balance", () => {
+ assert.equal(isAccountUsagePayload(validPayload()), true);
+
+ const withBalance = validPayload();
+ withBalance.balance = {
+ externalUserId: "95c33c7d-8951-4d9f-8c7f-3a589a4e4adc",
+ balanceUsdMicros: "500000",
+ consumedUsdMicros: "500000",
+ lifetimeGrantedUsdMicros: "1000000",
+ hasAccess: true,
+ };
+ assert.equal(isAccountUsagePayload(withBalance), true);
+});
+
+test("rejects bodies that aren't a payload object at all", () => {
+ // The stub that crashed the sidebar in the browser.
+ assert.equal(isAccountUsagePayload({}), false);
+ assert.equal(isAccountUsagePayload(null), false);
+ assert.equal(isAccountUsagePayload(undefined), false);
+ assert.equal(isAccountUsagePayload([validPayload()]), false);
+ assert.equal(isAccountUsagePayload("ok"), false);
+});
+
+test("rejects a payload missing only period, or with an unusable period", () => {
+ const noPeriod = validPayload();
+ delete noPeriod.period;
+ assert.equal(isAccountUsagePayload(noPeriod), false);
+
+ const noEnd = validPayload();
+ noEnd.period = { start: "2026-07-08T00:00:00.000Z" };
+ assert.equal(isAccountUsagePayload(noEnd), false);
+
+ const nullEnd = validPayload();
+ nullEnd.period = { start: "2026-07-08T00:00:00.000Z", end: null };
+ assert.equal(isAccountUsagePayload(nullEnd), false);
+});
+
+test("rejects scopes whose BigInt/array reads would throw during render", () => {
+ const noMicros = validPayload();
+ noMicros.current = {
+ ...(validPayload().current as object),
+ endUserBillableUsdMicros: 2400,
+ };
+ assert.equal(isAccountUsagePayload(noMicros), false);
+
+ const noRows = validPayload();
+ noRows.current = {
+ ...(validPayload().current as object),
+ pipelineModels: null,
+ };
+ assert.equal(isAccountUsagePayload(noRows), false);
+
+ const rowWithoutSeries = validPayload();
+ rowWithoutSeries.current = {
+ ...(validPayload().current as object),
+ pipelineModels: [
+ { pipeline: "text-to-image", modelId: "sd-3", requestCount: 3 },
+ ],
+ };
+ assert.equal(isAccountUsagePayload(rowWithoutSeries), false);
+
+ const noPrior = validPayload();
+ delete noPrior.prior;
+ assert.equal(isAccountUsagePayload(noPrior), false);
+
+ const noDayKeys = validPayload();
+ noDayKeys.periodDayKeys = undefined;
+ assert.equal(isAccountUsagePayload(noDayKeys), false);
+});
+
+test("rejects a partial balance but allows an explicit null", () => {
+ const partialBalance = validPayload();
+ partialBalance.balance = { balanceUsdMicros: "500000", hasAccess: true };
+ assert.equal(isAccountUsagePayload(partialBalance), false);
+
+ const undefinedBalance = validPayload();
+ undefinedBalance.balance = undefined;
+ assert.equal(isAccountUsagePayload(undefinedBalance), false);
+});
+
+test("errorMessageFromBody only trusts a string error field", () => {
+ assert.equal(
+ errorMessageFromBody({ error: "upstream down" }),
+ "upstream down"
+ );
+ assert.equal(errorMessageFromBody({ error: { code: 500 } }), null);
+ assert.equal(errorMessageFromBody("upstream down"), null);
+ assert.equal(errorMessageFromBody(null), null);
+});
diff --git a/lib/console/account-usage-payload.ts b/lib/console/account-usage-payload.ts
new file mode 100644
index 0000000..17f6b8e
--- /dev/null
+++ b/lib/console/account-usage-payload.ts
@@ -0,0 +1,97 @@
+import type { AccountUsagePayload } from "@/lib/console/account-usage";
+
+/**
+ * Runtime shape check for `/api/pymthouse/account-usage` responses.
+ *
+ * A 200 with a body that doesn't match `AccountUsagePayload` used to reach the
+ * consumers as a well-typed value, so `data.period.end` (and the BigInt reads on
+ * `current`/`balance`) threw during render. Covers the fields the consumers
+ * dereference without a fallback — `clientId` and `priorPeriod` are carried by
+ * the type but nothing reads them, so they're deliberately not checked.
+ */
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isPeriodBounds(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ typeof value.start === "string" &&
+ typeof value.end === "string"
+ );
+}
+
+/** `balance` is legitimately null when the account has no prepaid grant. */
+function isBalance(value: unknown): boolean {
+ return (
+ value === null ||
+ (isRecord(value) &&
+ typeof value.balanceUsdMicros === "string" &&
+ typeof value.consumedUsdMicros === "string" &&
+ typeof value.lifetimeGrantedUsdMicros === "string" &&
+ typeof value.hasAccess === "boolean")
+ );
+}
+
+function isPipelineRow(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ typeof value.pipeline === "string" &&
+ typeof value.modelId === "string" &&
+ typeof value.requestCount === "number" &&
+ Array.isArray(value.dailyRequests)
+ );
+}
+
+function isDailyPipelineRow(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ typeof value.pipeline === "string" &&
+ typeof value.modelId === "string" &&
+ typeof value.date === "string" &&
+ typeof value.requestCount === "number"
+ );
+}
+
+function isCurrentScope(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ typeof value.requestCount === "number" &&
+ typeof value.networkFeeUsdMicros === "string" &&
+ typeof value.endUserBillableUsdMicros === "string" &&
+ Array.isArray(value.pipelineModels) &&
+ value.pipelineModels.every(isPipelineRow) &&
+ Array.isArray(value.dailyByPipeline) &&
+ value.dailyByPipeline.every(isDailyPipelineRow)
+ );
+}
+
+function isPriorScope(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ typeof value.requestCount === "number" &&
+ Array.isArray(value.pipelineModels) &&
+ value.pipelineModels.every(isPipelineRow)
+ );
+}
+
+export function isAccountUsagePayload(
+ value: unknown
+): value is AccountUsagePayload {
+ return (
+ isRecord(value) &&
+ isPeriodBounds(value.period) &&
+ Array.isArray(value.periodDayKeys) &&
+ isBalance(value.balance) &&
+ isCurrentScope(value.current) &&
+ isPriorScope(value.prior)
+ );
+}
+
+/** Reads the `{ error }` field off a non-OK body without trusting its shape. */
+export function errorMessageFromBody(value: unknown): string | null {
+ return isRecord(value) && typeof value.error === "string"
+ ? value.error
+ : null;
+}
diff --git a/lib/console/account-usage.ts b/lib/console/account-usage.ts
new file mode 100644
index 0000000..c2e6aba
--- /dev/null
+++ b/lib/console/account-usage.ts
@@ -0,0 +1,67 @@
+export type AccountUsageBalance = {
+ externalUserId: string;
+ balanceUsdMicros: string;
+ consumedUsdMicros: string;
+ lifetimeGrantedUsdMicros: string;
+ hasAccess: boolean;
+};
+
+export type AccountUsageDailyPipelineRow = {
+ pipeline: string;
+ modelId: string;
+ date: string;
+ requestCount: number;
+ networkFeeUsdMicros: string;
+};
+
+export type AccountUsagePipelineRow = {
+ pipeline: string;
+ modelId: string;
+ requestCount: number;
+ networkFeeUsdMicros: string;
+ endUserBillableUsdMicros: string;
+ /** OpenMeter daily buckets aligned to `period` (oldest → newest). */
+ dailyRequests: number[];
+};
+
+export type AccountUsagePayload = {
+ clientId: string;
+ period: { start: string; end: string };
+ /** UTC YYYY-MM-DD keys aligned with `pipelineModels[].dailyRequests` (oldest → newest). */
+ periodDayKeys: string[];
+ priorPeriod: { start: string; end: string };
+ balance: AccountUsageBalance | null;
+ current: {
+ requestCount: number;
+ networkFeeUsdMicros: string;
+ endUserBillableUsdMicros: string;
+ pipelineModels: AccountUsagePipelineRow[];
+ dailyByPipeline: AccountUsageDailyPipelineRow[];
+ };
+ prior: {
+ requestCount: number;
+ pipelineModels: AccountUsagePipelineRow[];
+ };
+};
+
+export type SignedTicketRequestRow = {
+ time: string;
+ clientId: string;
+ appName?: string;
+ externalUserId: string;
+ gatewayRequestId: string;
+ pipeline: string;
+ modelId: string;
+ networkFeeUsdMicros: string;
+ feeWei?: string;
+ pixels?: string;
+ eventId: string;
+};
+
+export type AccountRequestsPayload = {
+ items: SignedTicketRequestRow[];
+ nextCursor: string | null;
+ openMeterConfigured: boolean;
+ clientId: string;
+ externalUserId: string;
+};
diff --git a/lib/console/org-consumption.ts b/lib/console/org-consumption.ts
index 3b0a7e7..a73afb6 100644
--- a/lib/console/org-consumption.ts
+++ b/lib/console/org-consumption.ts
@@ -1,21 +1,20 @@
-import { formatCompact } from "./org-fleet";
+import type { AccountUsagePayload } from "@/lib/console/account-usage";
+import {
+ humanizePipelineModel,
+ microsToUsd,
+} from "@/lib/console/usage-capability-display";
+import { formatCompact, getOrgFleet } from "./org-fleet";
/**
- * The CONSUME (outbound) ledger — the mirror of the "Deployed apps" ledger.
- *
- * An organization doesn't only deploy apps; it also *calls* apps across the
- * network — its own, and (mostly) apps it didn't deploy. That outbound demand
- * is what drives spend. This module answers the operator's question: "how much
- * of my usage is on apps I didn't build?"
- *
- * Mock note: in production this is aggregated from metered outbound requests.
+ * The CONSUME (outbound) ledger — apps this organization calls, with MTD spend
+ * and trailing 7-day call volume. Built from PymtHouse account-usage (OpenMeter).
*/
export interface ConsumedApp {
- /** App id when it's one of ours; otherwise a network slug for linking. */
+ /** App id when it's one of ours; otherwise pipeline|model key for linking. */
id: string;
name: string;
- /** Provider/owner label, or "Your organization" for apps you deployed. */
+ /** Provider/owner label, or the org slug for apps you deployed. */
owner: string;
/** Did THIS organization deploy it? false = an app you didn't build. */
owned: boolean;
@@ -24,83 +23,6 @@ export interface ConsumedApp {
spendNum: number;
}
-// What this organization calls. Mostly third-party network apps (you didn't
-// deploy them); a little is the org exercising its own apps.
-const CONSUMED_APPS_RAW: ConsumedApp[] = [
- {
- id: "daydream-video",
- name: "Daydream Video",
- owner: "daydream",
- owned: false,
- calls7d: 2_400,
- spendNum: 1.6,
- },
- {
- id: "flux-schnell",
- name: "FLUX Schnell",
- owner: "black-forest-labs",
- owned: false,
- calls7d: 1_900,
- spendNum: 0.95,
- },
- {
- id: "frameworks-transcoding",
- name: "Frameworks Transcoding",
- owner: "frameworks",
- owned: false,
- calls7d: 1_500,
- spendNum: 0.8,
- },
- {
- id: "qwen3-32b",
- name: "Qwen3 32B",
- owner: "qwen",
- owned: false,
- calls7d: 900,
- spendNum: 0.55,
- },
- {
- id: "whisper-v3",
- name: "Whisper V3",
- owner: "openai",
- owned: false,
- calls7d: 600,
- spendNum: 0.45,
- },
- {
- id: "sdxl-turbo",
- name: "SDXL Turbo",
- owner: "stability",
- owned: false,
- calls7d: 700,
- spendNum: 0.4,
- },
- {
- id: "llama-3-70b",
- name: "Llama 3 70B",
- owner: "meta",
- owned: false,
- calls7d: 400,
- spendNum: 0.25,
- },
- {
- id: "app-sentiment",
- name: "Sentiment",
- owner: "Your organization",
- owned: true,
- calls7d: 400,
- spendNum: 0.4,
- },
- {
- id: "app-image-upscale",
- name: "Image Upscale",
- owner: "Your organization",
- owned: true,
- calls7d: 100,
- spendNum: 0.3,
- },
-];
-
export interface OrgConsumption {
/** Apps you call, sorted by spend desc. The `owned` flag on each row tells
* your own apps apart from apps you didn't deploy. */
@@ -113,10 +35,85 @@ function money(n: number): string {
return `$${n.toFixed(2)}`;
}
-export function getOrgConsumption(): OrgConsumption {
- const apps = [...CONSUMED_APPS_RAW].sort((a, b) => b.spendNum - a.spendNum);
- const totalSpendNum = apps.reduce((s, a) => s + a.spendNum, 0);
- const totalCalls7d = apps.reduce((s, a) => s + a.calls7d, 0);
+/** Last 7 UTC calendar dates (YYYY-MM-DD), newest first. */
+function last7UtcDateKeys(now = new Date()): Set {
+ const keys = new Set();
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - i)
+ );
+ keys.add(d.toISOString().slice(0, 10));
+ }
+ return keys;
+}
+
+function ownerSlugFromPipeline(pipeline: string): string {
+ const raw = pipeline.includes(":")
+ ? pipeline.split(":").slice(-1)[0]!
+ : pipeline;
+ return (
+ raw
+ .split(/[-_./|]+/)
+ .filter(Boolean)[0]
+ ?.toLowerCase() || pipeline.toLowerCase()
+ );
+}
+
+/**
+ * Map a MTD account-usage payload into the Home Usage panel shape.
+ * Spend is period totals (calendar MTD when fetched with `window=mtd`);
+ * calls · 7d are summed from `dailyByPipeline` over the last 7 UTC days.
+ */
+export function buildOrgConsumptionFromUsage(
+ payload: AccountUsagePayload,
+ organization: string
+): OrgConsumption {
+ const fleet = getOrgFleet();
+ const ownedByPipelineId = new Map(
+ fleet.apps.map((app) => [app.deployment.pipelineId, app.id] as const)
+ );
+ const last7 = last7UtcDateKeys();
+ const periodDayCount = payload.periodDayKeys.length;
+
+ const calls7dByKey = new Map();
+ for (const row of payload.current.dailyByPipeline) {
+ if (!last7.has(row.date)) continue;
+ const key = `${row.pipeline}|${row.modelId}`;
+ calls7dByKey.set(key, (calls7dByKey.get(key) ?? 0) + row.requestCount);
+ }
+
+ const apps: ConsumedApp[] = payload.current.pipelineModels.map((row) => {
+ const key = `${row.pipeline}|${row.modelId}`;
+ const ownedAppId =
+ ownedByPipelineId.get(row.pipeline) ?? ownedByPipelineId.get(row.modelId);
+ const owned = Boolean(ownedAppId);
+ const fromDaily = calls7dByKey.get(key) ?? 0;
+ // Early in the month (or when daily buckets are missing) fall back to the
+ // period total when the whole MTD window fits inside 7 days.
+ const calls7d =
+ fromDaily > 0 || periodDayCount > 7 ? fromDaily : row.requestCount;
+
+ return {
+ id: ownedAppId ?? key,
+ name: humanizePipelineModel(row.pipeline, row.modelId),
+ owner: owned
+ ? organization.toLowerCase()
+ : ownerSlugFromPipeline(row.pipeline),
+ owned,
+ calls7d,
+ spendNum: microsToUsd(
+ row.endUserBillableUsdMicros || row.networkFeeUsdMicros
+ ),
+ };
+ });
+
+ apps.sort((a, b) => b.spendNum - a.spendNum || b.calls7d - a.calls7d);
+
+ const totalSpendNum = microsToUsd(
+ payload.current.endUserBillableUsdMicros ||
+ payload.current.networkFeeUsdMicros
+ );
+ const totalCalls7d = apps.reduce((sum, app) => sum + app.calls7d, 0);
return {
apps,
diff --git a/lib/console/pymthouse-bff.ts b/lib/console/pymthouse-bff.ts
index 9d5ea83..a916c32 100644
--- a/lib/console/pymthouse-bff.ts
+++ b/lib/console/pymthouse-bff.ts
@@ -1,10 +1,30 @@
import "server-only";
-import { PmtHouseClient, PmtHouseError } from "@pymthouse/builder-sdk";
import {
+ getUtcCalendarMonthIsoBounds,
+ PmtHouseClient,
+ PmtHouseError,
+} from "@pymthouse/builder-sdk";
+import type {
+ AccountRequestsPayload,
+ AccountUsageBalance,
+ AccountUsagePayload,
+ AccountUsagePipelineRow,
+} from "@/lib/console/account-usage";
+import {
+ issuerOriginFromConfig,
readPublicClientId,
requirePymthouseM2mConfig,
} from "@/lib/console/pymthouse-http";
+import {
+ dailyRequestSeriesForPipeline,
+ utcDateKeysForPeriod,
+} from "@/lib/console/usage-capability-display";
+
+export type {
+ AccountRequestsPayload,
+ AccountUsagePayload,
+} from "@/lib/console/account-usage";
export function createPmtHouseClientForPublicApp(
publicClientId: string
@@ -63,3 +83,227 @@ export async function mintEndUserAccessToken(
throw error;
}
}
+
+function rollingPeriodDays(
+ days: number,
+ now = new Date()
+): {
+ startDate: string;
+ endDate: string;
+ priorStartDate: string;
+ priorEndDate: string;
+} {
+ const end = new Date(now);
+ end.setUTCHours(23, 59, 59, 999);
+ const start = new Date(end);
+ start.setUTCDate(start.getUTCDate() - (days - 1));
+ start.setUTCHours(0, 0, 0, 0);
+
+ const priorEnd = new Date(start);
+ priorEnd.setUTCMilliseconds(priorEnd.getUTCMilliseconds() - 1);
+ const priorStart = new Date(priorEnd);
+ priorStart.setUTCDate(priorStart.getUTCDate() - (days - 1));
+ priorStart.setUTCHours(0, 0, 0, 0);
+
+ return {
+ startDate: start.toISOString(),
+ endDate: end.toISOString(),
+ priorStartDate: priorStart.toISOString(),
+ priorEndDate: priorEnd.toISOString(),
+ };
+}
+
+function mtdPeriodBounds(now = new Date()): {
+ startDate: string;
+ endDate: string;
+ priorStartDate: string;
+ priorEndDate: string;
+} {
+ const { startDate, endDate } = getUtcCalendarMonthIsoBounds(now);
+ const monthStart = new Date(startDate);
+ const priorEnd = new Date(monthStart.getTime() - 1);
+ const priorStart = new Date(
+ Date.UTC(priorEnd.getUTCFullYear(), priorEnd.getUTCMonth(), 1, 0, 0, 0, 0)
+ );
+ return {
+ startDate,
+ endDate,
+ priorStartDate: priorStart.toISOString(),
+ priorEndDate: priorEnd.toISOString(),
+ };
+}
+
+async function fetchUsageBalance(
+ client: PmtHouseClient,
+ externalUserId: string
+): Promise {
+ try {
+ const balance = await client.getUsageBalance(externalUserId);
+ return {
+ externalUserId: balance.externalUserId ?? externalUserId,
+ balanceUsdMicros: balance.balanceUsdMicros ?? "0",
+ consumedUsdMicros: balance.consumedUsdMicros ?? "0",
+ lifetimeGrantedUsdMicros: balance.lifetimeGrantedUsdMicros ?? "0",
+ hasAccess: Boolean(balance.hasAccess),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function fetchAccountUsageForExternalUser(input: {
+ externalUserId: string;
+ periodDays?: number;
+ window?: "rolling" | "mtd";
+ includePrior?: boolean;
+}): Promise {
+ const publicClientId = readPublicClientId();
+ const includePrior = input.includePrior !== false;
+ const period =
+ input.window === "mtd"
+ ? mtdPeriodBounds()
+ : rollingPeriodDays(input.periodDays ?? 30);
+
+ const client = createPmtHouseClientForPublicApp(publicClientId);
+
+ const [balance, currentScope, priorScope] = await Promise.all([
+ fetchUsageBalance(client, input.externalUserId),
+ client.fetchUsageForExternalUser({
+ externalUserId: input.externalUserId,
+ startDate: period.startDate,
+ endDate: period.endDate,
+ includeRetail: true,
+ }),
+ includePrior
+ ? client.fetchUsageForExternalUser({
+ externalUserId: input.externalUserId,
+ startDate: period.priorStartDate,
+ endDate: period.priorEndDate,
+ includeRetail: true,
+ })
+ : Promise.resolve(null),
+ ]);
+
+ const periodBounds = { start: period.startDate, end: period.endDate };
+ const dayKeys = utcDateKeysForPeriod(periodBounds.start, periodBounds.end);
+ const dailyByPipeline = (currentScope.currentUser.dailyByPipeline ?? []).map(
+ (row) => ({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ date: row.date,
+ requestCount: row.requestCount,
+ networkFeeUsdMicros: row.networkFeeUsdMicros,
+ })
+ );
+
+ const mapPipeline = (
+ rows: typeof currentScope.currentUser.pipelineModels,
+ seriesDayKeys: string[],
+ seriesDaily: typeof dailyByPipeline
+ ): AccountUsagePipelineRow[] =>
+ rows.map((row) => ({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ requestCount: row.requestCount,
+ networkFeeUsdMicros: row.networkFeeUsdMicros,
+ endUserBillableUsdMicros: row.endUserBillableUsdMicros,
+ dailyRequests: dailyRequestSeriesForPipeline({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ dayKeys: seriesDayKeys,
+ dailyByPipeline: seriesDaily,
+ }),
+ }));
+
+ return {
+ clientId: currentScope.clientId,
+ period: periodBounds,
+ periodDayKeys: dayKeys,
+ priorPeriod: { start: period.priorStartDate, end: period.priorEndDate },
+ balance,
+ current: {
+ requestCount: currentScope.currentUser.requestCount,
+ networkFeeUsdMicros: currentScope.currentUser.networkFeeUsdMicros,
+ endUserBillableUsdMicros:
+ currentScope.currentUser.endUserBillableUsdMicros,
+ pipelineModels: mapPipeline(
+ currentScope.currentUser.pipelineModels,
+ dayKeys,
+ dailyByPipeline
+ ),
+ dailyByPipeline,
+ },
+ prior: priorScope
+ ? {
+ requestCount: priorScope.currentUser.requestCount,
+ pipelineModels: mapPipeline(
+ priorScope.currentUser.pipelineModels,
+ utcDateKeysForPeriod(period.priorStartDate, period.priorEndDate),
+ []
+ ),
+ }
+ : {
+ requestCount: 0,
+ pipelineModels: [],
+ },
+ };
+}
+
+export async function fetchAccountRequestsForExternalUser(input: {
+ externalUserId: string;
+ email?: string;
+ cursor?: string | null;
+ limit?: number;
+}): Promise {
+ const publicClientId = readPublicClientId();
+ const accessToken = await mintEndUserAccessToken(
+ input.externalUserId,
+ input.email
+ );
+
+ const url = new URL(`${issuerOriginFromConfig()}/api/v1/user/usage/requests`);
+ if (input.cursor) url.searchParams.set("cursor", input.cursor);
+ if (input.limit != null) url.searchParams.set("limit", String(input.limit));
+
+ const response = await fetch(url.toString(), {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: "application/json",
+ },
+ cache: "no-store",
+ });
+ const raw = await response.text();
+ let body: (AccountRequestsPayload & { error?: string }) | null = null;
+ try {
+ body = raw
+ ? (JSON.parse(raw) as AccountRequestsPayload & { error?: string })
+ : null;
+ } catch {
+ body = null;
+ }
+
+ if (!response.ok) {
+ const notDeployed =
+ response.status === 404
+ ? " End-user usage/requests is not available on this PymtHouse deployment yet."
+ : "";
+ throw new PmtHouseError(
+ (body?.error ?? `Signed-ticket requests failed (${response.status})`) +
+ notDeployed,
+ {
+ status: response.status,
+ code: "pymthouse_http_error",
+ details: body ?? undefined,
+ }
+ );
+ }
+
+ return {
+ items: body?.items ?? [],
+ nextCursor: body?.nextCursor ?? null,
+ openMeterConfigured: body?.openMeterConfigured !== false,
+ clientId: body?.clientId ?? publicClientId,
+ externalUserId: body?.externalUserId ?? input.externalUserId,
+ };
+}
diff --git a/lib/console/signed-ticket-activity.ts b/lib/console/signed-ticket-activity.ts
new file mode 100644
index 0000000..0b9b477
--- /dev/null
+++ b/lib/console/signed-ticket-activity.ts
@@ -0,0 +1,42 @@
+import type { AccountActivityRow, PipelineKind } from "@/lib/console/types";
+import type { SignedTicketRequestRow } from "@/lib/console/account-usage";
+import {
+ humanizePipelineModel,
+ microsToUsdDisplay,
+} from "@/lib/console/usage-capability-display";
+
+const LIVE_PIPELINES = new Set([
+ "video-to-video",
+ "live-video-to-video",
+ "live-transcoding",
+]);
+
+function inferKind(pipeline: string): PipelineKind {
+ return LIVE_PIPELINES.has(pipeline) ? "live" : "batch";
+}
+
+/** Map PymtHouse signed-ticket rows into the /calls table shape. */
+export function mapSignedTicketToActivityRow(
+ row: SignedTicketRequestRow
+): AccountActivityRow {
+ const kind = inferKind(row.pipeline);
+ const model = humanizePipelineModel(row.pipeline, row.modelId);
+ const fee = microsToUsdDisplay(row.networkFeeUsdMicros || "0");
+
+ return {
+ id: row.gatewayRequestId || row.eventId,
+ environmentId: "env-production",
+ timestamp: row.time,
+ model,
+ pipeline: row.pipeline,
+ status: "success",
+ kind,
+ latencyMs: null,
+ durationMs: null,
+ signer: "paymthouse",
+ signerLabel: row.appName?.trim() || "PymtHouse",
+ tokenId: "",
+ tokenName: "",
+ costDisplay: `$${fee}`,
+ };
+}
diff --git a/lib/console/usage-capability-display.ts b/lib/console/usage-capability-display.ts
new file mode 100644
index 0000000..0c6de4e
--- /dev/null
+++ b/lib/console/usage-capability-display.ts
@@ -0,0 +1,175 @@
+import type { AccountUsagePipelineRow } from "@/lib/console/account-usage";
+
+const CAPABILITY_COLORS = [
+ "#4ade80",
+ "#38bdf8",
+ "#a78bfa",
+ "#fb923c",
+ "#f472b6",
+ "#facc15",
+ "#2dd4bf",
+ "#818cf8",
+];
+
+export type UsageCapabilityRow = AccountUsagePipelineRow & {
+ id: string;
+ name: string;
+ color: string;
+ spendUsd: number;
+ data: number[];
+ priorSum: number;
+ delta: number;
+};
+
+export function humanizePipelineModel(
+ pipeline: string,
+ modelId: string
+): string {
+ const normalizedModel =
+ modelId && modelId !== "*" && modelId.toLowerCase() !== "unknown"
+ ? modelId
+ : "";
+ const segment = normalizedModel || pipeline;
+ const raw = segment.includes(":")
+ ? segment.split(":").slice(-1)[0]!
+ : segment;
+ return raw
+ .split(/[-_./|:]+/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+export function microsToUsd(micros: string): number {
+ try {
+ return Number(BigInt(micros)) / 1_000_000;
+ } catch {
+ return 0;
+ }
+}
+
+/** UTC calendar dates (YYYY-MM-DD) from period start through end inclusive. */
+export function utcDateKeysForPeriod(
+ startIso: string,
+ endIso: string
+): string[] {
+ const start = new Date(startIso);
+ const end = new Date(endIso);
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+ return [];
+ }
+ const keys: string[] = [];
+ const cursor = new Date(
+ Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())
+ );
+ const endDay = new Date(
+ Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate())
+ );
+ while (cursor <= endDay) {
+ keys.push(cursor.toISOString().slice(0, 10));
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+ return keys;
+}
+
+export function dailyRequestSeriesForPipeline(input: {
+ pipeline: string;
+ modelId: string;
+ dayKeys: string[];
+ dailyByPipeline: Array<{
+ pipeline: string;
+ modelId: string;
+ date: string;
+ requestCount: number;
+ }>;
+}): number[] {
+ const countsByDay = new Map();
+ const key = `${input.pipeline}|${input.modelId}`;
+ for (const row of input.dailyByPipeline) {
+ if (`${row.pipeline}|${row.modelId}` !== key) continue;
+ countsByDay.set(
+ row.date,
+ (countsByDay.get(row.date) ?? 0) + row.requestCount
+ );
+ }
+ return input.dayKeys.map((day) => countsByDay.get(day) ?? 0);
+}
+
+export function buildUsageCapabilityRows(input: {
+ current: AccountUsagePipelineRow[];
+ prior: AccountUsagePipelineRow[];
+ period: { start: string; end: string };
+ dailyByPipeline?: Array<{
+ pipeline: string;
+ modelId: string;
+ date: string;
+ requestCount: number;
+ }>;
+}): UsageCapabilityRow[] {
+ const priorByKey = new Map(
+ input.prior.map((row) => [`${row.pipeline}|${row.modelId}`, row])
+ );
+ const dayKeys = utcDateKeysForPeriod(input.period.start, input.period.end);
+
+ return input.current
+ .map((row, index) => {
+ const key = `${row.pipeline}|${row.modelId}`;
+ const priorRow = priorByKey.get(key);
+ const priorSum = priorRow?.requestCount ?? 0;
+ const delta =
+ priorSum > 0
+ ? ((row.requestCount - priorSum) / priorSum) * 100
+ : row.requestCount > 0
+ ? 100
+ : 0;
+ const spendUsd = microsToUsd(
+ row.endUserBillableUsdMicros || row.networkFeeUsdMicros
+ );
+ const seriesSum = row.dailyRequests.reduce((a, b) => a + b, 0);
+ const data =
+ row.dailyRequests.length > 0 && seriesSum > 0
+ ? row.dailyRequests
+ : input.dailyByPipeline?.length && dayKeys.length > 0
+ ? dailyRequestSeriesForPipeline({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ dayKeys,
+ dailyByPipeline: input.dailyByPipeline,
+ })
+ : row.dailyRequests;
+ return {
+ ...row,
+ id: key,
+ name: humanizePipelineModel(row.pipeline, row.modelId),
+ color: CAPABILITY_COLORS[index % CAPABILITY_COLORS.length]!,
+ spendUsd,
+ data,
+ priorSum,
+ delta,
+ };
+ })
+ .sort((a, b) => b.requestCount - a.requestCount);
+}
+
+export function microsToUsdDisplay(micros: string): string {
+ const usd = microsToUsd(micros);
+ if (usd >= 100) return usd.toFixed(2);
+ if (usd >= 1) return usd.toFixed(2);
+ if (usd >= 0.01) return usd.toFixed(3);
+ return usd.toFixed(4);
+}
+
+export function formatPeriodResetLabel(periodEndIso: string): string {
+ try {
+ const end = new Date(periodEndIso);
+ const next = new Date(
+ Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1, 1, 0, 0, 0, 0)
+ );
+ return next.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ });
+ } catch {
+ return "next period";
+ }
+}
diff --git a/lib/console/useAccountRequests.ts b/lib/console/useAccountRequests.ts
new file mode 100644
index 0000000..a1e757f
--- /dev/null
+++ b/lib/console/useAccountRequests.ts
@@ -0,0 +1,86 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { AccountRequestsPayload } from "@/lib/console/account-usage";
+import { mapSignedTicketToActivityRow } from "@/lib/console/signed-ticket-activity";
+import type { AccountActivityRow } from "@/lib/console/types";
+
+type AccountRequestsState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | {
+ status: "ready";
+ rows: AccountActivityRow[];
+ nextCursor: string | null;
+ openMeterConfigured: boolean;
+ }
+ | { status: "error"; message: string };
+
+export function useAccountRequests(enabled: boolean) {
+ const [state, setState] = useState({ status: "idle" });
+
+ const load = useCallback(
+ async (cursor?: string | null, append = false) => {
+ if (!enabled) {
+ setState({
+ status: "error",
+ message: "Sign in to load signed-ticket requests.",
+ });
+ return;
+ }
+
+ if (!append) {
+ setState({ status: "loading" });
+ }
+
+ try {
+ const params = new URLSearchParams({ limit: "50" });
+ if (cursor) params.set("cursor", cursor);
+
+ const response = await fetch(
+ `/api/pymthouse/account-requests?${params}`,
+ {
+ cache: "no-store",
+ }
+ );
+ const body = (await response.json()) as AccountRequestsPayload & {
+ error?: string;
+ };
+ if (!response.ok) {
+ throw new Error(
+ body.error ?? `Requests fetch failed (${response.status})`
+ );
+ }
+
+ const mapped = body.items.map(mapSignedTicketToActivityRow);
+ setState((prev) => {
+ const priorRows = append && prev.status === "ready" ? prev.rows : [];
+ return {
+ status: "ready",
+ rows: append ? [...priorRows, ...mapped] : mapped,
+ nextCursor: body.nextCursor,
+ openMeterConfigured: body.openMeterConfigured !== false,
+ };
+ });
+ } catch (error) {
+ setState({
+ status: "error",
+ message:
+ error instanceof Error ? error.message : "Failed to load requests",
+ });
+ }
+ },
+ [enabled]
+ );
+
+ useEffect(() => {
+ void load(null, false);
+ }, [load]);
+
+ const loadMore = useCallback(async () => {
+ if (state.status !== "ready" || !state.nextCursor) return;
+ await load(state.nextCursor, true);
+ }, [load, state]);
+
+ return { ...state, reload: () => load(null, false), loadMore };
+}
diff --git a/lib/console/useAccountUsage.ts b/lib/console/useAccountUsage.ts
new file mode 100644
index 0000000..97b66f0
--- /dev/null
+++ b/lib/console/useAccountUsage.ts
@@ -0,0 +1,92 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { AccountUsagePayload } from "@/lib/console/account-usage";
+import {
+ errorMessageFromBody,
+ isAccountUsagePayload,
+} from "@/lib/console/account-usage-payload";
+
+type AccountUsageState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "ready"; data: AccountUsagePayload }
+ | { status: "error"; message: string };
+
+type UseAccountUsageOptions = {
+ periodDays?: number;
+ window?: "rolling" | "mtd";
+ includePrior?: boolean;
+};
+
+function normalizeOptions(
+ periodDaysOrOptions: number | UseAccountUsageOptions = 30
+): Required<
+ Pick
+> {
+ if (typeof periodDaysOrOptions === "number") {
+ return {
+ periodDays: periodDaysOrOptions,
+ window: "rolling",
+ includePrior: true,
+ };
+ }
+ return {
+ periodDays: periodDaysOrOptions.periodDays ?? 30,
+ window: periodDaysOrOptions.window ?? "rolling",
+ includePrior: periodDaysOrOptions.includePrior !== false,
+ };
+}
+
+export function useAccountUsage(
+ enabled: boolean,
+ periodDaysOrOptions: number | UseAccountUsageOptions = 30
+) {
+ const options = normalizeOptions(periodDaysOrOptions);
+ const [state, setState] = useState({ status: "idle" });
+
+ const load = useCallback(async () => {
+ if (!enabled) {
+ setState({
+ status: "error",
+ message: "Sign in to load usage for your account.",
+ });
+ return;
+ }
+
+ setState({ status: "loading" });
+ try {
+ const params = new URLSearchParams({
+ days: String(options.periodDays),
+ window: options.window,
+ includePrior: options.includePrior ? "1" : "0",
+ });
+ const response = await fetch(`/api/pymthouse/account-usage?${params}`, {
+ cache: "no-store",
+ });
+ const body: unknown = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ errorMessageFromBody(body) ??
+ `Usage fetch failed (${response.status})`
+ );
+ }
+ if (!isAccountUsagePayload(body)) {
+ throw new Error("Usage response was malformed.");
+ }
+ setState({ status: "ready", data: body });
+ } catch (error) {
+ setState({
+ status: "error",
+ message:
+ error instanceof Error ? error.message : "Failed to load usage",
+ });
+ }
+ }, [enabled, options.periodDays, options.window, options.includePrior]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return { ...state, reload: load };
+}
diff --git a/next.config.ts b/next.config.ts
index 84ef56e..4f79207 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,22 +1,6 @@
import type { NextConfig } from "next";
-/** Preview hosts change per deploy; Auth0 needs the request origin, not localhost. */
-function previewAppBaseUrl(): string | undefined {
- if (process.env.VERCEL_ENV !== "preview") return undefined;
- const host = process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL;
- return host ? `https://${host}` : undefined;
-}
-
-const previewBaseUrl = previewAppBaseUrl();
-
const nextConfig: NextConfig = {
- ...(previewBaseUrl
- ? {
- env: {
- APP_BASE_URL: previewBaseUrl,
- },
- }
- : {}),
// Bundler-agnostic polling interval for file watching — works with both
// Turbopack (default in Next 15) and Webpack. Needed because the native
// file watcher doesn't pick up changes reliably in git worktrees.