Skip to content

Commit b1c65a0

Browse files
fix(claude): count advisor model usage (#1423)
* fix(claude): count advisor model usage Expand advisor_message iterations into distinct usage entries so their tokens and model-specific costs are included in every report path. Keep main-model iteration totals unchanged and cover both standard and daily loaders. Co-authored-by: ryoppippi <ryoppippi@users.noreply.github.com> * docs(claude): clarify advisor cost modes Co-authored-by: ryoppippi <ryoppippi@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ryoppippi <ryoppippi@users.noreply.github.com>
1 parent 280b8cd commit b1c65a0

4 files changed

Lines changed: 234 additions & 8 deletions

File tree

rust/crates/ccusage/src/adapter/claude/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,14 @@ The term `session` has two meanings in this codebase:
3737
- True Claude Code session ID may also appear in each JSONL entry's `sessionId` field.
3838

3939
Malformed JSONL lines are skipped during parsing.
40+
41+
Advisor entries:
42+
43+
- Advisor usage is stored in `message.usage.iterations` records whose type is
44+
`advisor_message`.
45+
- ccusage counts each advisor record separately under the record's own model.
46+
The top-level usage remains attributed only to the main model, and other
47+
iteration types are not added again.
48+
- Advisor iterations do not include a precomputed `costUSD`, so `auto` and
49+
`calculate` modes price their tokens separately while `display` mode reports
50+
zero cost for them.

rust/crates/ccusage/src/adapter/claude/daily.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ use crate::{
1919
};
2020

2121
use super::{
22-
chunk_file_indexes_by_size, has_unsupported_null_field, is_semver_prefix,
22+
advisor_usages_from_line, chunk_file_indexes_by_size, has_unsupported_null_field,
23+
is_semver_prefix,
2324
paths::{claude_paths, extract_project, usage_files},
2425
usage_dedupe_hash,
2526
};
@@ -299,8 +300,11 @@ fn read_daily_usage_file(
299300
Some(model.clone())
300301
}
301302
});
303+
let date = format_date_tz(timestamp, tz);
304+
let message_id = data.message.id.clone();
305+
let request_id = data.request_id.clone();
302306
loaded_file.entries.push(DailyLoadedEntry {
303-
date: format_date_tz(timestamp, tz),
307+
date: date.clone(),
304308
project: Arc::clone(&project),
305309
usage,
306310
cost,
@@ -310,6 +314,34 @@ fn read_daily_usage_file(
310314
request_id: data.request_id,
311315
is_sidechain: data.is_sidechain,
312316
});
317+
for (index, advisor) in advisor_usages_from_line(line).into_iter().enumerate() {
318+
let missing_pricing_model = missing_pricing_model_for_usage(
319+
Some(&advisor.model),
320+
advisor.usage,
321+
None,
322+
mode,
323+
pricing,
324+
);
325+
loaded_file.entries.push(DailyLoadedEntry {
326+
date: date.clone(),
327+
project: Arc::clone(&project),
328+
usage: advisor.usage,
329+
cost: calculate_cost_for_usage(
330+
Some(&advisor.model),
331+
advisor.usage,
332+
None,
333+
mode,
334+
pricing,
335+
),
336+
model: Some(advisor.model),
337+
missing_pricing_model,
338+
message_id: message_id
339+
.as_ref()
340+
.map(|message_id| format!("{message_id}:advisor:{index}")),
341+
request_id: request_id.clone(),
342+
is_sidechain: data.is_sidechain,
343+
});
344+
}
313345
}
314346
loaded_file
315347
}

