majordomus_cli/economics/usage.rs
1//! Provider usage, read as the provider reported it, and the one normalisation every
2//! comparison uses.
3//!
4//! Providers do not agree on what their fields mean. Anthropic reports three disjoint input
5//! quantities (uncached, written to the cache, read from the cache); OpenAI reports one input
6//! total with the cached part *inside* it. Both are kept in their native names in the run
7//! record ([`EconomicsRequest`]), and [`totals`] maps them onto one shape where
8//! `total_input = uncached + cache write + cache read` holds for both.
9//!
10//! What a session said is never kept. [`read_stream`] reads a Claude Code `stream-json`
11//! transcript and keeps numbers and tool *names*; text, tool inputs and tool results are
12//! dropped as they are read, so a run record cannot leak a prompt, a file or a secret.
13//!
14//! ```
15//! use majordomus_cli::economics::usage::read_stream;
16//! let stream = concat!(
17//! r#"{"type":"assistant","message":{"id":"m1","model":"claude-sonnet-5","content":[{"type":"text","text":"secret"}],"usage":{"input_tokens":3,"cache_creation_input_tokens":100,"cache_read_input_tokens":0,"output_tokens":7}}}"#, "\n",
18//! r#"{"type":"assistant","message":{"id":"m1","model":"claude-sonnet-5","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/x"}}],"usage":{"input_tokens":3,"cache_creation_input_tokens":100,"cache_read_input_tokens":0,"output_tokens":7}}}"#, "\n",
19//! r#"{"type":"result","subtype":"success","is_error":false,"num_turns":1,"total_cost_usd":0.25}"#, "\n",
20//! );
21//! let s = read_stream(stream).unwrap();
22//! assert_eq!(s.requests.len(), 1, "one message, two content blocks, one request");
23//! assert_eq!(s.tools.get("Read"), Some(&1));
24//! assert_eq!(s.reported_cost_microusd, Some(250_000));
25//! assert!(!serde_json::to_string(&s.requests).unwrap().contains("secret"));
26//! ```
27
28use std::collections::{BTreeMap, BTreeSet};
29
30use serde_json::Value;
31
32use super::model::{
33 EconomicsModelTotal, EconomicsOrientation, EconomicsRequest, EconomicsRun, EconomicsSession,
34 EconomicsUsage,
35};
36
37/// Tools that change files: the first call to one ends a session's orientation.
38const EDITS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
39/// Tools that read a file.
40const READS: &[&str] = &["Read", "NotebookRead"];
41/// Tools that search.
42const SEARCHES: &[&str] = &["Grep", "Glob", "LS", "WebSearch"];
43/// Shell commands that are searches when they lead a `Bash` call.
44const SHELL_SEARCHES: &[&str] = &["grep", "rg", "find", "ls", "git grep", "fd"];
45
46/// What [`read_stream`] recovers from one session's transcript: the numeric and structural
47/// parts of an [`EconomicsSession`]. The caller adds what the transcript does not know
48/// (index, prompt digest, wall clock).
49///
50/// ```
51/// use majordomus_cli::economics::usage::{read_stream, StreamFacts};
52/// let line = concat!(
53/// r#"{"type":"assistant","message":{"id":"m1","model":"m","content":[],"#,
54/// r#""usage":{"input_tokens":4,"output_tokens":1}}}"#,
55/// );
56/// let facts: StreamFacts = read_stream(line).unwrap();
57/// assert_eq!(facts.requests[0].input_tokens, 4);
58/// assert_eq!(facts.ended, "no_result", "no closing `result` event: the session never finished");
59/// assert!(facts.is_error);
60/// ```
61#[derive(Debug, Clone, Default, PartialEq)]
62pub struct StreamFacts {
63 /// Requests, deduplicated by message id, in first-seen order. An event that carries no
64 /// message id is a request of its own: nothing says it repeats another.
65 pub requests: Vec<EconomicsRequest>,
66 /// The harness's per-model totals from its closing `result` event.
67 pub models: Vec<EconomicsModelTotal>,
68 /// The harness's `total_cost_usd`, in millionths of a dollar.
69 pub reported_cost_microusd: Option<u64>,
70 /// Tool calls by name.
71 pub tools: BTreeMap<String, u64>,
72 /// Orientation before the first edit.
73 pub orientation: EconomicsOrientation,
74 /// Turns.
75 pub turns: Option<u64>,
76 /// How the session ended.
77 pub ended: String,
78 /// Whether the harness reported an error.
79 pub is_error: bool,
80 /// Tools the harness offered, from its `init` event.
81 pub tools_offered: Vec<String>,
82 /// MCP servers the harness connected, from its `init` event.
83 pub mcp_servers: Vec<String>,
84}
85
86fn u(v: &Value, key: &str) -> u64 {
87 v.get(key).and_then(Value::as_u64).unwrap_or(0)
88}
89
90fn microusd(v: Option<&Value>) -> Option<u64> {
91 v.and_then(Value::as_f64)
92 .filter(|x| x.is_finite() && *x >= 0.0)
93 .map(|x| (x * 1_000_000.0).round() as u64)
94}
95
96/// Read an Anthropic `usage` object into a request, native field names kept.
97///
98/// ```
99/// use majordomus_cli::economics::usage::anthropic_request;
100/// let r = anthropic_request("m", &serde_json::json!({
101/// "input_tokens": 2, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 90,
102/// "output_tokens": 5, "output_tokens_details": {"thinking_tokens": 3}
103/// }));
104/// assert_eq!((r.input_tokens, r.cache_read_input_tokens, r.thinking_tokens), (2, 90, Some(3)));
105/// ```
106pub fn anthropic_request(model: &str, usage: &Value) -> EconomicsRequest {
107 EconomicsRequest {
108 model: model.to_string(),
109 input_tokens: u(usage, "input_tokens"),
110 cache_creation_input_tokens: u(usage, "cache_creation_input_tokens"),
111 cache_read_input_tokens: u(usage, "cache_read_input_tokens"),
112 output_tokens: u(usage, "output_tokens"),
113 thinking_tokens: usage
114 .get("output_tokens_details")
115 .and_then(|d| d.get("thinking_tokens"))
116 .and_then(Value::as_u64),
117 }
118}
119
120/// Read an OpenAI `usage` object into the same shape. OpenAI's `input_tokens` *includes*
121/// the cached part (`input_tokens_details.cached_tokens`), where Anthropic's excludes it;
122/// the cached part is moved out so that the three input fields are disjoint for both.
123/// OpenAI bills no cache writes, so that field is zero, which is what it is.
124///
125/// ```
126/// use majordomus_cli::economics::usage::openai_request;
127/// let r = openai_request("gpt-5", &serde_json::json!({
128/// "input_tokens": 1000, "input_tokens_details": {"cached_tokens": 800},
129/// "output_tokens": 50, "output_tokens_details": {"reasoning_tokens": 30}
130/// }));
131/// assert_eq!((r.input_tokens, r.cache_read_input_tokens, r.cache_creation_input_tokens), (200, 800, 0));
132/// assert_eq!(r.thinking_tokens, Some(30));
133/// ```
134pub fn openai_request(model: &str, usage: &Value) -> EconomicsRequest {
135 let input = u(usage, "input_tokens").max(u(usage, "prompt_tokens"));
136 let cached = usage
137 .get("input_tokens_details")
138 .or_else(|| usage.get("prompt_tokens_details"))
139 .map(|d| u(d, "cached_tokens"))
140 .unwrap_or(0)
141 .min(input);
142 EconomicsRequest {
143 model: model.to_string(),
144 input_tokens: input - cached,
145 cache_creation_input_tokens: 0,
146 cache_read_input_tokens: cached,
147 output_tokens: u(usage, "output_tokens").max(u(usage, "completion_tokens")),
148 thinking_tokens: usage
149 .get("output_tokens_details")
150 .or_else(|| usage.get("completion_tokens_details"))
151 .and_then(|d| d.get("reasoning_tokens"))
152 .and_then(Value::as_u64),
153 }
154}
155
156fn request_total(r: &EconomicsRequest) -> u64 {
157 r.input_tokens + r.cache_creation_input_tokens + r.cache_read_input_tokens + r.output_tokens
158}
159
160/// What a shell command does to the working tree, as far as its text shows. Agents edit
161/// through the shell as often as through edit tools (`cat > f`, `sed -i`, a heredoc script
162/// that opens a file for writing), so orientation cannot end only at an `Edit` call.
163///
164/// ```
165/// use majordomus_cli::economics::usage::{shell_effect, ShellEffect};
166/// let effects = ["rg todo src", "head -5 a.rs", "cargo test", "cp a.rs b.rs"].map(shell_effect);
167/// assert_eq!(
168/// effects,
169/// [ShellEffect::Search, ShellEffect::Read, ShellEffect::Other, ShellEffect::Edit]
170/// );
171/// ```
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum ShellEffect {
174 /// Writes a file.
175 Edit,
176 /// Searches (grep, find, ls, git ls-files).
177 Search,
178 /// Reads a file (cat, head, tail, sed -n).
179 Read,
180 /// Anything else: running tests, git status, ...
181 Other,
182}
183
184/// The command with every quoted span replaced by one placeholder word, so that a `>` or a
185/// `;` inside an argument (`grep '>' f`, `echo "a; rm b"`) is not read as shell syntax,
186/// while a quoted redirection target (`> 'out.txt'`) still leaves a target behind. A quote
187/// left open runs to the end of the command, as the shell would read it.
188fn unquoted(cmd: &str) -> String {
189 let mut out = String::with_capacity(cmd.len());
190 let mut chars = cmd.chars();
191 while let Some(c) = chars.next() {
192 match c {
193 '\'' => {
194 // a single-quoted span ends at the next single quote; nothing escapes in it
195 for q in chars.by_ref() {
196 if q == '\'' {
197 break;
198 }
199 }
200 out.push('_');
201 }
202 '"' => {
203 // a double-quoted span ends at the next unescaped double quote
204 while let Some(q) = chars.next() {
205 match q {
206 '\\' => {
207 chars.next();
208 }
209 '"' => break,
210 _ => {}
211 }
212 }
213 out.push('_');
214 }
215 _ => out.push(c),
216 }
217 }
218 out
219}
220
221fn writes(cmd: &str) -> bool {
222 const MARKERS: &[&str] = &[
223 "sed -i",
224 "perl -i",
225 "| tee ",
226 " tee -a ",
227 "git apply",
228 "patch -p",
229 ".write_text(",
230 ".write(",
231 "open(p, \"w\")",
232 "open(p,\"w\")",
233 "\"w\")",
234 "'w')",
235 "\"a\")",
236 "'a')",
237 ];
238 if MARKERS.iter().any(|m| cmd.contains(m)) {
239 return true;
240 }
241 let bare = unquoted(cmd);
242 let tokens: Vec<&str> = bare.split_whitespace().collect();
243 // a file-changing command word, in command position: first, or after ; && || |
244 let command_position = |i: usize| {
245 i == 0
246 || tokens
247 .get(i - 1)
248 .is_some_and(|p| p.ends_with(';') || ["&&", "||", "|", ";"].contains(p))
249 };
250 if tokens
251 .iter()
252 .enumerate()
253 .any(|(i, t)| ["cp", "mv", "rm", "touch", "mkdir"].contains(t) && command_position(i))
254 {
255 return true;
256 }
257 tokens.iter().enumerate().any(|(i, t)| {
258 let target = if *t == ">" || *t == ">>" {
259 tokens.get(i + 1).copied()
260 } else {
261 t.strip_prefix(">>").or_else(|| t.strip_prefix('>'))
262 };
263 target.is_some_and(|x| !x.is_empty() && !x.starts_with("/dev/") && !x.starts_with('&'))
264 })
265}
266
267/// Classify one shell command by its text alone; nothing is run. A command that writes a
268/// file is an edit whatever else it does, so a write outranks the read or search it starts
269/// with; otherwise the leading command decides, and a pipe into `grep` makes a search. A
270/// redirection to `/dev/null` or to another descriptor (`2>&1`) writes nothing and is not an
271/// edit, and neither is a `>` inside a quoted argument, which is text, not a redirection.
272///
273/// ```
274/// use majordomus_cli::economics::usage::{shell_effect, ShellEffect};
275/// assert_eq!(shell_effect("cat > tests/test_x.py <<'EOF'"), ShellEffect::Edit);
276/// assert_eq!(shell_effect("sed -i '' 's/a/b/' f.py"), ShellEffect::Edit);
277/// assert_eq!(shell_effect("python3 -m unittest -q 2>&1 | tail -3"), ShellEffect::Other);
278/// assert_eq!(shell_effect("grep -rn vat ledgerlite"), ShellEffect::Search);
279/// assert_eq!(shell_effect("cat ledgerlite/tax.py"), ShellEffect::Read);
280/// assert_eq!(shell_effect("cat a.py > b.py"), ShellEffect::Edit, "the write outranks the read");
281/// assert_eq!(shell_effect("ls src > /dev/null"), ShellEffect::Search);
282/// assert_eq!(shell_effect("grep -rn \"total > limit\" ledgerlite"), ShellEffect::Search);
283/// assert_eq!(shell_effect("echo done > 'notes.md'"), ShellEffect::Edit, "a quoted target");
284/// ```
285pub fn shell_effect(cmd: &str) -> ShellEffect {
286 let c = cmd.trim_start();
287 if writes(c) {
288 return ShellEffect::Edit;
289 }
290 let first = |s: &str| c == s || c.starts_with(&format!("{s} "));
291 if SHELL_SEARCHES.iter().any(|s| first(s)) || c.contains("git ls-files") || c.contains("| grep")
292 {
293 return ShellEffect::Search;
294 }
295 if ["cat", "head", "tail", "less", "sed -n", "nl", "wc"]
296 .iter()
297 .any(|s| first(s))
298 {
299 return ShellEffect::Read;
300 }
301 ShellEffect::Other
302}
303
304fn is_search(name: &str, input: &Value) -> bool {
305 if SEARCHES.contains(&name) {
306 return true;
307 }
308 name == "Bash"
309 && shell_effect(input.get("command").and_then(Value::as_str).unwrap_or(""))
310 == ShellEffect::Search
311}
312
313fn is_read(name: &str, input: &Value) -> bool {
314 READS.contains(&name)
315 || (name == "Bash"
316 && shell_effect(input.get("command").and_then(Value::as_str).unwrap_or(""))
317 == ShellEffect::Read)
318}
319
320fn is_edit(name: &str, input: &Value) -> bool {
321 EDITS.contains(&name)
322 || (name == "Bash"
323 && shell_effect(input.get("command").and_then(Value::as_str).unwrap_or(""))
324 == ShellEffect::Edit)
325}
326
327/// Read a Claude Code `--output-format stream-json --verbose` transcript. Lines that are not
328/// JSON objects are skipped; a transcript with neither a request nor a `result` event is an
329/// error, because a session that reported nothing must be recorded as having reported
330/// nothing, not as having used zero tokens.
331///
332/// ```
333/// use majordomus_cli::economics::usage::read_stream;
334/// assert!(read_stream("").is_err(), "a silent transcript is not zero tokens");
335/// let stream = concat!(
336/// r#"{"type":"system","subtype":"init","tools":["Read","Bash"],"#,
337/// r#""mcp_servers":[{"name":"majordomus"}]}"#, "\n",
338/// "not json\n",
339/// r#"{"type":"result","subtype":"error_max_turns","is_error":true,"num_turns":9}"#,
340/// );
341/// let facts = read_stream(stream).unwrap();
342/// assert_eq!(facts.tools_offered, ["Read", "Bash"]);
343/// assert_eq!(facts.mcp_servers, ["majordomus"]);
344/// let ending = (facts.ended.as_str(), facts.is_error, facts.turns);
345/// assert_eq!(ending, ("error_max_turns", true, Some(9)));
346/// assert!(facts.requests.is_empty() && facts.reported_cost_microusd.is_none());
347/// ```
348pub fn read_stream(text: &str) -> Result<StreamFacts, String> {
349 let mut facts = StreamFacts::default();
350 let mut index: BTreeMap<String, usize> = BTreeMap::new();
351 let mut saw_result = false;
352 let mut edited = false;
353 let mut before_edit_requests: BTreeSet<String> = BTreeSet::new();
354 for (line_no, line) in text.lines().enumerate() {
355 let Ok(event) = serde_json::from_str::<Value>(line) else {
356 continue;
357 };
358 match event.get("type").and_then(Value::as_str) {
359 Some("system") if event.get("subtype").and_then(Value::as_str) == Some("init") => {
360 facts.tools_offered = strings(event.get("tools"));
361 facts.mcp_servers = event
362 .get("mcp_servers")
363 .and_then(Value::as_array)
364 .map(|a| {
365 a.iter()
366 .filter_map(|s| s.get("name").and_then(Value::as_str))
367 .map(str::to_string)
368 .collect()
369 })
370 .unwrap_or_default();
371 }
372 Some("assistant") => {
373 let Some(message) = event.get("message") else {
374 continue;
375 };
376 // A message id is what joins the events one request was split into. An
377 // event without one is a request of its own, keyed by its line, so that two
378 // of them are never collapsed into one, and a later one never takes the
379 // place of an earlier one inside or outside the orientation.
380 let id = match message.get("id").and_then(Value::as_str) {
381 Some(id) if !id.is_empty() => id.to_string(),
382 _ => format!("#{}", line_no + 1),
383 };
384 let model = message
385 .get("model")
386 .and_then(Value::as_str)
387 .unwrap_or("unknown");
388 if let Some(usage) = message.get("usage") {
389 let request = anthropic_request(model, usage);
390 match index.get(&id) {
391 Some(&i) => facts.requests[i] = request,
392 None => {
393 index.insert(id.clone(), facts.requests.len());
394 facts.requests.push(request);
395 }
396 }
397 }
398 if !edited {
399 before_edit_requests.insert(id.clone());
400 }
401 let blocks = message.get("content").and_then(Value::as_array);
402 for block in blocks.into_iter().flatten() {
403 if block.get("type").and_then(Value::as_str) != Some("tool_use") {
404 continue;
405 }
406 let name = block
407 .get("name")
408 .and_then(Value::as_str)
409 .unwrap_or("unknown");
410 *facts.tools.entry(name.to_string()).or_insert(0) += 1;
411 if edited {
412 continue;
413 }
414 let input = block.get("input").cloned().unwrap_or(Value::Null);
415 if is_edit(name, &input) {
416 edited = true;
417 facts.orientation.edited = true;
418 continue;
419 }
420 facts.orientation.tool_calls += 1;
421 if is_read(name, &input) {
422 facts.orientation.reads += 1;
423 }
424 if is_search(name, &input) {
425 facts.orientation.searches += 1;
426 }
427 }
428 }
429 Some("result") => {
430 saw_result = true;
431 facts.turns = event.get("num_turns").and_then(Value::as_u64);
432 facts.is_error = event
433 .get("is_error")
434 .and_then(Value::as_bool)
435 .unwrap_or(false);
436 facts.ended = event
437 .get("subtype")
438 .and_then(Value::as_str)
439 .unwrap_or("unknown")
440 .to_string();
441 facts.reported_cost_microusd = microusd(event.get("total_cost_usd"));
442 if let Some(models) = event.get("modelUsage").and_then(Value::as_object) {
443 facts.models = models
444 .iter()
445 .map(|(model, m)| EconomicsModelTotal {
446 model: model.clone(),
447 input_tokens: u(m, "inputTokens"),
448 cache_creation_input_tokens: u(m, "cacheCreationInputTokens"),
449 cache_read_input_tokens: u(m, "cacheReadInputTokens"),
450 output_tokens: u(m, "outputTokens"),
451 cost_microusd: microusd(m.get("costUSD")),
452 })
453 .collect();
454 crate::order::canonical(&mut facts.models);
455 }
456 }
457 _ => {}
458 }
459 }
460 if facts.requests.is_empty() && !saw_result {
461 return Err(
462 "the transcript holds no request and no result: the provider reported nothing".into(),
463 );
464 }
465 if !saw_result {
466 facts.ended = "no_result".into();
467 facts.is_error = true;
468 }
469 // an event that carried no usage names no request, and is not counted as one
470 facts.orientation.requests = before_edit_requests
471 .iter()
472 .filter(|id| index.contains_key(*id))
473 .count() as u64;
474 facts.orientation.tokens = facts
475 .requests
476 .iter()
477 .zip(index_ids(&index, facts.requests.len()))
478 .filter(|(_, id)| before_edit_requests.contains(id))
479 .map(|(r, _)| request_total(r))
480 .sum();
481 Ok(facts)
482}
483
484fn index_ids(index: &BTreeMap<String, usize>, n: usize) -> Vec<String> {
485 let mut ids = vec![String::new(); n];
486 for (id, &i) in index {
487 ids[i] = id.clone();
488 }
489 ids
490}
491
492fn strings(v: Option<&Value>) -> Vec<String> {
493 v.and_then(Value::as_array)
494 .map(|a| {
495 a.iter()
496 .filter_map(Value::as_str)
497 .map(str::to_string)
498 .collect()
499 })
500 .unwrap_or_default()
501}
502
503/// A session's totals. The harness's per-model totals are preferred, because they include
504/// the calls the harness makes on its own account (titles, summaries) that the transcript
505/// does not show as messages; the per-request sum is used only when they are absent. `None`
506/// when the session reported neither: absence stays absence.
507///
508/// ```
509/// use majordomus_cli::economics::model::{EconomicsModelTotal, EconomicsSession};
510/// use majordomus_cli::economics::usage::{anthropic_request, session_totals};
511/// # let session = |requests| EconomicsSession {
512/// # index: 1, prompt_sha256: String::new(), duration_ms: 0, turns: None,
513/// # ended: "success".into(), is_error: false, requests, models: vec![],
514/// # reported_cost_microusd: None, tools: Default::default(), orientation: Default::default(),
515/// # };
516/// let usage = serde_json::json!({"input_tokens": 5, "cache_read_input_tokens": 90,
517/// "output_tokens": 10});
518/// let mut s = session(vec![anthropic_request("m", &usage)]);
519/// assert_eq!(session_totals(&s).map(|t| (t.total_input, t.total)), Some((95, 105)));
520/// s.models.push(EconomicsModelTotal { model: "m".into(), input_tokens: 25,
521/// cache_creation_input_tokens: 0, cache_read_input_tokens: 90, output_tokens: 12,
522/// cost_microusd: None });
523/// assert_eq!(session_totals(&s).unwrap().total, 127, "the harness's totals win");
524/// s.models.clear();
525/// s.requests.clear();
526/// assert_eq!(session_totals(&s), None, "absence stays absence");
527/// ```
528pub fn session_totals(s: &EconomicsSession) -> Option<EconomicsUsage> {
529 let mut t = EconomicsUsage::default();
530 if !s.models.is_empty() {
531 for m in &s.models {
532 t.input_uncached += m.input_tokens;
533 t.cache_write += m.cache_creation_input_tokens;
534 t.cache_read += m.cache_read_input_tokens;
535 t.output += m.output_tokens;
536 }
537 } else if !s.requests.is_empty() {
538 for r in &s.requests {
539 t.input_uncached += r.input_tokens;
540 t.cache_write += r.cache_creation_input_tokens;
541 t.cache_read += r.cache_read_input_tokens;
542 t.output += r.output_tokens;
543 }
544 } else {
545 return None;
546 }
547 t.reasoning = s
548 .requests
549 .iter()
550 .map(|r| r.thinking_tokens)
551 .sum::<Option<u64>>()
552 .filter(|_| !s.requests.is_empty());
553 t.total_input = t.input_uncached + t.cache_write + t.cache_read;
554 t.total = t.total_input + t.output;
555 t.requests = s.requests.len() as u64;
556 t.tool_calls = s.tools.values().sum();
557 t.cost_microusd = s.reported_cost_microusd;
558 Some(t)
559}
560
561/// A run's totals: the sum of its sessions'. `None` when any session reported nothing,
562/// because a partial sum would understate the run.
563///
564/// ```
565/// use majordomus_cli::economics::model::{EconomicsRun, EconomicsSession};
566/// use majordomus_cli::economics::usage::{anthropic_request, totals};
567/// # let mut run: EconomicsRun = serde_json::from_value(serde_json::json!({
568/// # "schema": "economics-run/v1", "id": "t--control--r1", "suite": "pilot",
569/// # "suite_version": 1, "methodology": 1, "task": "t", "variant": "control",
570/// # "repetition": 1, "provider": "anthropic",
571/// # "harness": {"name": "claude-code", "version": "0"}, "model_requested": "m",
572/// # "models_reported": ["m"], "repository": {"commit": "0", "dirty": false},
573/// # "majordomus_version": "0", "fixture_digest": "", "inputs_digest": "",
574/// # "configuration_digest": "", "started_at": "", "finished_at": "", "sessions": [],
575/// # "outcome": {"completed": true, "checks": [], "changed_files": []}
576/// # })).unwrap();
577/// # let session = |input: u64, output: u64| EconomicsSession {
578/// # index: 1, prompt_sha256: String::new(), duration_ms: 0, turns: None,
579/// # ended: "success".into(), is_error: false, models: vec![],
580/// # reported_cost_microusd: None, tools: Default::default(), orientation: Default::default(),
581/// # requests: vec![anthropic_request("m", &serde_json::json!({
582/// # "input_tokens": input, "output_tokens": output}))],
583/// # };
584/// // two sessions, of (input, output) tokens (100, 20) and (50, 10)
585/// run.sessions = vec![session(100, 20), session(50, 10)];
586/// assert_eq!(totals(&run).map(|t| (t.total, t.requests)), Some((180, 2)));
587/// run.sessions[1].requests.clear();
588/// assert_eq!(totals(&run), None, "one silent session voids the sum rather than shrinking it");
589/// ```
590pub fn totals(run: &EconomicsRun) -> Option<EconomicsUsage> {
591 let mut out: Option<EconomicsUsage> = None;
592 for s in &run.sessions {
593 let t = session_totals(s)?;
594 out = Some(match out {
595 None => t,
596 Some(acc) => add(&acc, &t),
597 });
598 }
599 out
600}
601
602/// The field-wise sum of two totals. Reasoning and cost stay known only when both are: an
603/// unknown on either side makes the sum unknown, never a smaller number.
604///
605/// ```
606/// use majordomus_cli::economics::model::EconomicsUsage;
607/// use majordomus_cli::economics::usage::add;
608/// let a = EconomicsUsage { total: 10, output: 2, cost_microusd: Some(40), ..Default::default() };
609/// let b = EconomicsUsage { total: 5, output: 1, cost_microusd: None, ..Default::default() };
610/// let sum = add(&a, &b);
611/// assert_eq!((sum.total, sum.output), (15, 3));
612/// assert_eq!(sum.cost_microusd, None, "a cost one side lacks is unknown for the sum");
613/// assert_eq!(add(&a, &a).cost_microusd, Some(80));
614/// ```
615pub fn add(a: &EconomicsUsage, b: &EconomicsUsage) -> EconomicsUsage {
616 EconomicsUsage {
617 input_uncached: a.input_uncached + b.input_uncached,
618 cache_write: a.cache_write + b.cache_write,
619 cache_read: a.cache_read + b.cache_read,
620 output: a.output + b.output,
621 reasoning: a.reasoning.zip(b.reasoning).map(|(x, y)| x + y),
622 total_input: a.total_input + b.total_input,
623 total: a.total + b.total,
624 requests: a.requests + b.requests,
625 tool_calls: a.tool_calls + b.tool_calls,
626 cost_microusd: a.cost_microusd.zip(b.cost_microusd).map(|(x, y)| x + y),
627 }
628}
629
630/// The input tokens of a session's first request: everything the session was given before
631/// the model had done anything (system prompt, instruction files, hook output, the prompt).
632/// The three disjoint input fields are summed; the request's output is not input and is
633/// left out. `None` when the session made no request.
634///
635/// ```
636/// use majordomus_cli::economics::model::EconomicsSession;
637/// use majordomus_cli::economics::usage::{anthropic_request, first_request_input};
638/// # let session = |requests| EconomicsSession {
639/// # index: 1, prompt_sha256: String::new(), duration_ms: 0, turns: None,
640/// # ended: "success".into(), is_error: false, requests, models: vec![],
641/// # reported_cost_microusd: None, tools: Default::default(), orientation: Default::default(),
642/// # };
643/// let first = serde_json::json!({"input_tokens": 4, "cache_creation_input_tokens": 12000,
644/// "cache_read_input_tokens": 0, "output_tokens": 300});
645/// let second = serde_json::json!({"input_tokens": 9, "cache_read_input_tokens": 12000});
646/// let mut s = session(vec![anthropic_request("m", &first), anthropic_request("m", &second)]);
647/// assert_eq!(first_request_input(&s), Some(12_004), "output and later requests excluded");
648/// s.requests.clear();
649/// assert_eq!(first_request_input(&s), None);
650/// ```
651pub fn first_request_input(s: &EconomicsSession) -> Option<u64> {
652 s.requests
653 .first()
654 .map(|r| r.input_tokens + r.cache_creation_input_tokens + r.cache_read_input_tokens)
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn assistant(id: &str, blocks: &str, usage: (u64, u64, u64, u64)) -> String {
662 format!(
663 r#"{{"type":"assistant","message":{{"id":"{id}","model":"claude-sonnet-5","content":[{blocks}],"usage":{{"input_tokens":{},"cache_creation_input_tokens":{},"cache_read_input_tokens":{},"output_tokens":{}}}}}}}"#,
664 usage.0, usage.1, usage.2, usage.3
665 )
666 }
667
668 const RESULT: &str = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"total_cost_usd":0.5,"modelUsage":{"claude-sonnet-5":{"inputTokens":6,"outputTokens":30,"cacheReadInputTokens":300,"cacheCreationInputTokens":200,"costUSD":0.45},"claude-haiku-4-5":{"inputTokens":50,"outputTokens":5,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"costUSD":0.05}}}"#;
669
670 fn stream() -> String {
671 [
672 r#"{"type":"system","subtype":"init","tools":["Read","Edit","Bash"],"mcp_servers":[]}"#.to_string(),
673 assistant("m1", r#"{"type":"text","text":"let me look"}"#, (2, 200, 0, 10)),
674 assistant("m1", r#"{"type":"tool_use","name":"Read","input":{"file_path":"/repo/secret.env"}}"#, (2, 200, 0, 10)),
675 r#"{"type":"user","message":{"content":[{"type":"tool_result","content":"API_KEY=sk-live-123"}]}}"#.to_string(),
676 assistant("m2", r#"{"type":"tool_use","name":"Bash","input":{"command":"grep -rn vat ."}}"#, (2, 0, 150, 10)),
677 assistant("m3", r#"{"type":"tool_use","name":"Edit","input":{"file_path":"a.py"}}"#, (2, 0, 150, 10)),
678 RESULT.to_string(),
679 ]
680 .join("\n")
681 }
682
683 fn session(facts: StreamFacts) -> EconomicsSession {
684 EconomicsSession {
685 index: 1,
686 prompt_sha256: String::new(),
687 duration_ms: 0,
688 turns: facts.turns,
689 ended: facts.ended,
690 is_error: facts.is_error,
691 requests: facts.requests,
692 models: facts.models,
693 reported_cost_microusd: facts.reported_cost_microusd,
694 tools: facts.tools,
695 orientation: facts.orientation,
696 }
697 }
698
699 #[test]
700 fn a_message_split_across_content_blocks_is_one_request() {
701 let f = read_stream(&stream()).unwrap();
702 assert_eq!(f.requests.len(), 3, "m1 appears twice and is counted once");
703 let summed: u64 = f.requests.iter().map(request_total).sum();
704 assert_eq!(summed, 212 + 162 + 162);
705 }
706
707 #[test]
708 fn orientation_ends_at_the_first_edit() {
709 let f = read_stream(&stream()).unwrap();
710 assert!(f.orientation.edited);
711 assert_eq!(f.orientation.tool_calls, 2, "Read and Bash, not the Edit");
712 assert_eq!(f.orientation.reads, 1);
713 assert_eq!(
714 f.orientation.searches, 1,
715 "a Bash call leading with grep is a search"
716 );
717 assert_eq!(
718 f.orientation.requests, 3,
719 "the request that made the edit is included"
720 );
721 }
722
723 #[test]
724 fn an_edit_made_through_the_shell_ends_orientation_too() {
725 let text = [
726 assistant("m1", r#"{"type":"tool_use","name":"Bash","input":{"command":"cat ledgerlite/tax.py; grep -rn vat ."}}"#, (1, 1, 1, 1)),
727 assistant("m2", r#"{"type":"tool_use","name":"Bash","input":{"command":"python3 - <<'EOF'\np='a.py'\ns=open(p).read()\nopen(p,\"w\").write(s)\nEOF"}}"#, (1, 1, 1, 1)),
728 assistant("m3", r#"{"type":"tool_use","name":"Bash","input":{"command":"cat a.py"}}"#, (1, 1, 1, 1)),
729 RESULT.to_string(),
730 ]
731 .join("\n");
732 let f = read_stream(&text).unwrap();
733 assert!(f.orientation.edited);
734 assert_eq!(f.orientation.tool_calls, 1);
735 assert_eq!(
736 f.orientation.reads, 1,
737 "a command that reads first is a read"
738 );
739 assert_eq!(f.orientation.requests, 2);
740 }
741
742 #[test]
743 fn redirections_to_nowhere_are_not_edits() {
744 for c in [
745 "python3 -m unittest 2>&1 | tail -3",
746 "ls > /dev/null",
747 "git status 2>/dev/null",
748 "echo hi >&2",
749 "grep -rn 'long term ' docs",
750 ] {
751 assert_ne!(shell_effect(c), ShellEffect::Edit, "{c}");
752 }
753 for c in [
754 "echo x > a.txt",
755 "printf 'y' >>notes.md",
756 "cat > tests/t.py <<'EOF'",
757 "cd a && rm b.py",
758 "mv a.py b.py",
759 ] {
760 assert_eq!(shell_effect(c), ShellEffect::Edit, "{c}");
761 }
762 }
763
764 #[test]
765 fn shell_syntax_inside_quotes_is_text() {
766 for c in [
767 r#"grep -rn "total > limit" ledgerlite"#,
768 "grep -rn '>' docs",
769 "grep -c ' >>x' notes",
770 r#"echo "a; rm b.py""#,
771 r#"git log --format="%h > %s" -3"#,
772 r#"grep -n "say \"a > b\"" f"#,
773 "grep 'unclosed > quote",
774 ] {
775 assert_ne!(shell_effect(c), ShellEffect::Edit, "{c}");
776 }
777 for c in [
778 "echo done > 'notes.md'",
779 r#"printf "%s" x >"out file.txt""#,
780 "echo '>' > a.txt",
781 ] {
782 assert_eq!(shell_effect(c), ShellEffect::Edit, "{c}");
783 }
784 assert_eq!(unquoted(r#"a 'b > c' "d \" > e" f"#), "a _ _ f");
785 }
786
787 #[test]
788 fn requests_without_a_message_id_are_neither_collapsed_nor_misattributed() {
789 let anonymous = |blocks: &str, usage: (u64, u64, u64, u64)| {
790 assistant("", blocks, usage).replace(r#""id":"","#, "")
791 };
792 let text = [
793 anonymous(
794 r#"{"type":"tool_use","name":"Read","input":{"file_path":"a.py"}}"#,
795 (10, 0, 0, 1),
796 ),
797 anonymous(
798 r#"{"type":"tool_use","name":"Edit","input":{"file_path":"a.py"}}"#,
799 (20, 0, 0, 2),
800 ),
801 anonymous(r#"{"type":"text","text":"done"}"#, (40, 0, 0, 4)),
802 assistant(
803 "",
804 r#"{"type":"text","text":"an empty id is no id"}"#,
805 (80, 0, 0, 8),
806 ),
807 RESULT.to_string(),
808 ]
809 .join("\n");
810 assert!(
811 !text.lines().next().unwrap().contains(r#""id""#),
812 "the first event has no id"
813 );
814 let f = read_stream(&text).unwrap();
815 let inputs: Vec<u64> = f.requests.iter().map(|r| r.input_tokens).collect();
816 assert_eq!(
817 inputs,
818 [10, 20, 40, 80],
819 "four requests, none collapsed into another"
820 );
821 assert_eq!(
822 f.orientation.requests, 2,
823 "the read and the request that made the edit"
824 );
825 assert_eq!(
826 f.orientation.tokens,
827 11 + 22,
828 "nothing after the edit is orientation"
829 );
830 }
831
832 #[test]
833 fn an_event_without_usage_is_not_an_orientation_request() {
834 let text = [
835 r#"{"type":"assistant","message":{"id":"m0","model":"m","content":[]}}"#.to_string(),
836 assistant(
837 "m1",
838 r#"{"type":"tool_use","name":"Edit","input":{"file_path":"a.py"}}"#,
839 (1, 0, 0, 1),
840 ),
841 RESULT.to_string(),
842 ]
843 .join("\n");
844 let f = read_stream(&text).unwrap();
845 assert_eq!(f.requests.len(), 1);
846 assert_eq!(
847 f.orientation.requests, 1,
848 "m0 reported no usage and is no request"
849 );
850 }
851
852 #[test]
853 fn the_harness_totals_win_over_the_request_sum_because_they_include_its_own_calls() {
854 let t = session_totals(&session(read_stream(&stream()).unwrap())).unwrap();
855 assert_eq!(t.input_uncached, 56);
856 assert_eq!(t.cache_write, 200);
857 assert_eq!(t.cache_read, 300);
858 assert_eq!(t.output, 35);
859 assert_eq!(t.total_input, 556);
860 assert_eq!(t.total, 591);
861 assert_eq!(t.cost_microusd, Some(500_000));
862 assert_eq!(t.tool_calls, 3);
863 }
864
865 #[test]
866 fn nothing_a_session_said_or_read_survives_into_the_record() {
867 let s = session(read_stream(&stream()).unwrap());
868 let json = serde_json::to_string(&s).unwrap();
869 for leaked in [
870 "secret.env",
871 "sk-live-123",
872 "let me look",
873 "grep -rn",
874 "a.py",
875 ] {
876 assert!(
877 !json.contains(leaked),
878 "{leaked} leaked into the record: {json}"
879 );
880 }
881 }
882
883 #[test]
884 fn a_silent_provider_is_an_error_not_zero_tokens() {
885 assert!(read_stream("").is_err());
886 assert!(read_stream("not json\n{\"type\":\"user\"}\n").is_err());
887 }
888
889 #[test]
890 fn a_transcript_cut_off_before_its_result_is_recorded_as_an_error() {
891 let cut = assistant("m1", r#"{"type":"text","text":"x"}"#, (1, 1, 1, 1));
892 let f = read_stream(&cut).unwrap();
893 assert_eq!(f.ended, "no_result");
894 assert!(f.is_error);
895 }
896
897 #[test]
898 fn a_session_with_no_usage_has_no_totals() {
899 let s = session(StreamFacts::default());
900 assert!(session_totals(&s).is_none());
901 }
902
903 #[test]
904 fn reasoning_is_known_only_when_every_request_reported_it() {
905 let mut s = session(read_stream(&stream()).unwrap());
906 assert_eq!(session_totals(&s).unwrap().reasoning, None);
907 for r in &mut s.requests {
908 r.thinking_tokens = Some(4);
909 }
910 assert_eq!(session_totals(&s).unwrap().reasoning, Some(12));
911 }
912}