usage-per-turn: model ids are field-safe and the cache-write TTL split is carried

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-11 23:11:56 +01:00
commit 65642dcf3c
2 changed files with 35 additions and 2 deletions

View file

@ -1051,6 +1051,11 @@ fn describe_result(event: &serde_json::Map<String, Value>) -> 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, Value>) -> 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()

View file

@ -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});