rust/crates/ccusage/src/adapter/claude/mod.rs

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ use std::{
1212
use jiff::tz::TimeZone as JiffTimeZone;
1313
use memchr::memmem;
1414
use rustc_hash::FxHasher;
15+
use serde::Deserialize;
1516

1617
use crate::{
17-
LoadedEntry, LoadedFile, PricingMap, Result, Speed, TimestampMs, UsageEntry, UsageSummary,
18-
calculate_cost,
18+
LoadedEntry, LoadedFile, PricingMap, Result, Speed, TimestampMs, TokenUsageRaw, UsageEntry,
19+
UsageSummary, calculate_cost, calculate_cost_for_usage,
1920
cli::{CostMode, SharedArgs},
2021
debug_log,
2122
fast::{FxHashMap, SmallIndexVec, byte_lines, suffix_string},
@@ -385,7 +386,7 @@ fn read_usage_file(
385386
Some(model.clone())
386387
}
387388
});
388-
loaded_file.entries.push(LoadedEntry {
389+
let entry = LoadedEntry {
389390
data,
390391
timestamp,
391392
date,
@@ -399,11 +400,107 @@ fn read_usage_file(
399400
model,
400401
usage_limit_reset_time,
401402
missing_pricing_model,
402-
});
403+
};
404+
let mut advisor_entries = Vec::new();
405+
for (index, advisor) in advisor_usages_from_line(line).into_iter().enumerate() {
406+
let mut advisor_data = entry.data.clone();
407+
advisor_data.message.id = advisor_data
408+
.message
409+
.id
410+
.map(|message_id| format!("{message_id}:advisor:{index}"));
411+
advisor_data.message.model = Some(advisor.model.clone());
412+
advisor_data.message.usage = advisor.usage;
413+
advisor_data.cost_usd = None;
414+
let missing_pricing_model = missing_pricing_model_for_usage(
415+
Some(&advisor.model),
416+
advisor.usage,
417+
None,
418+
mode,
419+
pricing,
420+
);
421+
advisor_entries.push(LoadedEntry {
422+
data: advisor_data,
423+
timestamp,
424+
date: entry.date.clone(),
425+
project: Arc::clone(&project),
426+
session_id: Arc::clone(&session_id),
427+
project_path: Arc::clone(&project_path),
428+
cost: calculate_cost_for_usage(
429+
Some(&advisor.model),
430+
advisor.usage,
431+
None,
432+
mode,
433+
pricing,
434+
),
435+
extra_total_tokens: 0,
436+
credits: None,
437+
message_count: None,
438+
model: Some(advisor.model),
439+
usage_limit_reset_time,
440+
missing_pricing_model,
441+
});
442+
}
443+
loaded_file.entries.push(entry);
444+
loaded_file.entries.extend(advisor_entries);
403445
}
404446
loaded_file
405447
}
406448

