From 65642dcf3c9ca0bc009bfd3ef8fce24c59202807 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 11 Nov 2026 23:11:56 +0100 Subject: [PATCH] usage-per-turn: model ids are field-safe and the cache-write TTL split is carried Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/self_run.rs | 14 ++++++++++++-- crates/mind-body/src/self_run/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index fb6bfed..957373e 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -1051,6 +1051,11 @@ fn describe_result(event: &serde_json::Map) -> String { format!("{}: {}", result_head(event), result_text(event)) } +/// A model id as one journal field: every byte outside `[A-Za-z0-9._:/-]` is `%XX`, so a space, `;`, `=` or newline in an id the CLI reports cannot split the line or forge a segment. Real ids pass unchanged. +fn field_safe(id: &str) -> String { + id.bytes().map(|b| if b.is_ascii_alphanumeric() || b"._:/-".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() +} + /// What a result spent, as one journal line: its tokens by model for this turn alone, read from /// `model_usage_since_last_result`. `modelUsage` is the session's running total on a resumed /// session, so it is never the source. Usage the result did not report, or reported @@ -1065,12 +1070,17 @@ fn usage_line(event: &serde_json::Map) -> String { .iter() .map(|(model, counts)| { let count = |name: &str| counts.get(name).and_then(Value::as_u64); + // The 5m and 1h cache writes are priced apart; each is carried when the envelope has it and left out when it does not. + let ttl = |label: &str, name: &str| count(name).map(|n| format!(" cache_creation_{label} {n}")).unwrap_or_default(); Some(format!( - "{model} input {} cache_read {} cache_creation {} output {}", + "{} input {} cache_read {} cache_creation {} output {}{}{}", + field_safe(model), count("inputTokens")?, count("cacheReadInputTokens")?, count("cacheCreationInputTokens")?, - count("outputTokens")? + count("outputTokens")?, + ttl("5m", "cacheCreationEphemeral5mInputTokens"), + ttl("1h", "cacheCreationEphemeral1hInputTokens"), )) }) .collect() diff --git a/crates/mind-body/src/self_run/tests.rs b/crates/mind-body/src/self_run/tests.rs index 16d0339..3bb064c 100644 --- a/crates/mind-body/src/self_run/tests.rs +++ b/crates/mind-body/src/self_run/tests.rs @@ -542,6 +542,29 @@ fn each_model_in_the_turns_usage_is_its_own_segment() { ); } +#[test] +fn a_model_id_cannot_split_the_line_or_forge_a_segment() { + let counts = json!({"inputTokens": 1, "cacheReadInputTokens": 2, "cacheCreationInputTokens": 3, "outputTokens": 4}); + let line = json!({"type": "result", "result_index": 0, "model_usage_since_last_result": {"evil +usage turn 9: x input 9; y=1": counts}}).to_string(); + let journal = journal_of(&line); + assert_eq!(journal.lines().count(), 2, "{journal}"); + assert_eq!(usage_lines(&journal), ["usage turn 0: evil%0Ausage%20turn%209:%20x%20input%209%3B%20y%3D1 input 1 cache_read 2 cache_creation 3 output 4"]); +} + +#[test] +fn the_cache_write_split_is_carried_when_reported_and_left_out_when_not() { + let mut entry = json!({"inputTokens": 1, "cacheReadInputTokens": 2, "cacheCreationInputTokens": 7, "outputTokens": 4}); + let journal = |entry: &Value| journal_of(&json!({"type": "result", "result_index": 0, "model_usage_since_last_result": {"m": entry}}).to_string()); + entry["cacheCreationEphemeral5mInputTokens"] = json!(3); + entry["cacheCreationEphemeral1hInputTokens"] = json!(4); + assert_eq!(usage_lines(&journal(&entry)), ["usage turn 0: m input 1 cache_read 2 cache_creation 7 output 4 cache_creation_5m 3 cache_creation_1h 4"]); + entry.as_object_mut().unwrap().remove("cacheCreationEphemeral5mInputTokens"); + assert_eq!(usage_lines(&journal(&entry)), ["usage turn 0: m input 1 cache_read 2 cache_creation 7 output 4 cache_creation_1h 4"]); + entry.as_object_mut().unwrap().remove("cacheCreationEphemeral1hInputTokens"); + assert_eq!(usage_lines(&journal(&entry)), ["usage turn 0: m input 1 cache_read 2 cache_creation 7 output 4"]); +} + #[test] fn an_unreported_usage_is_said_never_zero() { let whole = json!({"inputTokens": 1, "cacheReadInputTokens": 2, "cacheCreationInputTokens": 3, "outputTokens": 4});