diff --git a/README.md b/README.md index 283ac0b..573983c 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,17 @@ They follow the energy rates, so they stay `null` until those are measured. The figures cover the GPU's own sensor, not the rest of the machine, so they are a lower bound. An unknown or malformed argument stops the plugin at startup. +Sellers price in msat per million tokens. Add the price of one bitcoin, in the +same currency as the electricity, and both costs are also given in msat: + +```toml +args = ["--electricity-price-per-kwh", "0.25", "--currency", "EUR", "--btc-price", "95000"] +``` + +`data` then also carries `btc_price`, `cost_msat_per_1m_output_tokens` and +`cost_msat_per_1m_output_tokens_total`. The plugin asks no outside service for +the rate, so update it yourself when it moves. + ## Energy page The plugin adds an Energy page to the node's console. It shows the same @@ -138,6 +149,18 @@ energy rates, the costs when a price is set, and the board power of the last 10 minutes against the idle draw. The page reads `/api/status` like any other client and keeps nothing. +For each model the node serves, the page also sets the node's own output price +against its cost, from the `payment` block of `/v1/models` read every 30 s: + +- **Above cost**: the price covers the cost with the idle draw included; +- **Pays only if the machine runs anyway**: above the cost on top of idle, below + the cost with idle included; +- **Below cost**: every token loses money. + +It shows the cheapest other seller of the same model next to it. The cost is +measured across every model the node serves, not per model, and the +comparison needs `--btc-price`; without it the prices show in msat only. + `plugin-manifest.json` declares the page to the installer and `bundle/energy.js` draws it. The manifest is generated from the binary, and a test fails when the two differ: diff --git a/bundle/energy.js b/bundle/energy.js index dcbeb3a..ccd1dda 100644 --- a/bundle/energy.js +++ b/bundle/energy.js @@ -1,12 +1,16 @@ // The console's Energy page: the snapshot this plugin publishes under // `plugins.powermetrics-plugin` in `/api/status`, read every 5 seconds, plus -// the board power of the last minutes as an SVG line. +// the board power of the last minutes as an SVG line, and the node's own +// prices from `/v1/models` against what serving costs it. const PLUGIN = "powermetrics-plugin"; const POLL_MS = 5000; +// Prices change rarely: the model listing is read every 30 seconds. +const PRICES_MS = 30000; // Ten minutes of readings at one per poll. const HISTORY = 120; const SVG_NS = "http://www.w3.org/2000/svg"; +const MSAT_PER_BTC = 1e11; // The host imports this module and calls registerMeshPluginUi(host). const moduleRegistration = { @@ -45,6 +49,49 @@ function money(value, currency) { return `${significant(value)}${currency ? ` ${currency}` : ""}`; } +function formatMsat(value) { + if (value >= 1e6) return `${significant(value / 1e6)}M msat`; + if (value >= 1e3) return `${significant(value / 1e3)}k msat`; + return `${significant(value)} msat`; +} + +// The `payment` block of `/v1/models`, which the console's chat reads too: +// for each model this node serves, its own output price in msat per million +// tokens (null when it serves the model free) and the cheapest other seller. +function ownPrices(body) { + const rows = []; + for (const item of Array.isArray(body?.data) ? body.data : []) { + const offers = Array.isArray(item?.payment?.offers) ? item.payment.offers : []; + const own = offers.find((offer) => offer?.local === true); + if (typeof item?.id !== "string" || !own) continue; + const rate = (offer) => (offer?.paid === true ? offer.pricing?.output_msat_per_million : undefined); + const others = offers + .filter((offer) => offer?.local !== true) + .map(rate) + .filter((value) => typeof value === "number"); + rows.push({ + model: item.id, + price: typeof rate(own) === "number" ? rate(own) : null, + market: others.length > 0 ? Math.min(...others) : null, + }); + } + return rows; +} + +// A price against the two costs in msat: below the cost on top of idle loses +// money on every token; below the idle-included cost pays only when the +// machine runs anyway. +function verdict(price, data, tokens) { + if (price === null) return { text: "Free", color: tokens.foreground }; + if (typeof data?.btc_price !== "number") return { text: "Set --btc-price to compare", color: tokens.foreground }; + const cost = data.cost_msat_per_1m_output_tokens; + const total = data.cost_msat_per_1m_output_tokens_total; + if (cost == null) return { text: "Cost known after a request", color: tokens.foreground }; + if (price < cost) return { text: "Below cost", color: tokens.bad }; + if (total != null && price < total) return { text: "Pays only if the machine runs anyway", color: tokens.warn }; + return { text: "Above cost", color: tokens.good }; +} + function duration(seconds) { if (seconds < 90) return `${Math.round(seconds)} s`; if (seconds < 5400) return `${Math.round(seconds / 60)} min`; @@ -136,10 +183,68 @@ function mountEnergyPage({ element, host }) { const chartScale = el("p", { color: tokens.foreground, fontSize: "0.75rem", margin: "0", opacity: "0.7" }, ""); chartPanel.append(chartTitle, svg, chartScale); - element.append(status, grid, chartPanel); + const pricePanel = el("section", { + background: tokens.panelStrong, + border: `1px solid ${tokens.border}`, + borderRadius: tokens.radiusLarge, + display: "none", + gap: "8px", + padding: "14px 16px", + }); + pricePanel.setAttribute("aria-label", "Your prices against your cost"); + const priceTable = el("div", { + alignItems: "baseline", + columnGap: "16px", + display: "grid", + gridTemplateColumns: "minmax(0, 2fr) repeat(3, minmax(0, 1fr))", + rowGap: "6px", + }); + const priceNote = el("p", { color: tokens.foreground, fontSize: "0.75rem", margin: "0", opacity: "0.7" }, ""); + pricePanel.append( + el("h3", { color: tokens.foreground, fontSize: "0.8125rem", fontWeight: "600", margin: "0" }, "Your prices against your cost"), + priceTable, + priceNote, + ); + + element.append(status, grid, pricePanel, chartPanel); const history = []; let lastReading = null; + let latest = null; + let priceRows = []; + let nextPrices = 0; + + // Output prices per million tokens, in msat and, with --btc-price, in the + // tariff's currency too. + function priceText(msat, data) { + if (msat === null) return "–"; + const inCurrency = typeof data?.btc_price === "number" ? ` (${money((msat / MSAT_PER_BTC) * data.btc_price, data.currency)})` : ""; + return `${formatMsat(msat)}${inCurrency}`; + } + + function renderPrices() { + pricePanel.style.display = priceRows.length > 0 ? "grid" : "none"; + if (priceRows.length === 0) return; + const cell = (text, style = {}) => el("span", { color: tokens.foreground, fontSize: "0.8125rem", overflowWrap: "anywhere", ...style }, text); + const head = (text) => cell(text, { fontSize: "0.75rem", fontWeight: "600", opacity: "0.7" }); + const cells = [head("Model"), head("Your price"), head("Cheapest other seller"), head("")]; + for (const row of priceRows) { + const { text, color } = verdict(row.price, latest, tokens); + cells.push( + cell(row.model), + cell(row.price === null ? "free" : priceText(row.price, latest)), + cell(priceText(row.market, latest)), + cell(text, { color, fontWeight: "600" }), + ); + } + priceTable.replaceChildren(...cells); + const cost = latest?.cost_msat_per_1m_output_tokens; + const total = latest?.cost_msat_per_1m_output_tokens_total; + priceNote.textContent = + cost != null + ? `Prices and costs per million output tokens. Your cost: ${formatMsat(cost)} on top of idle${total != null ? `, ${formatMsat(total)} idle included` : ""}, measured across every model this node serves.` + : "Prices per million output tokens. Your cost shows here once the plugin has --electricity-price-per-kwh and --btc-price and has measured a request."; + } function show(key, text) { cards[key].value.textContent = text; @@ -189,6 +294,8 @@ function mountEnergyPage({ element, host }) { show("tokens", (data.output_tokens ?? 0).toLocaleString()); show("time", `${duration(data.busy_seconds ?? 0)} / ${duration(data.idle_seconds ?? 0)}`); draw(data.idle_watts); + latest = data; + renderPrices(); } // One request at a time, given up after a poll interval, and the next one @@ -207,6 +314,20 @@ function mountEnergyPage({ element, host }) { if (!response.ok) throw new Error(`HTTP ${response.status}`); const body = await response.json(); if (!stopped) render(body?.plugins?.[PLUGIN]); + if (!stopped && Date.now() >= nextPrices) { + nextPrices = Date.now() + PRICES_MS; + // The prices are extra: a listing that fails leaves the energy + // figures shown and the last prices in place. + try { + const models = await fetch("/v1/models", { headers: { accept: "application/json" }, signal: inFlight.signal }); + if (models.ok) { + priceRows = ownPrices(await models.json()); + if (!stopped) renderPrices(); + } + } catch { + // Tried again in PRICES_MS. + } + } } catch (error) { if (!stopped) { status.textContent = `Status unavailable: ${error.name === "AbortError" ? "no answer" : error.message}`; diff --git a/src/main.rs b/src/main.rs index 0137754..e390a4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -201,6 +201,29 @@ mod tests { assert_eq!(priced["currency"], "EUR"); assert!((priced["cost_per_1m_output_tokens"].as_f64().unwrap() - 0.1).abs() < 1e-12); assert!((priced["cost_per_1m_output_tokens_total"].as_f64().unwrap() - 0.15).abs() < 1e-12); + assert!(priced.get("btc_price").is_none()); + assert!(priced.get("cost_msat_per_1m_output_tokens").is_none()); + + let args = [ + "--electricity-price-per-kwh", + "0.25", + "--btc-price", + "100000", + ] + .map(String::from); + let in_msat = snapshot(price::Tariff::from_args(args).unwrap()); + assert_eq!(in_msat["btc_price"], 100_000.0); + assert!( + (in_msat["cost_msat_per_1m_output_tokens"].as_f64().unwrap() - 100_000.0).abs() < 1e-6 + ); + assert!( + (in_msat["cost_msat_per_1m_output_tokens_total"] + .as_f64() + .unwrap() + - 150_000.0) + .abs() + < 1e-6 + ); } /// Release packaging ships `plugin-manifest.json` as checked in: it must be diff --git a/src/price.rs b/src/price.rs index 2a44645..349c59c 100644 --- a/src/price.rs +++ b/src/price.rs @@ -9,6 +9,10 @@ //! allow_inference_observation = true //! args = ["--electricity-price-per-kwh", "0.25", "--currency", "EUR"] //! ``` +//! +//! Sellers price in msat per million tokens, so with the price of one bitcoin +//! in the same currency (`--btc-price 95000`) the costs are also given in +//! msat, and the Energy page can set them against the node's own prices. use crate::meter::EnergyStatusPayload; use anyhow::{Context, Result, bail}; @@ -16,12 +20,28 @@ use serde::Serialize; const PRICE: &str = "--electricity-price-per-kwh"; const CURRENCY: &str = "--currency"; +const BTC_PRICE: &str = "--btc-price"; +/// One bitcoin is 10^8 sat, and one sat is 1,000 msat. +const MSAT_PER_BTC: f64 = 100_000_000_000.0; /// The operator's electricity price, from the plugin's arguments. #[derive(Clone, Debug, PartialEq)] pub struct Tariff { price_per_kwh: f64, currency: Option, + /// One bitcoin in the tariff's currency, given by hand: the plugin asks no + /// outside service for a rate. + btc_price: Option, +} + +fn positive(flag: &str, text: &str) -> Result { + let parsed: f64 = text + .parse() + .with_context(|| format!("{flag} is not a number: {text}"))?; + if !parsed.is_finite() || parsed <= 0.0 { + bail!("{flag} must be a positive price, got {text}"); + } + Ok(parsed) } impl Tariff { @@ -30,6 +50,7 @@ impl Tariff { pub fn from_args(args: impl IntoIterator) -> Result> { let mut price = None; let mut currency = None; + let mut btc_price = None; let mut args = args.into_iter(); while let Some(arg) = args.next() { let (flag, inline) = match arg.split_once('=') { @@ -44,17 +65,15 @@ impl Tariff { }; match flag.as_str() { PRICE => { - let text = value()?; - let parsed: f64 = text - .parse() - .with_context(|| format!("{PRICE} is not a number: {text}"))?; - if !parsed.is_finite() || parsed <= 0.0 { - bail!("{PRICE} must be a positive price, got {text}"); - } - if price.replace(parsed).is_some() { + if price.replace(positive(PRICE, &value()?)?).is_some() { bail!("{PRICE} is given twice"); } } + BTC_PRICE => { + if btc_price.replace(positive(BTC_PRICE, &value()?)?).is_some() { + bail!("{BTC_PRICE} is given twice"); + } + } CURRENCY => { let text = value()?; if text.trim().is_empty() { @@ -64,31 +83,45 @@ impl Tariff { bail!("{CURRENCY} is given twice"); } } - other => bail!("unknown argument {other}; expected {PRICE} or {CURRENCY}"), + other => { + bail!("unknown argument {other}; expected {PRICE}, {CURRENCY} or {BTC_PRICE}") + } } } - match (price, currency) { - (Some(price_per_kwh), currency) => Ok(Some(Self { + match price { + Some(price_per_kwh) => Ok(Some(Self { price_per_kwh, currency, + btc_price, })), - (None, Some(_)) => bail!("{CURRENCY} needs {PRICE}"), - (None, None) => Ok(None), + None if currency.is_some() => bail!("{CURRENCY} needs {PRICE}"), + None if btc_price.is_some() => bail!("{BTC_PRICE} needs {PRICE}"), + None => Ok(None), } } /// Wh per 1,000 tokens is numerically kWh per million tokens, so each /// cost is that rate times the price per kWh. pub fn cost(&self, energy: &EnergyStatusPayload) -> CostPayload { + let cost = energy + .wh_per_1k_output_tokens + .map(|kwh| kwh * self.price_per_kwh); + let cost_total = energy + .wh_per_1k_output_tokens_total + .map(|kwh| kwh * self.price_per_kwh); CostPayload { electricity_price_per_kwh: self.price_per_kwh, currency: self.currency.clone(), - cost_per_1m_output_tokens: energy - .wh_per_1k_output_tokens - .map(|kwh| kwh * self.price_per_kwh), - cost_per_1m_output_tokens_total: energy - .wh_per_1k_output_tokens_total - .map(|kwh| kwh * self.price_per_kwh), + cost_per_1m_output_tokens: cost, + cost_per_1m_output_tokens_total: cost_total, + msat: self.btc_price.map(|btc_price| { + let msat = |cost: f64| cost / btc_price * MSAT_PER_BTC; + MsatCost { + btc_price, + cost_msat_per_1m_output_tokens: cost.map(msat), + cost_msat_per_1m_output_tokens_total: cost_total.map(msat), + } + }), } } } @@ -103,6 +136,18 @@ pub struct CostPayload { pub cost_per_1m_output_tokens: Option, /// Idle draw included: the floor for a machine that runs only to serve. pub cost_per_1m_output_tokens_total: Option, + /// Present only with `--btc-price`. + #[serde(flatten)] + pub msat: Option, +} + +/// The same two costs in msat per million output tokens, the unit sellers +/// price in, at the operator's bitcoin price. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MsatCost { + pub btc_price: f64, + pub cost_msat_per_1m_output_tokens: Option, + pub cost_msat_per_1m_output_tokens_total: Option, } #[cfg(test)] @@ -123,6 +168,7 @@ mod tests { let expected = Tariff { price_per_kwh: 0.25, currency: Some("EUR".into()), + btc_price: None, }; assert_eq!( Tariff::from_args(args(&[PRICE, "0.25", CURRENCY, "EUR"])).unwrap(), @@ -151,6 +197,11 @@ mod tests { &["--electricity-price", "0.2"], &[PRICE, "0.2", PRICE, "0.3"], &[PRICE, "0.2", CURRENCY, "EUR", CURRENCY, "USD"], + &[BTC_PRICE, "95000"], + &[PRICE, "0.2", BTC_PRICE, "0"], + &[PRICE, "0.2", BTC_PRICE, "a lot"], + &[PRICE, "0.2", BTC_PRICE], + &[PRICE, "0.2", BTC_PRICE, "95000", BTC_PRICE, "96000"], ] { assert!(Tariff::from_args(args(bad)).is_err(), "accepted {bad:?}"); } @@ -161,6 +212,7 @@ mod tests { let tariff = Tariff { price_per_kwh: 0.25, currency: None, + btc_price: None, }; let energy = EnergyStatusPayload { wh_per_1k_output_tokens: Some(0.4), @@ -170,9 +222,37 @@ mod tests { let cost = tariff.cost(&energy); assert!((cost.cost_per_1m_output_tokens.unwrap() - 0.1).abs() < 1e-12); assert!((cost.cost_per_1m_output_tokens_total.unwrap() - 0.15).abs() < 1e-12); + assert_eq!(cost.msat, None); let cost = tariff.cost(&EnergyStatusPayload::default()); assert_eq!(cost.cost_per_1m_output_tokens, None); assert_eq!(cost.cost_per_1m_output_tokens_total, None); } + + #[test] + fn a_bitcoin_price_gives_the_costs_in_msat() { + let tariff = Tariff::from_args(args(&[ + PRICE, + "0.25", + CURRENCY, + "EUR", + "--btc-price=100000", + ])) + .unwrap() + .unwrap(); + let energy = EnergyStatusPayload { + wh_per_1k_output_tokens: Some(0.4), + wh_per_1k_output_tokens_total: Some(0.6), + ..EnergyStatusPayload::default() + }; + // 0.10 EUR at 100,000 EUR per BTC is 10^-6 BTC: 100 sat, 100,000 msat. + let msat = tariff.cost(&energy).msat.unwrap(); + assert_eq!(msat.btc_price, 100_000.0); + assert!((msat.cost_msat_per_1m_output_tokens.unwrap() - 100_000.0).abs() < 1e-6); + assert!((msat.cost_msat_per_1m_output_tokens_total.unwrap() - 150_000.0).abs() < 1e-6); + + let unmeasured = tariff.cost(&EnergyStatusPayload::default()).msat.unwrap(); + assert_eq!(unmeasured.cost_msat_per_1m_output_tokens, None); + assert_eq!(unmeasured.cost_msat_per_1m_output_tokens_total, None); + } }