449+
#[derive(Debug, Deserialize)]
450+
struct UsageIterationsEnvelope {
451+
message: UsageIterationsMessage,
452+
}
453+
454+
#[derive(Debug, Deserialize)]
455+
struct UsageIterationsMessage {
456+
usage: UsageIterations,
457+
}
458+
459+
#[derive(Debug, Deserialize)]
460+
struct UsageIterations {
461+
#[serde(default)]
462+
iterations: Vec<UsageIteration>,
463+
}
464+
465+
#[derive(Debug, Deserialize)]
466+
struct UsageIteration {
467+
#[serde(rename = "type")]
468+
kind: String,
469+
model: Option<String>,
470+
#[serde(flatten)]
471+
usage: TokenUsageRaw,
472+
}
473+
474+
pub(super) struct AdvisorUsage {
475+
pub(super) model: String,
476+
pub(super) usage: TokenUsageRaw,
477+
}
478+
479+
pub(super) fn advisor_usages_from_line(line: &[u8]) -> Vec<AdvisorUsage> {
480+
if memmem::find(line, br#""advisor_message""#).is_none() {
481+
return Vec::new();
482+
}
483+
let Ok(envelope) = serde_json::from_slice::<UsageIterationsEnvelope>(line) else {
484+
return Vec::new();
485+
};
486+
envelope
487+
.message
488+
.usage
489+
.iterations
490+
.into_iter()
491+
.filter_map(|iteration| {
492+
(iteration.kind == "advisor_message")
493+
.then_some(iteration.model)
494+
.flatten()
495+
.filter(|model| !model.is_empty())
496+
.map(|model| AdvisorUsage {
497+
model,
498+
usage: iteration.usage,
499+
})
500+
})
501+
.collect()
502+
}
503+
407504
fn update_loaded_file_timestamp(loaded_file: &mut LoadedFile, timestamp: TimestampMs) {
408505
loaded_file.timestamp = Some(
409506
loaded_file
@@ -563,9 +660,12 @@ mod tests {
563660

564661
use super::{
565662
extract_session_parts, has_unsupported_null_field, paths::is_project_path_segment,
566-
push_deduped_entry, usage_files,
663+
push_deduped_entry, read_usage_file, usage_files,
664+
};
665+
use crate::{
666+
LoadedEntry, PricingMap, TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage,
667+
cli::CostMode,
567668
};
568-
use crate::{LoadedEntry, TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage};
569669
use ccusage_test_support::fs_fixture;
570670

571671
#[test]
@@ -653,6 +753,38 @@ mod tests {
653753
));
654754
}
655755

756+
#[test]
757+
fn calculates_advisor_cost_with_the_advisor_model() {
758+
let fixture = fs_fixture!({
759+
"projects/project-a/session-a/chat.jsonl": r#"{"timestamp":"2026-05-22T02:34:40.000Z","version":"1.2.3","sessionId":"session-a","message":{"id":"msg-parent","model":"main-model","usage":{"input_tokens":1,"output_tokens":2,"iterations":[{"type":"advisor_message","model":"advisor-model","input_tokens":10,"output_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}]}},"requestId":"req-parent","costUSD":1.23}"#,
760+
});
761+
let mut pricing = PricingMap::default();
762+
pricing.load_json(
763+
r#"{
764+
"main-model": {
765+
"input_cost_per_token": 100,
766+
"output_cost_per_token": 100
767+
},
768+
"advisor-model": {
769+
"input_cost_per_token": 2,
770+
"output_cost_per_token": 3
771+
}
772+
}"#,
773+
);
774+
775+
let loaded = read_usage_file(
776+
&fixture.path("projects/project-a/session-a/chat.jsonl"),
777+
None,
778+
CostMode::Auto,
779+
Some(&pricing),
780+
);
781+
782+
assert_eq!(loaded.entries.len(), 2);
783+
assert_eq!(loaded.entries[0].cost, 1.23);
784+
assert_eq!(loaded.entries[1].model.as_deref(), Some("advisor-model"));
785+
assert_eq!(loaded.entries[1].cost, 26.0);
786+
}
787+
656788
#[test]
657789
fn keeps_parent_usage_when_sidechain_replays_message_with_new_request_id() {
658790
let mut deduped_indexes = Default::default();

rust/crates/ccusage/src/main.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,57 @@ mod tests {
363363
assert_eq!(entries[0].project.as_ref(), "project1");
364364
}
365365

366+
#[test]
367+
fn loads_advisor_usage_as_a_separate_model_entry() {
368+
let fixture = fs_fixture!({
369+
"projects/project1/session1/chat.jsonl": r#"{"timestamp":"2026-05-22T02:34:40.000Z","version":"1.2.3","sessionId":"session1","advisorModel":"claude-opus-4-20250514","message":{"id":"msg_123","model":"claude-sonnet-4-20250514","usage":{"input_tokens":2,"output_tokens":491,"cache_creation_input_tokens":7853,"cache_read_input_tokens":226584,"iterations":[{"type":"message","input_tokens":1,"output_tokens":45,"cache_creation_input_tokens":7192,"cache_read_input_tokens":109696},{"type":"advisor_message","model":"claude-opus-4-20250514","input_tokens":159419,"output_tokens":7805,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},{"type":"message","input_tokens":1,"output_tokens":446,"cache_creation_input_tokens":661,"cache_read_input_tokens":116888}]}},"requestId":"req_456","costUSD":1.23}"#,
370+
});
371+
372+
let _env = EnvVarGuard::set("CLAUDE_CONFIG_DIR", fixture.root());
373+
let shared = SharedArgs {
374+
mode: CostMode::Display,
375+
..SharedArgs::default()
376+
};
377+
let entries = load_entries(&shared, None).unwrap();
378+
379+
assert_eq!(entries.len(), 2);
380+
assert_eq!(
381+
entries[0].model.as_deref(),
382+
Some("claude-sonnet-4-20250514")
383+
);
384+
assert_eq!(entries[0].data.message.usage.input_tokens, 2);
385+
assert_eq!(entries[0].data.message.usage.output_tokens, 491);
386+
assert_eq!(entries[1].model.as_deref(), Some("claude-opus-4-20250514"));
387+
assert_eq!(entries[1].data.message.usage.input_tokens, 159_419);
388+
assert_eq!(entries[1].data.message.usage.output_tokens, 7_805);
389+
}
390+
391+
#[test]
392+
fn includes_advisor_usage_in_daily_summaries() {
393+
let fixture = fs_fixture!({
394+
"projects/project1/session1/chat.jsonl": r#"{"timestamp":"2026-05-22T02:34:40.000Z","version":"1.2.3","sessionId":"session1","advisorModel":"claude-opus-4-20250514","message":{"id":"msg_123","model":"claude-sonnet-4-20250514","usage":{"input_tokens":2,"output_tokens":491,"cache_creation_input_tokens":7853,"cache_read_input_tokens":226584,"iterations":[{"type":"message","input_tokens":1,"output_tokens":45,"cache_creation_input_tokens":7192,"cache_read_input_tokens":109696},{"type":"advisor_message","model":"claude-opus-4-20250514","input_tokens":159419,"output_tokens":7805,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},{"type":"message","input_tokens":1,"output_tokens":446,"cache_creation_input_tokens":661,"cache_read_input_tokens":116888}]}},"requestId":"req_456","costUSD":1.23}"#,
395+
});
396+
397+
let _env = EnvVarGuard::set("CLAUDE_CONFIG_DIR", fixture.root());
398+
let shared = SharedArgs {
399+
mode: CostMode::Display,
400+
timezone: Some("UTC".to_string()),
401+
..SharedArgs::default()
402+
};
403+
let daily = load_daily_summaries(&shared, None, false).unwrap();
404+
405+
assert_eq!(daily.len(), 1);
406+
assert_eq!(daily[0].input_tokens, 159_421);
407+
assert_eq!(daily[0].output_tokens, 8_296);
408+
assert_eq!(
409+
daily[0].models_used,
410+
vec![
411+
"claude-sonnet-4-20250514".to_string(),
412+
"claude-opus-4-20250514".to_string(),
413+
]
414+
);
415+
}
416+
366417
#[test]
367418
fn loads_daily_summaries_like_loaded_entry_aggregation() {
368419
let fixture = fs_fixture!({

0 commit comments

Comments
 (0)