blob: 2fbd1c023521259d5bca8a9573e724bd6158c157 [file]
//! What a harness says it spent, read from the transcript it left. For a login that
//! travels in a tunnel the egress sees bytes and no tokens, but the harness itself knows:
//! Codex writes a running total into its rollout, Claude Code writes the usage of every
//! model call into its transcript. This is self-reported, and recorded as such, beside
//! what the egress observed.
use dm_egress::Usage;
use serde_json::Value;
use std::collections::{BTreeMap, HashSet};
/// Usage per model, summed over a transcript.
#[derive(Debug, Default, PartialEq)]
pub struct Reported {
/// Model name to its summed usage.
pub by_model: BTreeMap<String, Usage>,
/// For a ChatGPT login: how much of the plan's window Codex says is used, as a
/// percentage, and the window in minutes.
pub plan_used_percent: Option<(f64, u64)>,
}
fn u(v: &Value, k: &str) -> u64 {
v.get(k).and_then(Value::as_u64).unwrap_or(0)
}
/// Read a transcript in any of the shapes the harnesses write.
#[must_use]
pub fn from_transcript(jsonl: &str) -> Reported {
let mut out = Reported::default();
let mut seen_claude_messages: HashSet<String> = HashSet::new();
let mut codex_model = String::from("codex");
let mut codex_total: Option<Usage> = None;
for line in jsonl.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
let kind = v.get("type").and_then(Value::as_str).unwrap_or_default();
// Claude Code: one line per content block, the usage repeated per message id
if kind == "assistant" {
if let Some(m) = v.get("message") {
let id = m
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !id.is_empty() && !seen_claude_messages.insert(id) {
continue;
}
if let Some(usage) = m.get("usage") {
let model = m
.get("model")
.and_then(Value::as_str)
.unwrap_or("claude")
.to_string();
let e = out.by_model.entry(model).or_default();
e.input_tokens += u(usage, "input_tokens");
e.output_tokens += u(usage, "output_tokens");
e.cache_read_tokens += u(usage, "cache_read_input_tokens");
e.cache_write_tokens += u(usage, "cache_creation_input_tokens");
}
}
continue;
}
// Codex: the model from the turn context, a running total in token_count events
if kind == "turn_context" {
if let Some(m) = v.pointer("/payload/model").and_then(Value::as_str) {
codex_model = m.to_string();
}
continue;
}
if kind == "event_msg"
&& v.pointer("/payload/type").and_then(Value::as_str) == Some("token_count")
{
if let Some(t) = v.pointer("/payload/info/total_token_usage") {
let cached = u(t, "cached_input_tokens");
codex_total = Some(Usage {
model: Some(codex_model.clone()),
// Codex counts cached input inside input_tokens; keep them apart
input_tokens: u(t, "input_tokens").saturating_sub(cached),
output_tokens: u(t, "output_tokens"),
cache_read_tokens: cached,
cache_write_tokens: u(t, "cache_write_input_tokens"),
});
}
if let Some(p) = v.pointer("/payload/rate_limits/primary") {
if let (Some(pct), Some(win)) = (
p.get("used_percent").and_then(Value::as_f64),
p.get("window_minutes").and_then(Value::as_u64),
) {
out.plan_used_percent = Some((pct, win));
}
}
}
}
if let Some(t) = codex_total {
out.by_model.insert(codex_model, t);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_usage_is_summed_once_per_message() {
let t = concat!(
r#"{"type":"user","message":{"content":"hi"}}"#,
"\n",
r#"{"type":"assistant","message":{"id":"m1","model":"claude-opus-5-5","usage":{"input_tokens":2,"cache_creation_input_tokens":6620,"cache_read_input_tokens":25862,"output_tokens":258},"content":[{"type":"text","text":"a"}]}}"#,
"\n",
r#"{"type":"assistant","message":{"id":"m1","model":"claude-opus-5-5","usage":{"input_tokens":2,"cache_creation_input_tokens":6620,"cache_read_input_tokens":25862,"output_tokens":258},"content":[{"type":"tool_use","name":"t0"}]}}"#,
"\n",
r#"{"type":"assistant","message":{"id":"m2","model":"claude-opus-5-5","usage":{"input_tokens":2,"cache_creation_input_tokens":322,"cache_read_input_tokens":32482,"output_tokens":97},"content":[{"type":"text","text":"b"}]}}"#,
"\n",
);
let r = from_transcript(t);
let u = &r.by_model["claude-opus-5-5"];
assert_eq!(u.input_tokens, 4);
assert_eq!(
u.output_tokens, 355,
"the second line of m1 is the same call"
);
assert_eq!(u.cache_read_tokens, 25862 + 32482);
assert_eq!(u.cache_write_tokens, 6620 + 322);
assert!(r.plan_used_percent.is_none());
}
#[test]
fn codex_usage_is_the_last_running_total_with_the_plan_window() {
let t = concat!(
r#"{"type":"turn_context","payload":{"model":"gpt-6-luna"}}"#,
"\n",
r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":5000,"cached_input_tokens":4000,"cache_write_input_tokens":0,"output_tokens":20}},"rate_limits":{"primary":{"used_percent":0.5,"window_minutes":43200}}}}"#,
"\n",
r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":13643,"cached_input_tokens":11008,"cache_write_input_tokens":0,"output_tokens":72}},"rate_limits":{"primary":{"used_percent":1.0,"window_minutes":43200}}}}"#,
"\n",
);
let r = from_transcript(t);
let u = &r.by_model["gpt-6-luna"];
assert_eq!(u.input_tokens, 13643 - 11008);
assert_eq!(u.cache_read_tokens, 11008);
assert_eq!(u.output_tokens, 72);
assert_eq!(r.plan_used_percent, Some((1.0, 43200)));
assert!(from_transcript("not json\n").by_model.is_empty());
}
}