feat(stats): make ST4TS model price table operator-tunable

The hive-wide cost estimate on the dashboard's ST4TS tab used a
hard-coded model->price table in hive_stats.rs. Anthropic list pricing
drifts, so move the table to a nix option operators can keep current
without a code change.

- New `services.hyperhive.modelPrices` option: attrset of model-family
  short name -> { input, output, cache_read, cache_write } USD per
  million tokens. Passed to `hive-c0re serve --model-prices <json>`.
- hive_stats: `Prices` is now public + Deserialize; add `PriceTable`
  type and `resolve_prices` (longest case-insensitive substring key
  wins) with the old hard-coded table preserved as `builtin_prices`
  fallback for any model not covered.
- Coordinator holds the parsed table (hive-c0re-local, not injected
  into containers, so not part of HiveEnv); `/api/stats-hive` reads it.
- Docs: dashboard.md ST4TS cost note updated; option self-documents
  via nixosOptionsDoc.

Closes #1434
This commit is contained in:
iris 2026-06-05 23:52:16 +02:00
commit cc8f58fb24
6 changed files with 172 additions and 23 deletions

View file

@ -20,7 +20,7 @@ use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rusqlite::{Connection, OpenFlags};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::coordinator::Coordinator;
@ -73,19 +73,34 @@ impl Window {
}
}
/// Approximate USD price per **million** tokens, per model class.
/// Matched by substring against the model id. This is a deliberately
/// rough estimate — Anthropic list pricing drifts, so the dashboard
/// labels the figure as an estimate. A follow-up can wire it from a
/// nix option (like `contextWindowTokens`) instead of hard-coding.
struct Prices {
input: f64,
output: f64,
cache_read: f64,
cache_write: f64,
/// Approximate USD price per **million** tokens for one model class.
/// The four fields map to the four token kinds the turn-stats schema
/// records. This is a deliberately rough estimate — Anthropic list
/// pricing drifts, so the dashboard labels the figure as an estimate.
///
/// Operators keep the table current without a code change via the
/// `services.hyperhive.modelPrices` nix option (passed to
/// `hive-c0re serve --model-prices <json>`, held on the `Coordinator`
/// as a [`PriceTable`]). Any model not covered by the operator table
/// falls back to [`builtin_prices`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Prices {
pub input: f64,
pub output: f64,
pub cache_read: f64,
pub cache_write: f64,
}
fn model_prices(model: &str) -> Prices {
/// Operator-tunable model→price map. Keys are matched
/// case-insensitively as a substring of the model id (e.g. `"sonnet"`
/// matches `"claude-sonnet-4-5"`); the longest matching key wins so a
/// specific entry beats a generic family name. An empty map means
/// every model uses [`builtin_prices`].
pub type PriceTable = HashMap<String, Prices>;
/// Built-in fallback pricing — the historical hard-coded table. Used
/// when the operator's [`PriceTable`] has no key matching the model.
fn builtin_prices(model: &str) -> Prices {
let m = model.to_ascii_lowercase();
if m.contains("opus") {
Prices {
@ -112,6 +127,32 @@ fn model_prices(model: &str) -> Prices {
}
}
/// Resolve the price for `model`: prefer the operator-provided
/// `table` (longest matching key wins, so a specific
/// `claude-3-5-sonnet` entry beats a generic `sonnet`), else fall back
/// to [`builtin_prices`].
fn resolve_prices(model: &str, table: &PriceTable) -> Prices {
let m = model.to_ascii_lowercase();
let mut best: Option<(usize, Prices)> = None;
for (k, v) in table {
let key = k.to_ascii_lowercase();
if key.is_empty() || !m.contains(&key) {
continue;
}
let better = match best {
Some((blen, _)) => key.len() > blen,
None => true,
};
if better {
best = Some((key.len(), *v));
}
}
match best {
Some((_, p)) => p,
None => builtin_prices(&m),
}
}
#[derive(Debug, Serialize)]
pub struct KeyCount {
pub key: String,
@ -179,7 +220,7 @@ fn u64_from_i64(v: i64) -> u64 {
/// Aggregate one agent's turn-stats over `[from, now]`. Errors bubble
/// up so the caller can skip a single bad/locked db without failing
/// the whole endpoint.
fn read_agent(path: &Path, from: i64) -> rusqlite::Result<AgentAgg> {
fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result<AgentAgg> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
// turn_stats is rollback-journal (not WAL): a read landing while the
// harness is mid-INSERT would get SQLITE_BUSY and drop that active
@ -208,7 +249,7 @@ fn read_agent(path: &Path, from: i64) -> rusqlite::Result<AgentAgg> {
agg.output = agg.output.saturating_add(output);
agg.cache_read = agg.cache_read.saturating_add(cache_read);
agg.cache_creation = agg.cache_creation.saturating_add(cache_creation);
let p = model_prices(&model);
let p = resolve_prices(&model, prices);
#[allow(
clippy::cast_precision_loss,
reason = "token counts stay well under f64's 2^53 exact-integer range, so this cost computation loses no precision"
@ -228,7 +269,7 @@ fn read_agent(path: &Path, from: i64) -> rusqlite::Result<AgentAgg> {
/// Build the swarm-wide rollup. Best-effort: a missing or unreadable
/// per-agent db is skipped (logged), never fatal.
#[must_use]
pub fn hive_snapshot(window: Window) -> HiveStats {
pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats {
let now = now_secs();
let from = now - window.span_secs();
@ -247,7 +288,7 @@ pub fn hive_snapshot(window: Window) -> HiveStats {
if !path.exists() {
continue;
}
let agg = match read_agent(&path, from) {
let agg = match read_agent(&path, from, prices) {
Ok(a) => a,
Err(e) => {
tracing::warn!(agent = %name, error = ?e, "hive-stats: read failed; skipping");