Compare commits

..
6 changed files with 32 additions and 192 deletions

View file

@ -333,14 +333,10 @@ the data is fetched on tab activation and on window change. Rendered
with plain tables + CSS bars — the dashboard bundle ships no chart with plain tables + CSS bars — the dashboard bundle ships no chart
library. library.
The cost figure is a deliberately rough estimate from a per-model The cost figure is a deliberately rough estimate from an approximate
price table (`est_cost_usd`); it drifts with list pricing and is per-model price table (`est_cost_usd`); it drifts with list pricing and
labelled accordingly. The table is operator-tunable via the is labelled accordingly. (A follow-up can move the table to a nix
`services.hyperhive.modelPrices` nix option — each key is a option so it's operator-tunable.)
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 ## P33RS tab

View file

@ -86,14 +86,6 @@ pub struct Coordinator {
pub agent_cpu_quota: String, pub agent_cpu_quota: String,
/// Per-agent systemd `MemoryMax=` value (e.g. `"4G"`). Same drop-in. /// Per-agent systemd `MemoryMax=` value (e.g. `"4G"`). Same drop-in.
pub agent_memory_max: String, 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: Mutex<HashMap<String, AgentSocket>>,
/// Agents whose lifecycle action (currently just spawn) is in flight. /// Agents whose lifecycle action (currently just spawn) is in flight.
/// Read by the dashboard to render a spinner; cleared when the action /// Read by the dashboard to render a spinner; cleared when the action
@ -287,7 +279,6 @@ impl Coordinator {
context_window_tokens: std::collections::HashMap<String, u64>, context_window_tokens: std::collections::HashMap<String, u64>,
agent_cpu_quota: String, agent_cpu_quota: String,
agent_memory_max: String, agent_memory_max: String,
model_prices: crate::hive_stats::PriceTable,
) -> Result<Self> { ) -> Result<Self> {
let broker = Broker::open(db_path).context("open broker")?; let broker = Broker::open(db_path).context("open broker")?;
let approvals = Approvals::open(db_path).context("open approvals")?; let approvals = Approvals::open(db_path).context("open approvals")?;
@ -323,7 +314,6 @@ impl Coordinator {
context_window_tokens, context_window_tokens,
agent_cpu_quota, agent_cpu_quota,
agent_memory_max, agent_memory_max,
model_prices,
agents: Mutex::new(HashMap::new()), agents: Mutex::new(HashMap::new()),
transient: Mutex::new(HashMap::new()), transient: Mutex::new(HashMap::new()),
recent_transient: Mutex::new(HashMap::new()), recent_transient: Mutex::new(HashMap::new()),

View file

@ -1791,16 +1791,9 @@ struct StatsHiveQuery {
/// Hive-wide turn-stats rollup for the dashboard swarm-stats view. /// Hive-wide turn-stats rollup for the dashboard swarm-stats view.
/// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only /// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only
/// (skips missing/unreadable ones). Window defaults to `24h`. /// (skips missing/unreadable ones). Window defaults to `24h`.
async fn api_stats_hive( async fn api_stats_hive(axum::extract::Query(q): axum::extract::Query<StatsHiveQuery>) -> Response {
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")); let window = crate::hive_stats::Window::parse(q.window.as_deref().unwrap_or("24h"));
axum::Json(crate::hive_stats::hive_snapshot( axum::Json(crate::hive_stats::hive_snapshot(window)).into_response()
window,
&state.coord.model_prices,
))
.into_response()
} }
/// Live per-agent-container CPU + memory load from cgroup v2. Samples /// Live per-agent-container CPU + memory load from cgroup v2. Samples

View file

@ -20,7 +20,7 @@ use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rusqlite::{Connection, OpenFlags}; use rusqlite::{Connection, OpenFlags};
use serde::{Deserialize, Serialize}; use serde::Serialize;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
@ -73,53 +73,33 @@ impl Window {
} }
} }
/// Approximate USD price per **million** tokens for one model class. /// Approximate USD price per **million** tokens, per model class.
/// The four fields map to the four token kinds the turn-stats schema /// Matched by substring against the model id. This is a deliberately
/// records. This is a deliberately rough estimate — Anthropic list /// rough estimate — Anthropic list pricing drifts, so the dashboard
/// pricing drifts, so the dashboard labels the figure as an estimate. /// labels the figure as an estimate. A follow-up can wire it from a
/// /// nix option (like `contextWindowTokens`) instead of hard-coding.
/// Operators keep the table current without a code change via the struct Prices {
/// `services.hyperhive.modelPrices` nix option (passed to input: f64,
/// `hive-c0re serve --model-prices <json>`, held on the `Coordinator` output: f64,
/// as a [`PriceTable`]). Any model not covered by the operator table cache_read: f64,
/// falls back to [`builtin_prices`]. cache_write: f64,
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Prices {
pub input: f64,
pub output: f64,
pub cache_read: f64,
pub cache_write: f64,
} }
/// Operator-tunable model→price map. Keys are matched fn model_prices(model: &str) -> Prices {
/// 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, per **million** tokens. Used when the
/// operator's [`PriceTable`] has no key matching the model. Figures are
/// current Anthropic list prices for the Claude 4.x family (Opus 4.x,
/// Sonnet 4.x, Haiku 4.5); `cache_write` uses the 1-hour cache-TTL price,
/// which is the default through the Claude subscription the agents run
/// on. Keep in sync with the `services.hyperhive.modelPrices` nix default
/// (`nix/modules/hive-c0re.nix`).
fn builtin_prices(model: &str) -> Prices {
let m = model.to_ascii_lowercase(); let m = model.to_ascii_lowercase();
if m.contains("opus") { if m.contains("opus") {
Prices { Prices {
input: 5.0, input: 15.0,
output: 25.0, output: 75.0,
cache_read: 0.5, cache_read: 1.5,
cache_write: 10.0, cache_write: 18.75,
} }
} else if m.contains("haiku") { } else if m.contains("haiku") {
Prices { Prices {
input: 1.0, input: 0.8,
output: 5.0, output: 4.0,
cache_read: 0.1, cache_read: 0.08,
cache_write: 2.0, cache_write: 1.0,
} }
} else { } else {
// sonnet + unknown fallback // sonnet + unknown fallback
@ -127,37 +107,11 @@ fn builtin_prices(model: &str) -> Prices {
input: 3.0, input: 3.0,
output: 15.0, output: 15.0,
cache_read: 0.3, cache_read: 0.3,
cache_write: 6.0, cache_write: 3.75,
} }
} }
} }
/// 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)] #[derive(Debug, Serialize)]
pub struct KeyCount { pub struct KeyCount {
pub key: String, pub key: String,
@ -225,7 +179,7 @@ fn u64_from_i64(v: i64) -> u64 {
/// Aggregate one agent's turn-stats over `[from, now]`. Errors bubble /// Aggregate one agent's turn-stats over `[from, now]`. Errors bubble
/// up so the caller can skip a single bad/locked db without failing /// up so the caller can skip a single bad/locked db without failing
/// the whole endpoint. /// the whole endpoint.
fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result<AgentAgg> { fn read_agent(path: &Path, from: i64) -> rusqlite::Result<AgentAgg> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
// turn_stats is rollback-journal (not WAL): a read landing while the // turn_stats is rollback-journal (not WAL): a read landing while the
// harness is mid-INSERT would get SQLITE_BUSY and drop that active // harness is mid-INSERT would get SQLITE_BUSY and drop that active
@ -254,7 +208,7 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result<A
agg.output = agg.output.saturating_add(output); agg.output = agg.output.saturating_add(output);
agg.cache_read = agg.cache_read.saturating_add(cache_read); agg.cache_read = agg.cache_read.saturating_add(cache_read);
agg.cache_creation = agg.cache_creation.saturating_add(cache_creation); agg.cache_creation = agg.cache_creation.saturating_add(cache_creation);
let p = resolve_prices(&model, prices); let p = model_prices(&model);
#[allow( #[allow(
clippy::cast_precision_loss, clippy::cast_precision_loss,
reason = "token counts stay well under f64's 2^53 exact-integer range, so this cost computation loses no precision" reason = "token counts stay well under f64's 2^53 exact-integer range, so this cost computation loses no precision"
@ -274,7 +228,7 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result<A
/// Build the swarm-wide rollup. Best-effort: a missing or unreadable /// Build the swarm-wide rollup. Best-effort: a missing or unreadable
/// per-agent db is skipped (logged), never fatal. /// per-agent db is skipped (logged), never fatal.
#[must_use] #[must_use]
pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats { pub fn hive_snapshot(window: Window) -> HiveStats {
let now = now_secs(); let now = now_secs();
let from = now - window.span_secs(); let from = now - window.span_secs();
@ -293,7 +247,7 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats {
if !path.exists() { if !path.exists() {
continue; continue;
} }
let agg = match read_agent(&path, from, prices) { let agg = match read_agent(&path, from) {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
tracing::warn!(agent = %name, error = ?e, "hive-stats: read failed; skipping"); tracing::warn!(agent = %name, error = ?e, "hive-stats: read failed; skipping");

View file

@ -82,19 +82,6 @@ enum Cmd {
/// Set via `services.hyperhive.agentMemoryMax`. /// Set via `services.hyperhive.agentMemoryMax`.
#[arg(long, default_value = "4G")] #[arg(long, default_value = "4G")]
agent_memory_max: String, 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 (whose default carries the full opus/sonnet/haiku
/// table — kept in sync with the in-code `builtin_prices`).
/// Defaults to `{}` here so a bare `hive-c0re serve` leans
/// entirely on `builtin_prices`; the NixOS default (every real
/// deployment) shadows it with the same operator-tunable table.
#[arg(long, default_value = "{}")]
model_prices: String,
}, },
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses /// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
/// the approval queue — use only as an operator on the host. For /// the approval queue — use only as an operator on the host. For
@ -165,7 +152,6 @@ async fn main() -> Result<()> {
context_window_tokens, context_window_tokens,
agent_cpu_quota, agent_cpu_quota,
agent_memory_max, agent_memory_max,
model_prices,
} => { } => {
cmd_serve( cmd_serve(
hyperhive_flake, hyperhive_flake,
@ -177,7 +163,6 @@ async fn main() -> Result<()> {
context_window_tokens, context_window_tokens,
agent_cpu_quota, agent_cpu_quota,
agent_memory_max, agent_memory_max,
model_prices,
&cli.socket, &cli.socket,
) )
.await .await
@ -236,7 +221,6 @@ async fn cmd_serve(
context_window_tokens: String, context_window_tokens: String,
agent_cpu_quota: String, agent_cpu_quota: String,
agent_memory_max: String, agent_memory_max: String,
model_prices: String,
socket: &std::path::Path, socket: &std::path::Path,
) -> Result<()> { ) -> Result<()> {
// Move any host-side state still at the legacy flat layout into its // Move any host-side state still at the legacy flat layout into its
@ -246,8 +230,6 @@ async fn cmd_serve(
hive_c0re::paths::relocate_legacy_state(); hive_c0re::paths::relocate_legacy_state();
let cwt: std::collections::HashMap<String, u64> = serde_json::from_str(&context_window_tokens) let cwt: std::collections::HashMap<String, u64> = serde_json::from_str(&context_window_tokens)
.context("--context-window-tokens: invalid JSON")?; .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( let coord = Arc::new(Coordinator::open(
&db, &db,
hyperhive_flake, hyperhive_flake,
@ -258,7 +240,6 @@ async fn cmd_serve(
cwt, cwt,
agent_cpu_quota, agent_cpu_quota,
agent_memory_max, agent_memory_max,
prices,
)?); )?);
manager_server::start(coord.clone())?; manager_server::start(coord.clone())?;
// Idempotent pre-flight: rewrite pre-meta-layout applied // Idempotent pre-flight: rewrite pre-meta-layout applied

View file

@ -423,80 +423,6 @@ in
''; '';
}; };
modelPrices = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
input = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million input tokens.";
};
output = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million output tokens.";
};
cache_read = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-read tokens.";
};
cache_write = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-creation (write) tokens.";
};
};
}
);
# Current Anthropic list prices for the Claude 4.x family (Opus
# 4.x, Sonnet 4.x, Haiku 4.5); cache_write is the 1-hour cache-TTL
# price (the default through the Claude subscription the agents run
# on). Keep in sync with `builtin_prices` in
# hive-c0re/src/hive_stats.rs.
default = {
opus = {
input = 5.0;
output = 25.0;
cache_read = 0.5;
cache_write = 10.0;
};
sonnet = {
input = 3.0;
output = 15.0;
cache_read = 0.3;
cache_write = 6.0;
};
haiku = {
input = 1.0;
output = 5.0;
cache_read = 0.1;
cache_write = 2.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 { agentCpuQuota = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "200%"; default = "200%";
@ -736,7 +662,7 @@ in
); );
}; };
serviceConfig = { 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} --model-prices ${lib.escapeShellArg (builtins.toJSON cfg.modelPrices)}"; 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}";
# Migrate hive-c0re's *own* state to the service user after an # Migrate hive-c0re's *own* state to the service user after an
# upgrade from a root-run install (systemd's StateDirectory only # upgrade from a root-run install (systemd's StateDirectory only
# chowns the top-level dir, not pre-existing files inside it). The # chowns the top-level dir, not pre-existing files inside it). The