Files
ai-agent/frontend/src/lib/footprint.ts
Gabriel Vidal 5ea601fb6b feat(dashboards): rework the conversations dashboard as a cost & footprint analysis
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'
2026-08-09 18:52:30 +02:00

196 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)}`;
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`;
}
/**
* 23 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;
}