blob: 77564a0243799d86b520282b1fa10837e403c4aa [file]
//! What runs cost: the egress's metering priced by a table the owner keeps, per session
//! and per completed task, the paper's primary efficiency number. Prices live in
//! `prices.toml` under the root, in US dollars per million tokens, per model as the
//! provider reports it, with the four classes of token priced apart, since a harness
//! like Claude Code spends most of its tokens reading its cache. A call on the
//! harness's own login is plan quota, not metered spend, and is shown as tokens beside
//! the money, never priced. A model the table does not name is shown unpriced rather
//! than guessed at.
//!
//! Tokens are one resource class of the paper's price vector (ยง12); the host's own
//! are the others: each run's wall clock, the CPU time and peak memory of the process
//! tree it confined, and the launcher's own CPU, the egress among it, as the launcher
//! recorded them, and the time spent waiting on model calls, as the egress timed them.
//! `[resources]` in the same table prices CPU time and memory held; unpriced, they are
//! shown in their own units.
use dm_egress::telemetry::{Run, SessionModel, Telemetry, BILLING_PLAN};
use dm_server::Memory;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
/// The template `amem init` writes: every rate left for the owner to fill in.
pub const TEMPLATE: &str = r#"# What a million tokens cost, in US dollars, per model as the provider reports it in
# `amem usage`. A key is matched exactly, else as the longest prefix of the model's
# name, so "claude-opus-5" prices "claude-opus-5-5". Four classes, priced apart:
# input (not from the cache), output, cache reads and cache writes. A model not named
# here is shown unpriced. Calls on a harness's own login are plan quota, never priced.
#
# [models."claude-opus-5-5"]
# input = 0.0
# output = 0.0
# cache_read = 0.0
# cache_write = 0.0
#
# The host's own resources, per run of a harness: CPU time of the process tree and the
# launcher, in US dollars per CPU-hour, and peak resident memory held for the run's wall
# clock, in US dollars per GiB-hour. Left out, they are shown unpriced.
#
# [resources]
# cpu_hour = 0.0
# memory_gib_hour = 0.0
"#;
/// The rates of one model, in US dollars per million tokens.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct Rate {
#[serde(default)]
pub input: f64,
#[serde(default)]
pub output: f64,
#[serde(default)]
pub cache_read: f64,
#[serde(default)]
pub cache_write: f64,
}
/// What the host's resources cost, in US dollars.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct ResourceRates {
/// Per hour of CPU time, user and system, the run's tree and the launcher's.
#[serde(default)]
pub cpu_hour: f64,
/// Per GiB of peak resident memory held for an hour of the run's wall clock.
#[serde(default)]
pub memory_gib_hour: f64,
}
/// The table.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Prices {
#[serde(default)]
pub models: BTreeMap<String, Rate>,
/// The host's resources; absent, they are not priced.
#[serde(default)]
pub resources: Option<ResourceRates>,
}
impl Prices {
/// The table under `root`, or an empty one.
///
/// # Errors
///
/// A file that is there and does not parse.
pub fn load(root: &Path) -> Result<Prices, Box<dyn std::error::Error>> {
match std::fs::read_to_string(root.join("prices.toml")) {
Ok(text) => Ok(toml::from_str(&text)?),
Err(_) => Ok(Prices::default()),
}
}
/// A model's rate: its exact name, else the longest key it starts with.
#[must_use]
pub fn rate(&self, model: &str) -> Option<&Rate> {
self.models.get(model).or_else(|| {
self.models
.iter()
.filter(|(k, _)| model.starts_with(k.as_str()))
.max_by_key(|(k, _)| k.len())
.map(|(_, r)| r)
})
}
/// What usage cost, when its model is priced.
#[must_use]
pub fn cost(&self, u: &SessionModel) -> Option<f64> {
let r = self.rate(&u.model)?;
#[allow(clippy::cast_precision_loss)] // token counts, priced
let usd = (u.input_tokens as f64 * r.input
+ u.output_tokens as f64 * r.output
+ u.cache_read_tokens as f64 * r.cache_read
+ u.cache_write_tokens as f64 * r.cache_write)
/ 1_000_000.0;
Some(usd)
}
/// What a run's host resources cost, when they are priced.
#[must_use]
pub fn run_cost(&self, r: &Run) -> Option<f64> {
let rates = self.resources.as_ref()?;
#[allow(clippy::cast_precision_loss)] // milliseconds and KiB, priced
let usd = (r.user_ms + r.sys_ms + r.launcher_cpu_ms) as f64 / 3_600_000.0 * rates.cpu_hour
+ r.max_rss_kib as f64 / 1_048_576.0 * r.wall_ms as f64 / 3_600_000.0
* rates.memory_gib_hour;
Some(usd)
}
}
/// One session's cost.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Session {
pub session: String,
pub first: String,
/// Metered spend on priced models, in US dollars.
pub usd: f64,
/// Tokens on metered models the table does not price, by model.
pub unpriced: BTreeMap<String, u64>,
/// Tokens on the harness's own login, by model: plan quota.
pub plan: BTreeMap<String, u64>,
pub calls: u64,
/// The tasks this session completed: it wrote their closing handoff.
pub tasks: Vec<String>,
/// Runs of a harness under this session.
pub runs: u64,
/// Their wall clock, summed, in milliseconds.
pub wall_ms: u64,
/// CPU time of their process trees, user and system, in milliseconds.
pub cpu_ms: u64,
/// The launcher's own CPU time over them, the egress among it, in milliseconds.
pub launcher_cpu_ms: u64,
/// The largest resident set one process reached in any of them, in KiB.
pub max_rss_kib: u64,
/// Whether every run's CPU and memory were counted, not only its wall clock.
pub measured: bool,
/// Time spent waiting on the model calls the egress saw, in milliseconds.
pub call_ms: u64,
/// The share of `usd` that is the host's resources.
pub resource_usd: f64,
}
/// One completed task's cost: its session's, shared with the other tasks it completed.
#[derive(Clone, Debug, Serialize)]
pub struct TaskCost {
pub task: String,
pub title: String,
pub session: String,
pub usd: f64,
/// Whether some of its session's tokens were unpriced or plan quota, so the money
/// is not all of it.
pub partial: bool,
/// Its share of the session's wall clock, in milliseconds.
pub wall_ms: u64,
/// Its share of the session's CPU time, the trees' and the launcher's, in
/// milliseconds.
pub cpu_ms: u64,
}
/// The report.
#[derive(Debug, Default, Serialize)]
pub struct Report {
pub sessions: Vec<Session>,
pub tasks: Vec<TaskCost>,
pub usd: f64,
pub priced_models: Vec<String>,
}
fn tokens(u: &SessionModel) -> u64 {
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens
}
/// Price the usage, per session, and attribute each completed task to the session that
/// wrote its closing handoff.
#[must_use]
pub fn price(
rows: &[SessionModel],
runs: &[Run],
prices: &Prices,
completed: &[(String, String, String)],
) -> Report {
let mut by: BTreeMap<String, Session> = BTreeMap::new();
for u in rows {
let s = by.entry(u.session.clone()).or_insert_with(|| Session {
session: u.session.clone(),
first: u.first.clone(),
measured: true,
..Session::default()
});
s.calls += u.calls;
s.call_ms += u.call_ms;
if u.billing == BILLING_PLAN {
*s.plan.entry(u.model.clone()).or_default() += tokens(u);
} else if let Some(usd) = prices.cost(u) {
s.usd += usd;
} else {
*s.unpriced.entry(u.model.clone()).or_default() += tokens(u);
}
}
for r in runs {
let s = by.entry(r.session.clone()).or_insert_with(|| Session {
session: r.session.clone(),
first: r.at.clone(),
measured: true,
..Session::default()
});
s.runs += 1;
s.wall_ms += r.wall_ms;
s.cpu_ms += r.user_ms + r.sys_ms;
s.launcher_cpu_ms += r.launcher_cpu_ms;
s.max_rss_kib = s.max_rss_kib.max(r.max_rss_kib);
s.measured &= r.measured;
if let Some(usd) = prices.run_cost(r) {
s.resource_usd += usd;
s.usd += usd;
}
}
for (task, _, session) in completed {
if let Some(s) = by.get_mut(session) {
s.tasks.push(task.clone());
}
}
let mut tasks = Vec::new();
for (task, title, session) in completed {
if let Some(s) = by.get(session) {
#[allow(clippy::cast_precision_loss)] // a count of tasks
let share = s.usd / s.tasks.len().max(1) as f64;
let n = u64::try_from(s.tasks.len().max(1)).unwrap_or(1);
tasks.push(TaskCost {
task: task.clone(),
title: title.clone(),
session: session.clone(),
usd: share,
partial: !s.unpriced.is_empty() || !s.plan.is_empty(),
wall_ms: s.wall_ms / n,
cpu_ms: (s.cpu_ms + s.launcher_cpu_ms) / n,
});
}
}
let mut sessions: Vec<Session> = by.into_values().collect();
sessions.sort_by(|a, b| a.first.cmp(&b.first));
Report {
usd: sessions.iter().map(|s| s.usd).sum(),
sessions,
tasks,
priced_models: prices.models.keys().cloned().collect(),
}
}
/// The completed tasks of every granted domain, each with the session that wrote its
/// closing handoff, from the ledger and the write log.
///
/// # Errors
///
/// A ledger error.
pub async fn completed(
m: &Memory,
) -> Result<Vec<(String, String, String)>, Box<dyn std::error::Error>> {
let writes = dm_server::reads::all_writes(m.root()).unwrap_or_default();
let session_of = |id: &str| {
writes
.iter()
.find(|w| w.id == id)
.map(|w| w.session.clone())
};
let mut out = Vec::new();
for domain in m.domains() {
for t in m.tasks(Some(&domain), dm_ledger::Status::Completed).await? {
if let Some(s) = t.handoff.as_deref().and_then(session_of) {
out.push((t.id.clone(), t.spec.title.clone(), s));
}
}
}
Ok(out)
}
/// `amem cost`.
///
/// # Errors
///
/// When the telemetry, the price table or the ledger cannot be read.
pub async fn report(root: &Path) -> Result<Report, Box<dyn std::error::Error>> {
let m = Memory::open(root)?.without_read_log();
let t = Telemetry::open(&root.join("state/telemetry.db"))?;
Ok(price(
&t.by_session_model()?,
&t.runs()?,
&Prices::load(root)?,
&completed(&m).await?,
))
}
/// Milliseconds as whole seconds, for a table.
fn secs(ms: u64) -> String {
format!("{:.1}", Duration::from_millis(ms).as_secs_f64())
}
/// The sessions' host resources, for those that ran a harness.
fn resources(s: &mut String, r: &Report) {
let ran: Vec<&Session> = r.sessions.iter().filter(|x| x.runs > 0).collect();
if ran.is_empty() {
return;
}
s.push_str(
"\nhost resources runs wall s cpu s launcher s peak MiB on calls s usd\n",
);
for x in ran {
s.push_str(&format!(
"{:<16} {:>4} {:>8} {:>6} {:>11} {:>9} {:>11} {:>8.4}{}\n",
x.session,
x.runs,
secs(x.wall_ms),
secs(x.cpu_ms),
secs(x.launcher_cpu_ms),
x.max_rss_kib / 1024,
secs(x.call_ms),
x.resource_usd,
if x.measured {
""
} else {
" (wall clock only)"
}
));
}
}
/// The report as text.
#[must_use]
pub fn render(r: &Report) -> String {
let mut s = String::new();
if r.priced_models.is_empty() {
s.push_str(
"No prices: fill in prices.toml under the root to see money; tokens follow.\n\n",
);
}
s.push_str("session calls usd tasks unpriced or plan tokens\n");
for x in &r.sessions {
let mut other: Vec<String> = x
.unpriced
.iter()
.map(|(m, n)| format!("{m} {n} unpriced"))
.collect();
other.extend(x.plan.iter().map(|(m, n)| format!("{m} {n} on a plan")));
s.push_str(&format!(
"{:<16} {:>5} {:>8.4} {:>7} {}\n",
x.session,
x.calls,
x.usd,
x.tasks.len(),
other.join(", ")
));
}
resources(&mut s, r);
if !r.tasks.is_empty() {
s.push_str("\ncompleted task usd wall s cpu s title\n");
for t in &r.tasks {
s.push_str(&format!(
"{:<18} {:>6.4}{} {:>7} {:>6} {}\n",
t.task,
t.usd,
if t.partial { "+" } else { " " },
secs(t.wall_ms),
secs(t.cpu_ms),
t.title
));
}
s.push_str(" + some of that session's tokens were unpriced or plan quota\n");
}
s.push_str(&format!(
"\ntotal metered spend on priced models and resources: ${:.4}\n",
r.usd
));
s
}
#[cfg(test)]
mod tests {
use super::*;
fn row(session: &str, model: &str, billing: &str, input: u64, cache_read: u64) -> SessionModel {
SessionModel {
call_ms: 0,
session: session.into(),
destination: "d".into(),
model: model.into(),
billing: billing.into(),
source: "egress".into(),
calls: 1,
input_tokens: input,
output_tokens: 1000,
cache_read_tokens: cache_read,
cache_write_tokens: 0,
first: "2026-10-11T00:00:00Z".into(),
}
}
#[test]
fn usage_is_priced_by_class_and_a_prefix_matches_a_family() {
let p: Prices = toml::from_str(
"[models.\"claude-opus-5\"]\ninput = 10.0\noutput = 50.0\ncache_read = 1.0\n",
)
.unwrap();
let u = row("s", "claude-opus-5-5", "metered", 1_000_000, 2_000_000);
// 1M input at 10, 1000 output at 50, 2M cache reads at 1
assert!((p.cost(&u).unwrap() - (10.0 + 0.05 + 2.0)).abs() < 1e-9);
assert!(
p.cost(&row("s", "qwen3.8-27b", "metered", 1, 0)).is_none(),
"not named, not guessed"
);
}
#[test]
fn a_task_costs_its_sessions_spend_shared_and_plan_quota_is_never_priced() {
let p: Prices = toml::from_str("[models.m]\ninput = 1.0\noutput = 0.0\n").unwrap();
let rows = vec![
row("s1", "m", "metered", 2_000_000, 0),
row("s2", "m", "plan", 9_000_000, 0),
row("s3", "other", "metered", 5, 0),
];
let done = vec![
("t1".to_string(), "A".to_string(), "s1".to_string()),
("t2".to_string(), "B".to_string(), "s1".to_string()),
("t3".to_string(), "C".to_string(), "s2".to_string()),
];
let r = price(&rows, &[], &p, &done);
assert!(
(r.usd - 2.0).abs() < 1e-9,
"only the metered, priced session costs money"
);
let t1 = r.tasks.iter().find(|t| t.task == "t1").unwrap();
assert!(
(t1.usd - 1.0).abs() < 1e-9,
"two tasks share their session's spend"
);
let t3 = r.tasks.iter().find(|t| t.task == "t3").unwrap();
assert!(
t3.usd.abs() < 1e-9 && t3.partial,
"a plan session is tokens, not money"
);
let s3 = r.sessions.iter().find(|s| s.session == "s3").unwrap();
assert_eq!(s3.unpriced.get("other"), Some(&1005));
assert!(render(&r).contains("on a plan"));
}
fn run(session: &str, measured: bool) -> Run {
Run {
session: session.into(),
at: "2026-10-12T00:00:00Z".into(),
harness: "claude".into(),
runner: "namespaces".into(),
task: None,
exit: 0,
wall_ms: 3_600_000,
user_ms: 2_000_000,
sys_ms: 1_000_000,
max_rss_kib: 1_048_576,
measured,
launcher_cpu_ms: 600_000,
}
}
#[test]
fn a_runs_host_resources_are_priced_and_shared_like_its_tokens() {
let p: Prices =
toml::from_str("[resources]\ncpu_hour = 0.5\nmemory_gib_hour = 0.25\n").unwrap();
// an hour of CPU in all, and a GiB held for an hour of wall clock
let rows = vec![row("s1", "m", "metered", 10, 0)];
let done = vec![
("t1".to_string(), "A".to_string(), "s1".to_string()),
("t2".to_string(), "B".to_string(), "s1".to_string()),
];
let r = price(&rows, &[run("s1", true), run("s2", false)], &p, &done);
let s1 = r.sessions.iter().find(|s| s.session == "s1").unwrap();
assert!((s1.resource_usd - 0.75).abs() < 1e-9, "{s1:?}");
assert!(
(s1.usd - 0.75).abs() < 1e-9,
"the model is unpriced: {s1:?}"
);
assert_eq!(
(s1.runs, s1.cpu_ms, s1.launcher_cpu_ms),
(1, 3_000_000, 600_000)
);
let t1 = r.tasks.iter().find(|t| t.task == "t1").unwrap();
assert_eq!((t1.wall_ms, t1.cpu_ms), (1_800_000, 1_800_000), "half each");
assert!((t1.usd - 0.375).abs() < 1e-9);
let s2 = r.sessions.iter().find(|s| s.session == "s2").unwrap();
assert!(!s2.measured, "a run with only its wall clock");
let text = render(&r);
assert!(text.contains("host resources"), "{text}");
assert!(text.contains("(wall clock only)"), "{text}");
assert!(text.contains("1024"), "a GiB, in MiB: {text}");
// unpriced, the resources are shown and cost nothing
let free = price(&rows, &[run("s1", true)], &Prices::default(), &done);
assert!(free.usd.abs() < 1e-9);
assert_eq!(free.sessions[0].wall_ms, 3_600_000);
}
}