diff --git a/src/cloud/community-usage.ts b/src/cloud/community-usage.ts
index 4075239..47f1356 100644
--- a/src/cloud/community-usage.ts
+++ b/src/cloud/community-usage.ts
@@ -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.
@@ -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 {
@@ -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 {
diff --git a/src/components/StewardPage.tsx b/src/components/StewardPage.tsx
index e2fd156..8f3c12c 100644
--- a/src/components/StewardPage.tsx
+++ b/src/components/StewardPage.tsx
@@ -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,
@@ -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
@@ -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 (
+
+ {fmtWhRange(wh)}
+ {equivalent && · {equivalent}}
+
+ );
+}
+
function UsageTab() {
const [report, setReport] = useState(null);
const [loading, setLoading] = useState(true);
@@ -264,6 +292,13 @@ function UsageTab() {
the same rates as the monitor's alerts (cache traffic included). Days
roll over at midnight UTC.
+
+ 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.
+
{/* The headline numbers */}
@@ -274,6 +309,7 @@ function UsageTab() {
{fmtTokens(report.totals.today.tokens)} tokens ·{' '}
{report.members.filter(m => m.today.tokens > 0).length} building
+
All time
@@ -281,6 +317,7 @@ function UsageTab() {
{fmtTokens(report.totals.all_time.tokens)} tokens · {report.members.length} {report.members.length === 1 ? 'builder' : 'builders'}
+
@@ -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)}`}
/>
))}
@@ -353,6 +390,12 @@ function UsageTab() {
{budgetShare !== null && ` · ${budgetShare}% of today's budget`}
{view === 'all' && m.models.length > 0 && ` — ${modelMix(m.models)}`}
+ {t.wh > 0 && (
+
+ {fmtWhRange(t.wh)}
+ {kitchenEquivalent(t.wh) && ` · ${kitchenEquivalent(t.wh)}`}
+
+ )}
{fmtUsd(t.usd)}
diff --git a/src/lib/energy.ts b/src/lib/energy.ts
new file mode 100644
index 0000000..98ccd8b
--- /dev/null
+++ b/src/lib/energy.ts
@@ -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}`;
+}
diff --git a/supabase/functions/admin-requests/index.ts b/supabase/functions/admin-requests/index.ts
index 624af8e..19429c9 100644
--- a/supabase/functions/admin-requests/index.ts
+++ b/supabase/functions/admin-requests/index.ts
@@ -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): UsageTokens {
return {
input: Number(row.input_tokens ?? 0),
@@ -572,13 +619,15 @@ 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; models: Map }
>();
- const byDay = new Map();
+ const byDay = new Map();
for (const row of usageRows) {
const email = String(row.email ?? '').toLowerCase();
@@ -586,6 +635,7 @@ Deno.serve(async (req: Request) => {
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(), models: new Map() };
@@ -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);
}
@@ -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);
}
@@ -631,8 +688,13 @@ 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),
@@ -640,18 +702,28 @@ Deno.serve(async (req: Request) => {
.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(),
);