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:
parent
f5d9f325c6
commit
cc8f58fb24
6 changed files with 172 additions and 23 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <json>`. 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<HashMap<String, AgentSocket>>,
|
||||
/// 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<String, u64>,
|
||||
agent_cpu_quota: String,
|
||||
agent_memory_max: String,
|
||||
model_prices: crate::hive_stats::PriceTable,
|
||||
) -> Result<Self> {
|
||||
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()),
|
||||
|
|
|
|||
|
|
@ -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<StatsHiveQuery>) -> Response {
|
||||
async fn api_stats_hive(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<StatsHiveQuery>,
|
||||
) -> 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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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-<name>`). 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<String, u64> = 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue