From cc8f58fb2485cce9883b1592a2ca773e8bfdd011 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 23:52:16 +0200 Subject: [PATCH] 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 `. - 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 --- docs/web-ui/dashboard.md | 12 ++++-- hive-c0re/src/coordinator.rs | 10 +++++ hive-c0re/src/dashboard.rs | 11 +++++- hive-c0re/src/hive_stats.rs | 73 ++++++++++++++++++++++++++++-------- hive-c0re/src/main.rs | 18 +++++++++ nix/modules/hive-c0re.nix | 71 ++++++++++++++++++++++++++++++++++- 6 files changed, 172 insertions(+), 23 deletions(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 05a28bc3..5c630084 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -333,10 +333,14 @@ the data is fetched on tab activation and on window change. Rendered with plain tables + CSS bars — the dashboard bundle ships no chart library. -The cost figure is a deliberately rough estimate from an approximate -per-model price table (`est_cost_usd`); it drifts with list pricing and -is labelled accordingly. (A follow-up can move the table to a nix -option so it's operator-tunable.) +The cost figure is a deliberately rough estimate from a per-model +price table (`est_cost_usd`); it drifts with list pricing and is +labelled accordingly. The table is operator-tunable via the +`services.hyperhive.modelPrices` nix option — each key is a +model-family short name (matched case-insensitively as a substring of +the model id, longest match wins) mapping to +`{ input, output, cache_read, cache_write }` USD-per-million-token +prices. Models not covered fall back to hive-c0re's built-in estimate. ## P33RS tab diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index c9846c9a..690bc2d1 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -86,6 +86,14 @@ pub struct Coordinator { pub agent_cpu_quota: String, /// Per-agent systemd `MemoryMax=` value (e.g. `"4G"`). Same drop-in. pub agent_memory_max: String, + /// Operator-tunable model→price table backing the hive-wide cost + /// estimate on the ST4TS tab. Set via `services.hyperhive.modelPrices` + /// and passed to `hive-c0re serve --model-prices `. Models not + /// in the table fall back to the built-in estimate in `hive_stats`. + /// hive-c0re-local (read only by the `/api/stats-hive` handler), so — + /// unlike `context_window_tokens` — it is *not* part of `HiveEnv` and + /// is never injected into containers. + pub model_prices: crate::hive_stats::PriceTable, agents: Mutex>, /// Agents whose lifecycle action (currently just spawn) is in flight. /// Read by the dashboard to render a spinner; cleared when the action @@ -279,6 +287,7 @@ impl Coordinator { context_window_tokens: std::collections::HashMap, agent_cpu_quota: String, agent_memory_max: String, + model_prices: crate::hive_stats::PriceTable, ) -> Result { let broker = Broker::open(db_path).context("open broker")?; let approvals = Approvals::open(db_path).context("open approvals")?; @@ -314,6 +323,7 @@ impl Coordinator { context_window_tokens, agent_cpu_quota, agent_memory_max, + model_prices, agents: Mutex::new(HashMap::new()), transient: Mutex::new(HashMap::new()), recent_transient: Mutex::new(HashMap::new()), diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index d452217e..9d9e3b6f 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1791,9 +1791,16 @@ struct StatsHiveQuery { /// Hive-wide turn-stats rollup for the dashboard swarm-stats view. /// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only /// (skips missing/unreadable ones). Window defaults to `24h`. -async fn api_stats_hive(axum::extract::Query(q): axum::extract::Query) -> Response { +async fn api_stats_hive( + State(state): State, + axum::extract::Query(q): axum::extract::Query, +) -> Response { let window = crate::hive_stats::Window::parse(q.window.as_deref().unwrap_or("24h")); - axum::Json(crate::hive_stats::hive_snapshot(window)).into_response() + axum::Json(crate::hive_stats::hive_snapshot( + window, + &state.coord.model_prices, + )) + .into_response() } /// Live per-agent-container CPU + memory load from cgroup v2. Samples diff --git a/hive-c0re/src/hive_stats.rs b/hive-c0re/src/hive_stats.rs index 7f23b7df..470fb457 100644 --- a/hive-c0re/src/hive_stats.rs +++ b/hive-c0re/src/hive_stats.rs @@ -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 `, 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; + +/// 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 { +fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result { 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 { 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 { /// 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"); diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 53a3f452..4c506284 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -82,6 +82,18 @@ enum Cmd { /// Set via `services.hyperhive.agentMemoryMax`. #[arg(long, default_value = "4G")] agent_memory_max: String, + /// Per-model USD prices (per million tokens) for the hive-wide + /// ST4TS cost estimate, as a JSON object mapping a model-family + /// short name to `{input, output, cache_read, cache_write}`. + /// Keys are matched case-insensitively as a substring of the + /// model id; models not covered fall back to the built-in + /// estimate. Set via the `services.hyperhive.modelPrices` NixOS + /// option. + #[arg( + long, + default_value = r#"{"opus":{"input":15.0,"output":75.0,"cache_read":1.5,"cache_write":18.75},"sonnet":{"input":3.0,"output":15.0,"cache_read":0.3,"cache_write":3.75},"haiku":{"input":0.8,"output":4.0,"cache_read":0.08,"cache_write":1.0}}"# + )] + model_prices: String, }, /// Spawn a new agent container directly (`hive-agent-`). Bypasses /// the approval queue — use only as an operator on the host. For @@ -152,6 +164,7 @@ async fn main() -> Result<()> { context_window_tokens, agent_cpu_quota, agent_memory_max, + model_prices, } => { cmd_serve( hyperhive_flake, @@ -163,6 +176,7 @@ async fn main() -> Result<()> { context_window_tokens, agent_cpu_quota, agent_memory_max, + model_prices, &cli.socket, ) .await @@ -221,6 +235,7 @@ async fn cmd_serve( context_window_tokens: String, agent_cpu_quota: String, agent_memory_max: String, + model_prices: String, socket: &std::path::Path, ) -> Result<()> { // Move any host-side state still at the legacy flat layout into its @@ -230,6 +245,8 @@ async fn cmd_serve( hive_c0re::paths::relocate_legacy_state(); let cwt: std::collections::HashMap = serde_json::from_str(&context_window_tokens) .context("--context-window-tokens: invalid JSON")?; + let prices: hive_c0re::hive_stats::PriceTable = + serde_json::from_str(&model_prices).context("--model-prices: invalid JSON")?; let coord = Arc::new(Coordinator::open( &db, hyperhive_flake, @@ -240,6 +257,7 @@ async fn cmd_serve( cwt, agent_cpu_quota, agent_memory_max, + prices, )?); manager_server::start(coord.clone())?; // Idempotent pre-flight: rewrite pre-meta-layout applied diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index d66bc99b..4ef9e2dd 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -423,6 +423,75 @@ in ''; }; + modelPrices = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + input = lib.mkOption { + type = lib.types.float; + description = "USD per million input tokens."; + }; + output = lib.mkOption { + type = lib.types.float; + description = "USD per million output tokens."; + }; + cache_read = lib.mkOption { + type = lib.types.float; + description = "USD per million cache-read tokens."; + }; + cache_write = lib.mkOption { + type = lib.types.float; + description = "USD per million cache-creation (write) tokens."; + }; + }; + } + ); + default = { + opus = { + input = 15.0; + output = 75.0; + cache_read = 1.5; + cache_write = 18.75; + }; + sonnet = { + input = 3.0; + output = 15.0; + cache_read = 0.3; + cache_write = 3.75; + }; + haiku = { + input = 0.8; + output = 4.0; + cache_read = 0.08; + cache_write = 1.0; + }; + }; + example = { + sonnet = { + input = 3.0; + output = 15.0; + cache_read = 0.3; + cache_write = 3.75; + }; + }; + description = '' + Per-model USD prices (per **million** tokens) used for the + hive-wide cost *estimate* on the dashboard's ST4TS tab. Each key + is a model-family short name matched case-insensitively as a + substring of the active model id at runtime (e.g. `"sonnet"` + matches `"claude-sonnet-4-5"`); the longest matching key wins, so + a specific entry beats a generic family name. Any model not + covered by this table falls back to hive-c0re's built-in + estimate. + + The defaults track Anthropic list pricing at the time of + writing — override them here to keep the estimate current + without a code change. Passed to `hive-c0re serve` as JSON via + `--model-prices`; read only by hive-c0re itself (not injected + into containers). Changes apply on the next host rebuild. + ''; + }; + agentCpuQuota = lib.mkOption { type = lib.types.str; default = "200%"; @@ -662,7 +731,7 @@ in ); }; serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)} --agent-cpu-quota ${lib.escapeShellArg cfg.agentCpuQuota} --agent-memory-max ${lib.escapeShellArg cfg.agentMemoryMax}"; + ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)} --agent-cpu-quota ${lib.escapeShellArg cfg.agentCpuQuota} --agent-memory-max ${lib.escapeShellArg cfg.agentMemoryMax} --model-prices ${lib.escapeShellArg (builtins.toJSON cfg.modelPrices)}"; # Migrate hive-c0re's *own* state to the service user after an # upgrade from a root-run install (systemd's StateDirectory only # chowns the top-level dir, not pre-existing files inside it). The