The page reported numbers but never analysed them: six near-identical stat tiles, three raw histograms, and bar charts with no interpretation — and the environmental cost lived on a separate page, so the two prices of a conversation were never seen together. It is now organised as questions. Each panel states its question, answers it in a sentence computed from the data, and puts the chart underneath as evidence, with a table twin behind a toggle. - new lib/costAnalysis.ts: pure aggregations carrying both currencies — token-type flow (cost reconstructed from the backend's own pricing rules, normalised to the recorded total), per-activity carbon via each conversation's cache mix, model mix with carbon intensity, Lorenz concentration, lifecycle outcome, rolling-window deltas, plan pro-rating - new dashboards/components/viz.tsx: chart primitives with the house rules baked in (one measure per axis, 2px surface gaps, 4px data-ends, hover read-out, table twin, colour by entity) - headline pairs equivalent API spend with kg eqCO2 and four per-unit rates - the signature panel shows tokens / dollars / eqCO2 as three 100% bars: cache reads are 97% of tokens and 56% of the bill but 35% of the carbon, output is 0.6% of tokens and 54% of the carbon - trend is three small multiples (spend, eqCO2, carbon intensity) rather than one dual-axis plot - deltas are limited to rolling ranges; comparing 'since subscription' to the window before it only measured when the history started - fix: footprint sig() stripped an integer's own trailing zeros, printing 110 kg eqCO2 as '11 kg' and 1000 as '1'
196 lines
7.3 KiB
TypeScript
196 lines
7.3 KiB
TypeScript
/**
|
||
* Environmental footprint of a Claude conversation — carbon (CO2e), water and
|
||
* energy estimated from its token usage.
|
||
*
|
||
* These are **order-of-magnitude estimates, not measurements**. Anthropic
|
||
* publishes no per-model inference figures, so the coefficients below are the
|
||
* open-source `claude-carbon` factors (github.com/gwittebolle/claude-carbon),
|
||
* which fit the peer-reviewed Jegham et al. 2025 study "How Hungry is AI?
|
||
* Benchmarking Energy, Water, and Carbon Footprint of LLM Inference"
|
||
* (arxiv.org/abs/2505.09598) to Claude on AWS.
|
||
*
|
||
* Methodology, all constants sourced from those two references:
|
||
* - Per-family gCO2e per **million** tokens, split input vs output. Sonnet is
|
||
* the 3-point fit; Opus ≈ 2× Sonnet, Haiku ≈ 0.5× Sonnet (claude-carbon's
|
||
* own extrapolation). Cache reads count as 0.08× an input token.
|
||
* - Grid carbon intensity 0.287 kgCO2e/kWh (claude-carbon, AWS location-based),
|
||
* which lets us back energy (kWh) out of the CO2e figure.
|
||
* - Water from the paper's total WUE — 0.18 L/kWh on-site cooling + 3.142 L/kWh
|
||
* off-site (electricity generation) = 3.322 L/kWh — applied to that energy.
|
||
*
|
||
* Only Claude is modelled; a non-Claude model returns null (no card shown).
|
||
*/
|
||
|
||
/** gCO2e per million tokens, per Claude family. */
|
||
type Factor = { input: number; output: number };
|
||
|
||
const FACTORS: Record<string, Factor> = {
|
||
// 3-point fit to Jegham v6 (claude-carbon data/factors.json).
|
||
sonnet: { input: 39, output: 826 },
|
||
// Extrapolated by claude-carbon: Opus 2× Sonnet, Haiku 0.5× Sonnet.
|
||
opus: { input: 78, output: 1652 },
|
||
haiku: { input: 20, output: 413 },
|
||
// Fable has no published figure; treat it like Haiku (small/fast tier).
|
||
fable: { input: 20, output: 413 },
|
||
};
|
||
|
||
/** A cache-read token costs ~8% of a fresh input token (claude-carbon). */
|
||
const CACHE_READ_FACTOR = 0.08;
|
||
|
||
/** Grid carbon intensity, kgCO2e per kWh (claude-carbon, AWS location-based). */
|
||
const CARBON_INTENSITY_KG_PER_KWH = 0.287;
|
||
|
||
/** Total water-usage effectiveness, L per kWh (Jegham 2025: 0.18 on-site +
|
||
* 3.142 off-site generation). */
|
||
const WUE_L_PER_KWH = 0.18 + 3.142;
|
||
|
||
export interface Footprint {
|
||
/** kg CO2-equivalent. */
|
||
co2eKg: number;
|
||
/** litres of water. */
|
||
waterL: number;
|
||
/** kWh of electricity. */
|
||
energyKwh: number;
|
||
/** the Claude family the factors came from (for the caption). */
|
||
family: string;
|
||
}
|
||
|
||
/** Token counts we can attribute an impact to. Mirrors {@link Usage}. */
|
||
export interface FootprintUsage {
|
||
input: number;
|
||
output: number;
|
||
cacheRead: number;
|
||
/** cache **writes** — billed like input tokens, so counted as input here. */
|
||
cacheWriteTokens: number;
|
||
}
|
||
|
||
/**
|
||
* Estimate the footprint of a conversation from its aggregate token usage and
|
||
* the Claude family it mostly ran on. Returns null for a non-Claude model — the
|
||
* factors are Claude-specific and we don't guess for other providers.
|
||
*
|
||
* A conversation can mix models; we attribute the whole token bill to the
|
||
* dominant family (`models[0]`, busiest-first, else `model`). That's the same
|
||
* simplification the source tools make for a per-conversation estimate.
|
||
*/
|
||
export function estimateFootprint(
|
||
usage: FootprintUsage,
|
||
family: string | undefined,
|
||
): Footprint | null {
|
||
const f = family ? FACTORS[family] : undefined;
|
||
if (!f) return null;
|
||
|
||
// Effective input tokens: fresh input + cache writes (billed as input) at
|
||
// full weight, cache reads at the discounted weight.
|
||
const inputTok = usage.input + usage.cacheWriteTokens;
|
||
const cacheTok = usage.cacheRead * CACHE_READ_FACTOR;
|
||
|
||
// gCO2e = (tokens / 1e6) * gCO2e-per-Mtok, summed over input & output.
|
||
const grams =
|
||
((inputTok + cacheTok) * f.input + usage.output * f.output) / 1e6;
|
||
const co2eKg = grams / 1000;
|
||
|
||
const energyKwh = co2eKg / CARBON_INTENSITY_KG_PER_KWH;
|
||
const waterL = energyKwh * WUE_L_PER_KWH;
|
||
|
||
return { co2eKg, waterL, energyKwh, family: family as string };
|
||
}
|
||
|
||
/** A zero footprint — the identity for {@link addFootprint}. */
|
||
export const EMPTY_FOOTPRINT: Footprint = {
|
||
co2eKg: 0,
|
||
waterL: 0,
|
||
energyKwh: 0,
|
||
family: "",
|
||
};
|
||
|
||
/** Accumulate a footprint into a running total (family is not summable, so the
|
||
* accumulator keeps "" — callers track the model split separately). */
|
||
export function addFootprint(acc: Footprint, fp: Footprint | null): Footprint {
|
||
if (!fp) return acc;
|
||
return {
|
||
co2eKg: acc.co2eKg + fp.co2eKg,
|
||
waterL: acc.waterL + fp.waterL,
|
||
energyKwh: acc.energyKwh + fp.energyKwh,
|
||
family: acc.family,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Everyday equivalences for a mass of CO2e, so an abstract "g CO2e" lands as
|
||
* something tangible. Coefficients are round public figures (EEA / EPA order of
|
||
* magnitude): a petrol car ≈ 120 gCO2e/km, a smartphone charge ≈ 8 gCO2e, a
|
||
* beef burger ≈ 3 kgCO2e. Returned as {value, label} for whichever reads best.
|
||
*/
|
||
export function co2Equivalents(kg: number): { value: string; label: string }[] {
|
||
const g = kg * 1000;
|
||
return [
|
||
{ value: fmtSig(g / 120), label: "km by car" },
|
||
{ value: fmtSig(g / 8), label: "phone charges" },
|
||
{ value: fmtSig(kg / 3), label: "beef burgers" },
|
||
];
|
||
}
|
||
|
||
/** Everyday equivalences for a volume of water. A standard bathtub ≈ 150 L, a
|
||
* glass ≈ 0.25 L. */
|
||
export function waterEquivalents(litres: number): { value: string; label: string }[] {
|
||
return [
|
||
{ value: fmtSig(litres / 0.25), label: "glasses of water" },
|
||
{ value: fmtSig(litres / 150), label: "bathtubs" },
|
||
];
|
||
}
|
||
|
||
/** A plain significant-figure number (no unit), for equivalence counts. */
|
||
function fmtSig(n: number): string {
|
||
if (!n || n < 0) return "0";
|
||
if (n >= 1000) return Math.round(n).toLocaleString("en-US");
|
||
return sig(n);
|
||
}
|
||
|
||
/**
|
||
* Format a mass of CO2-equivalent with a scientific SI prefix on the `eqCO2`
|
||
* unit: t eqCO2 (tonnes) → kg eqCO2 → g eqCO2 → mg eqCO2. Picks the largest
|
||
* prefix that keeps the number ≥ 1.
|
||
*
|
||
* 1.2 → "1.2 t eqCO2" (tonnes)
|
||
* 0.004 → "4 kg eqCO2"
|
||
* 3.1e-6 (kg) → "3.1 mg eqCO2"
|
||
*/
|
||
export function fmtCo2e(kg: number): string {
|
||
if (!kg || kg < 0) return "0 g eqCO2";
|
||
const tonnes = kg / 1000;
|
||
if (tonnes >= 1) return `${sig(tonnes)} t eqCO2`;
|
||
if (kg >= 1) return `${sig(kg)} kg eqCO2`;
|
||
const g = kg * 1000;
|
||
if (g >= 1) return `${sig(g)} g eqCO2`;
|
||
const mg = g * 1000;
|
||
return `${sig(mg)} mg eqCO2`;
|
||
}
|
||
|
||
/** Format a volume of water with a scientific SI prefix: m³ → L → mL. */
|
||
export function fmtWater(litres: number): string {
|
||
if (!litres || litres < 0) return "0 mL";
|
||
if (litres >= 1000) return `${sig(litres / 1000)} m³`;
|
||
if (litres >= 1) return `${sig(litres)} L`;
|
||
return `${sig(litres * 1000)} mL`;
|
||
}
|
||
|
||
/** Format energy with a scientific SI prefix: MWh → kWh → Wh. */
|
||
export function fmtEnergy(kwh: number): string {
|
||
if (!kwh || kwh < 0) return "0 Wh";
|
||
if (kwh >= 1000) return `${sig(kwh / 1000)} MWh`;
|
||
if (kwh >= 1) return `${sig(kwh)} kWh`;
|
||
return `${sig(kwh * 1000)} Wh`;
|
||
}
|
||
|
||
/**
|
||
* 2–3 significant figures, no trailing *fractional* zeros: 1.20 → "1.2",
|
||
* 12.34 → "12.3", 110 → "110". The zero-stripping only applies past a decimal
|
||
* point — without that guard it ate the integer's own zeros, rendering
|
||
* 110 kg eqCO2 as "11 kg" and 1000 as "1".
|
||
*/
|
||
function sig(n: number): string {
|
||
const s = n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2);
|
||
return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
|
||
}
|