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(), );