Skip to main content

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}