guest@6admin.io:~$

opencode-watt: what does it cost me to run my local LLM, in watts and euros?

Every dashboard about local LLM usage shows tokens per second and nobody shows the electric bill. The API guys get cost per request; the homelab gets “my GPU exists somewhere on the power curve”. opencode-watt (TypeScript, v0.2.0) is my plugin for OpenCode that answers the question every inference homelab owner eventually asks out loud: what is this model costing me, in watts and in euros?

The problem is the meters, not the math

No LLM gateway exposes power through its API: llama.cpp, Ollama and vLLM report tokens, not joules. The only reliable sources are host-level power meters, so the plugin reads them directly: nvidia-smi and rocm-smi for per-GPU watts, RAPL energy counters from /sys/class/powercap for the CPU package (with Intel’s dram subzone as a separate meter since v0.2.0: on long contexts, the KV cache eats most of the joules), generic hwmon power*_input for whatever reports (amdgpu, nvme) and Prometheus /VictoriaMetrics instant-vector queries for machines that export metrics (a DCGM_FI_DEV_GPU_POWER query is the same meter, wearing a different jacket). A meters option filters by source prefix, for hosts where two meters report the same device (AMD: rocm-smi and the amdgpu hwmon): keep one.

Every probe degrades honestly: an absent binary reports absent, root-only RAPL reports denied, an unreachable SSH host backs off and retries (up to 60 s intervals while no meter answers). And a failing meter no longer sacrifices the whole tick: its probe reports error for that tick while the other meters keep accruing energy. The worst thing an energy monitor can do is invent a reading.

The accounting: integrate, subtract, attribute

The mechanics are an energy meter with a conscience:

  • Integrate while busy: samples every interval_ms (1 s default) while a session is generating, plus a short tail after idle. Intervals are measured between the readings themselves, not between tick starts: a slow nvidia-smi no longer shrinks the integrated interval.
  • Subtract the idle baseline: a second sampling loop (idle_interval_ms, 10 s) measures what the box draws doing nothing and that baseline is subtracted; the baseline is now the median of the idle window, not the mean, so one tick polluted by another process (a compile, an app launch) no longer inflates the floor. A GPU at 25 W idle is not your agent’s cost; billing the idle floor would make every session three euros of guilt. The baseline is also seeded by the first derivable rate: a session starting on an already-busy machine is no longer charged the whole machine until the first idle tick.
  • Attribute when concurrent: several sessions generating at once split the device power equally between them and energy attributed to no session still lands in the daily totals. Deleted sessions leave the busy set (session.deleted), so an abandoned session stops absorbing energy or diluting the others’ share. Nobody pays double, nothing disappears.

Pricing: where the hardware is, not where the chat is

The cost side reads a built-in per-country tariff table (approximate retail averages, early 2026), with an optional override and optional time-of-use windows: the example config prices 22:00-06:00 at the HC rate and cost is accumulated per tick at the resolved rate, so a session spanning a window boundary sums correctly. Remote targets declare their own country: energy is priced where the hardware consumes it, including over SSH (ssh: [{host: "inference-box", country: "FR"}]); SSH probes pass -n -o BatchMode=yes (a passphrase prompt can no longer block a tick) and reuse one connection via ControlMaster (ssh_control, true by default, control_master: false per target to switch off).

The surfaces: a power_cost tool (scope=session|today|all) showing watts per meter, kWh raw and attributed, cost, active rate and probe statuses, with a format: "json" for structured data (meters, rate, baseline, session/day/totals, statuses, budget); a TUI toast at session end when the session burned more than 1 Wh, because feedback has to arrive at the moment of spending; and a daily_budget_kwh that warns once per day via toast when attributed energy crosses the threshold. A wall_factor (default 1, clamped [1, 2]) prices the wall draw on top of measured energy (PSU losses and friends): the kWh stays measured, only the money scales. State is an atomically-written state.json, sessions pruned after 30 days. Under an OpenCode 2.x host there is no server-to-TUI toast channel in the plugin context, so those notifications fall back to console.info; the package serves v1 and v2 from a single entry.

The non-goals are the spec

The README’s non-goals section is the most honest part: NPU power (no standard Linux interface), Windows/macOS probes, fetching spot electricity prices from the network, per-process attribution, carbon intensity. Each one is a place where the measurement would quietly become a guess. watt measures what the kernel and the GPU drivers actually report; the moment I fake a number, the whole plugin becomes a vibe.

In watts and euros

In the end the lesson fits in two lines. Gateways expose tokens, only the host exposes joules, the meter stack has to live below the inference stack. The idle floor gets subtracted or the number becomes propaganda, pricing is geography, the kWh is priced where the hardware sits. A fabricated watt is a bug wearing a unit, an honest status beats a plausible invention, the kWh stays measured and only the money scales.