Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/cloud/community-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { adminCall } from '@/cloud/account-requests';

/**
* The steward's community plan utilization view — who's building on the
* subsidized plan and what it's costing, today and all-time. Usage rows are
* subsidized plan, what it's costing, and roughly what it's drawing in
* energy, today and all-time. Usage rows are
* RLS-locked to each member, so the cross-member read (and the per-model
* pricing, kept in lockstep with the community-monitor alerts) lives in the
* admin-requests function; the client just asks.
Expand All @@ -12,6 +13,13 @@ export interface UsageTotals {
requests: number;
tokens: number;
usd: number;
/**
* Estimated energy in watt-hours — the CENTRAL estimate of a band that
* spans roughly twentyfold. Never render it as a bare number: pass it
* through `energyBand` / `kitchenEquivalent` in `@/lib/energy`, which
* carry the reasoning and the honest range.
*/
wh: number;
}

export interface MemberUsage {
Expand All @@ -31,8 +39,8 @@ export interface CommunityUsageReport {
/** Every member with any recorded usage, all-time cost descending */
members: MemberUsage[];
totals: { today: UsageTotals; all_time: UsageTotals };
/** The last 14 days' community-wide tokens and cost, oldest first */
recent_days: { day: string; tokens: number; usd: number }[];
/** The last 14 days' community-wide tokens, cost and energy, oldest first */
recent_days: { day: string; tokens: number; usd: number; wh: number }[];
}

export async function adminCommunityUsage(): Promise<CommunityUsageReport> {
Expand Down
45 changes: 44 additions & 1 deletion src/components/StewardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type CommunityUsageReport,
type MemberUsage,
} from '@/cloud/community-usage';
import { energyBand, formatWh, kitchenEquivalent } from '@/lib/energy';
import { listAllStudios, DEFAULT_STUDIO_SLUG, type StudioContext } from '@/knowledge/studio-context';
import {
fetchStudioAccessMap,
Expand Down Expand Up @@ -210,6 +211,17 @@ function fmtUsd(n: number): string {
return n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`;
}

/**
* The honest range behind a central estimate — "~24 Wh–480 Wh". Small
* amounts collapse to "under 9.5 Wh": a low end below a watt-hour renders
* as "<1 Wh", and "~<1 Wh–9.5 Wh" is not a thing anyone can read.
*/
function fmtWhRange(wh: number): string {
const { low, high } = energyBand(wh);
if (low < 1) return `under ${formatWh(high)}`;
return `~${formatWh(low)}\u2013${formatWh(high)}`;
}

/** "claude-fable-5 $6.10 · claude-opus-5 $3.20" → "fable-5 $6.10 · opus-5 $3.20" */
function modelMix(models: MemberUsage['models']): string {
return models
Expand All @@ -219,6 +231,22 @@ function modelMix(models: MemberUsage['models']): string {
.join(' · ');
}

/**
* Energy under a headline number: the band, then the household comparison.
* Never the central estimate on its own — a single figure would read as a
* measurement, and this isn't one.
*/
function EnergyLine({ wh }: { wh: number }) {
if (!(wh > 0)) return null;
const equivalent = kitchenEquivalent(wh);
return (
<p className="text-xs text-muted-foreground/80 tabular-nums mt-0.5">
{fmtWhRange(wh)}
{equivalent && <span className="normal-nums"> · {equivalent}</span>}
</p>
);
}

function UsageTab() {
const [report, setReport] = useState<CommunityUsageReport | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -264,6 +292,13 @@ function UsageTab() {
the same rates as the monitor's alerts (cache traffic included). Days
roll over at midnight UTC.
</p>
<p className="text-xs text-muted-foreground">
Energy is a rough estimate, not a measurement — no lab publishes
per-token figures, so the real value sits somewhere in the range
shown, which spans about twentyfold. Treat it as an order of
magnitude for our own sense of scale. Builders using their own API
key aren't counted here at all.
</p>

{/* The headline numbers */}
<div className="grid grid-cols-2 gap-2">
Expand All @@ -274,13 +309,15 @@ function UsageTab() {
{fmtTokens(report.totals.today.tokens)} tokens ·{' '}
{report.members.filter(m => m.today.tokens > 0).length} building
</p>
<EnergyLine wh={report.totals.today.wh} />
</div>
<div className="rounded-lg border px-3 py-2.5">
<p className="text-xs uppercase tracking-wide text-muted-foreground">All time</p>
<p className="text-xl font-semibold tabular-nums">{fmtUsd(report.totals.all_time.usd)}</p>
<p className="text-xs text-muted-foreground tabular-nums">
{fmtTokens(report.totals.all_time.tokens)} tokens · {report.members.length} {report.members.length === 1 ? 'builder' : 'builders'}
</p>
<EnergyLine wh={report.totals.all_time.wh} />
</div>
</div>

Expand All @@ -293,7 +330,7 @@ function UsageTab() {
key={d.day}
className="flex-1 rounded-sm bg-primary/70 min-h-[2px]"
style={{ height: `${Math.max(4, (d.usd / maxDayUsd) * 100)}%` }}
title={`${d.day}: ${fmtUsd(d.usd)} · ${fmtTokens(d.tokens)} tokens`}
title={`${d.day}: ${fmtUsd(d.usd)} · ${fmtTokens(d.tokens)} tokens · ${fmtWhRange(d.wh)}`}
/>
))}
</div>
Expand Down Expand Up @@ -353,6 +390,12 @@ function UsageTab() {
{budgetShare !== null && ` · ${budgetShare}% of today's budget`}
{view === 'all' && m.models.length > 0 && ` — ${modelMix(m.models)}`}
</p>
{t.wh > 0 && (
<p className="text-xs text-muted-foreground/80 truncate tabular-nums">
{fmtWhRange(t.wh)}
{kitchenEquivalent(t.wh) && ` · ${kitchenEquivalent(t.wh)}`}
</p>
)}
</div>
<Badge variant="outline" className="shrink-0 tabular-nums">
{fmtUsd(t.usd)}
Expand Down
92 changes: 92 additions & 0 deletions src/lib/energy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Turning token counts into watt-hours, and watt-hours into something a
* person can picture.
*
* Two things this module is careful about, because the numbers invite
* overclaiming in both directions:
*
* 1. **The token counts are exact; the conversion is not.** No lab publishes
* per-token energy for a specific served model, so the coefficient is
* inferred two ways that disagree by roughly twentyfold — from hardware
* (~2 FLOPs per active parameter per token at realistic sustained
* throughput, plus datacenter overhead) it lands near 0.04–0.15 mWh per
* output token; from the per-prompt figures labs have published (Google's
* measured Gemini Apps median, the widely-quoted ChatGPT figure) it lands
* nearer 0.8–1.1 mWh, because those include idle capacity and the whole
* serving stack. So every figure here travels as a BAND. Anything that
* renders a single confident number is inventing precision we don't have.
*
* 2. **Comparisons have to sit at the right order of magnitude.** Vehicle
* equivalents were the first instinct and they don't work: a whole
* project comes out around 376 feet of driving, which reads as "this is
* nothing" — and inflating it to avoid that would be a lie. Household
* appliances land in the same range as the real number, so the comparison
* informs instead of either alarming or dismissing.
*/

/**
* The band, as multipliers on the central estimate the server computes.
* Central is 0.2 mWh per output-equivalent token; the ends are 0.05 and 1.0.
* Kept as multipliers so only one number crosses the wire and the band can
* never drift out of step with it.
*/
export const ENERGY_BAND = { low: 0.25, high: 5 } as const;

export function energyBand(wh: number): { low: number; high: number } {
return { low: wh * ENERGY_BAND.low, high: wh * ENERGY_BAND.high };
}

/** "840 Wh" · "2.4 kWh" · "18 kWh" — watt-hours at a glance */
export function formatWh(wh: number): string {
if (wh >= 10_000) return `${Math.round(wh / 1000)} kWh`;
if (wh >= 1_000) return `${(wh / 1000).toFixed(1)} kWh`;
if (wh >= 100) return `${Math.round(wh)} Wh`;
if (wh >= 1) return `${wh.toFixed(1)} Wh`;
return wh > 0 ? '<1 Wh' : '0 Wh';
}

/**
* Household anchors, smallest first. Conventional figures: a phone battery
* is ~12.7 Wh, boiling a litre takes ~100 Wh, a washing machine cycle ~500
* Wh, a fridge ~1.2 kWh a day.
*
* Deliberately all domestic and all electrical — the point is a reader
* recognising the scale from their own kitchen, not a precise conversion.
*/
const ANCHORS: readonly { wh: number; one: string; many: string }[] = [
{ wh: 12.7, one: 'phone charge', many: 'phone charges' },
{ wh: 100, one: 'kettle boiled', many: 'kettles boiled' },
{ wh: 500, one: 'load of laundry', many: 'loads of laundry' },
{ wh: 1_200, one: 'day of running a fridge', many: 'days of running a fridge' },
{ wh: 36_000, one: 'month of running a fridge', many: 'months of running a fridge' },
];

/**
* The most legible household comparison for an amount of energy — e.g.
* "about 3 kettles boiled". Picks the largest anchor the amount roughly
* reaches, so counts stay in the range a person can hold in their head
* instead of reading "1,700 phone charges".
*/
export function kitchenEquivalent(wh: number): string | null {
if (!(wh > 0)) return null;

// The largest anchor this amount essentially reaches. 0.75 rather than 1
// so ~96 Wh reads as "about 1 kettle" instead of "7.6 phone charges" —
// nearer the truth of what it feels like.
let anchor = ANCHORS[0];
for (const candidate of ANCHORS) {
if (wh >= candidate.wh * 0.75) anchor = candidate;
}

const count = wh / anchor.wh;
// Only the smallest anchor can come out under its own threshold, and
// "about 0 phone charges" is worse than saying so plainly
if (count < 0.75) return `less than a ${anchor.one}`;

const rounded =
count >= 10 ? Math.round(count) : Number(count.toFixed(1));
// "1.0 kettles boiled" reads worse than "1 kettle boiled"
const label = Math.abs(rounded - 1) < 0.05 ? anchor.one : anchor.many;
const shown = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
return `about ${shown} ${label}`;
}
88 changes: 80 additions & 8 deletions supabase/functions/admin-requests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,53 @@ function priceUsage(t: UsageTokens, r: typeof USAGE_DEFAULT_RATES): number {
);
}

// --- Energy: watt-hours per token, the same shape as the rates above ---
//
// Central estimate for an Opus-class model, in Wh per token. Output
// dominates because generating is sequential — one forward pass per token,
// memory-bandwidth-bound. Prefill processes the whole input in parallel and
// is far cheaper per token, and a cache read skips recomputation almost
// entirely; the weights (1 / 0.15 / 0.15 / 0.05 of the output rate) mirror
// that, the way the price weights above mirror billing.
//
// The absolute figure is INFERRED, not published: from hardware it lands
// near 0.04-0.15 mWh per output token, and from labs' per-prompt figures
// nearer 0.8-1.1 mWh. So 0.2 mWh is a central estimate inside a band of
// roughly twentyfold, and the client renders it as a band (see
// src/lib/energy.ts, which holds the multipliers). Revise the number here
// and every view follows; the token counts it multiplies are exact and
// unaffected.
const ENERGY_WH_PER_TOKEN = {
input: 3e-5,
output: 2e-4,
cacheWrite: 3e-5,
cacheRead: 1e-5,
};

/**
* How much more (or less) energy a model costs per token than the
* Opus-class default, taken from its output price relative to Opus.
*
* Price is a proxy for serving compute, not a measurement of it — it also
* carries margin and positioning. But treating Haiku and Fable as identical
* would be more wrong than this is, and the ordering (haiku < sonnet < opus
* < fable) certainly matches. The scalar moves things by 0.2x-2x, well
* inside the band, so it never carries more weight than it can bear.
*/
function energyScaleFor(model: string): number {
return usageRatesFor(model).output / USAGE_DEFAULT_RATES.output;
}

function energyUsage(t: UsageTokens, scale: number): number {
return (
scale *
(t.input * ENERGY_WH_PER_TOKEN.input +
t.output * ENERGY_WH_PER_TOKEN.output +
t.cacheWrite * ENERGY_WH_PER_TOKEN.cacheWrite +
t.cacheRead * ENERGY_WH_PER_TOKEN.cacheRead)
);
}

function usageCounts(row: Record<string, unknown>): UsageTokens {
return {
input: Number(row.input_tokens ?? 0),
Expand Down Expand Up @@ -572,20 +619,23 @@ Deno.serve(async (req: Request) => {
requests: number;
tokens: number;
usd: number;
/** Estimated watt-hours — central estimate; the client renders a band */
wh: number;
}
const blank = (): Acc => ({ requests: 0, tokens: 0, usd: 0 });
const blank = (): Acc => ({ requests: 0, tokens: 0, usd: 0, wh: 0 });
const members = new Map<
string,
{ today: Acc; all_time: Acc; days: Set<string>; models: Map<string, number> }
>();
const byDay = new Map<string, { tokens: number; usd: number }>();
const byDay = new Map<string, { tokens: number; usd: number; wh: number }>();

for (const row of usageRows) {
const email = String(row.email ?? '').toLowerCase();
const day = String(row.day ?? '');
const total = usageCounts(row);
const residual = { ...total };
let usd = 0;
let wh = 0;
const entry =
members.get(email) ??
{ today: blank(), all_time: blank(), days: new Set<string>(), models: new Map<string, number>() };
Expand All @@ -598,11 +648,15 @@ Deno.serve(async (req: Request) => {
const model = String(m.model ?? '');
const modelUsd = priceUsage(t, usageRatesFor(model));
usd += modelUsd;
// Energy scales per model for the same reason cost does — a day
// spent on Haiku shouldn't read like a day spent on Fable
wh += energyUsage(t, energyScaleFor(model));
entry.models.set(model, (entry.models.get(model) ?? 0) + modelUsd);
}
if (totalTokens(residual) > 0) {
const residualUsd = priceUsage(residual, USAGE_DEFAULT_RATES);
usd += residualUsd;
wh += energyUsage(residual, 1);
entry.models.set('untracked', (entry.models.get('untracked') ?? 0) + residualUsd);
}

Expand All @@ -611,17 +665,20 @@ Deno.serve(async (req: Request) => {
entry.all_time.requests += requests;
entry.all_time.tokens += tokens;
entry.all_time.usd += usd;
entry.all_time.wh += wh;
entry.days.add(day);
if (day === today) {
entry.today.requests += requests;
entry.today.tokens += tokens;
entry.today.usd += usd;
entry.today.wh += wh;
}
members.set(email, entry);

const d = byDay.get(day) ?? { tokens: 0, usd: 0 };
const d = byDay.get(day) ?? { tokens: 0, usd: 0, wh: 0 };
d.tokens += tokens;
d.usd += usd;
d.wh += wh;
byDay.set(day, d);
}

Expand All @@ -631,27 +688,42 @@ Deno.serve(async (req: Request) => {
email,
name: names.get(email) ?? null,
daily_budget: budgets.get(email) ?? null,
today: { ...m.today, usd: round(m.today.usd) },
all_time: { ...m.all_time, usd: round(m.all_time.usd), days_active: m.days.size },
today: { ...m.today, usd: round(m.today.usd), wh: round(m.today.wh) },
all_time: {
...m.all_time,
usd: round(m.all_time.usd),
wh: round(m.all_time.wh),
days_active: m.days.size,
},
models: [...m.models.entries()]
.map(([model, usd]) => ({ model, usd: round(usd) }))
.sort((a, b) => b.usd - a.usd),
}))
.sort((a, b) => b.all_time.usd - a.all_time.usd);

// The last 14 days, zeros filled, oldest first — the daily pulse
const recentDays: { day: string; tokens: number; usd: number }[] = [];
const recentDays: { day: string; tokens: number; usd: number; wh: number }[] = [];
for (let i = 13; i >= 0; i--) {
const day = new Date(Date.now() - i * 86400_000).toISOString().slice(0, 10);
const d = byDay.get(day);
recentDays.push({ day, tokens: d?.tokens ?? 0, usd: round(d?.usd ?? 0) });
recentDays.push({
day,
tokens: d?.tokens ?? 0,
usd: round(d?.usd ?? 0),
wh: round(d?.wh ?? 0),
});
}

const sum = (pick: (m: (typeof memberList)[number]) => Acc): Acc =>
memberList.reduce(
(acc, m) => {
const t = pick(m);
return { requests: acc.requests + t.requests, tokens: acc.tokens + t.tokens, usd: round(acc.usd + t.usd) };
return {
requests: acc.requests + t.requests,
tokens: acc.tokens + t.tokens,
usd: round(acc.usd + t.usd),
wh: round(acc.wh + t.wh),
};
},
blank(),
);
Expand Down