[Bug]: Usage ignores Claude advisor calls, so advisor-model tokens and cost are missing#24129

opened6d agobyIizzat316

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

Minimal fixture: this transcript line, anonymised from a real one (a claude-opus-5-5 turn that consulted a claude-fable-5-1 advisor once; IDs and content removed):

json
1{"type":"assistant","timestamp":"2026-10-22T01:31:11.984Z","sessionId":"…","requestId":"req_…","message":{"id":"msg_…","model":"claude-opus-5-5","usage":{"input_tokens":4,"cache_read_input_tokens":118320,"cache_creation_input_tokens":2676,"output_tokens":4506,"speed":"standard","iterations":[{"type":"message","input_tokens":2,"cache_read_input_tokens":58352,"cache_creation_input_tokens":1616,"output_tokens":141},{"type":"advisor_message","model":"claude-fable-5-1","input_tokens":54204,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":4229},{"type":"message","input_tokens":2,"cache_read_input_tokens":59968,"cache_creation_input_tokens":1060,"output_tokens":4365}]}}}
  1. Pass the line to parseClaudeLine in apps/server/src/provider/Drivers/claudeUsage.ts, or append it to a .jsonl file under ~/.claude/projects/ and open Usage for a range that includes 2026-10-22.
  2. Check the returned records, or the Claude model breakdown in Usage.

Live repro: set "advisorModel": "fable" in ~/.claude/settings.json and run Claude threads in which the model calls the advisor tool. No claude-fable-5-1 row appears in Usage.

Expected behavior

The advisor iteration counts as its own claude-fable-5-1 usage, priced at Fable's rates, next to the executor's claude-opus-5-5 usage. For the fixture: Opus 4 input / 118,320 cache read / 2,676 cache write / 4,506 output, plus Fable 54,204 input / 4,229 output, about $0.13 + $0.75 at T3's rates.

Nothing is counted twice. The advisor tool docs say "Top-level usage fields reflect executor tokens only. Advisor tokens are not rolled into the top-level totals because they are billed at a different rate", and that advisor_message iterations are billed at the advisor model's rates. In every advisor turn in my transcripts (54 at the time of writing), the top-level counts equal the sum of the message iterations.

Actual behavior

Only the executor usage is counted: one claude-opus-5-5 record with the top-level counts (about $0.13). The advisor's 54,204 input and 4,229 output tokens are dropped, and no claude-fable-5-1 row appears.

parseClaudeRecord takes the model from message.model and the tokens from the four top-level usage fields. Nothing in claudeUsage.ts or under apps/server/src/usage reads usage.iterations (main @ eb1510d24).

On my machine, Usage shows $378.28 for 2026-09-24 to 2026-10-23. A read-only mirror of the reader reproduces that ($378.32) and finds 51 advisor calls it skips in the same window (46 Fable, 5 Opus): 17,122,289 input tokens, all uncached, and 374,209 output tokens, or $178.72 at T3's own rates. That is about 32% of the window's estimated spend. The output is under Logs and the script under supporting files.

Related: #10488 fixed this (advisor iterations as separate records with their own dedupe keys, plus a scan-cache version bump) but was closed unmerged on 2026-10-11 in the cleanup of PRs made with older models. #20246 is a separate dedupe bug; on my data the kept copy always carries the complete advisor iterations, so this does not depend on it. The plan-limit bars (Session / Weekly / Weekly · Fable) come from Anthropic and are not part of this report.

Impact

Minor bug or occasional failure

Version or commit

0.1.2-nightly.20261022.3526 (desktop); same code on main @ eb1510d24

Environment

macOS 26.6.2, T3 Code Nightly desktop, Claude Code 2.1.304–2.1.309 via the Agent SDK, Opus 5.5 / Sonnet 5.5 main models with a Fable 5.1 advisor

Logs or stack traces

shell
1$ python3 -I repro_usage.py ~/.claude/projects <T3 userdata>/usage-model-rates.json 2026-09-24 2026-10-23 2026-10-23T11:50:25+00:00
2# (trimmed to the relevant sections)
3== dashboard reproduction, days bucketed in utc (fast records: 0) ==
4 claude-haiku-5-5: $0.01 tokens 0.1M
5 claude-opus-5-5: $291.24 tokens 865.3M
6 claude-sonnet-5-5: $87.07 tokens 517.1M
7 TOTAL $378.32 sessions 11 split input $0.03, read $220.73, write $82.39, output $75.17
8
9== advisor iterations, dashboard window (utc) ==
10 calls 51 (in subagent transcripts: 8; present in T3's kept first copy: 51)
11 advisor output tokens: best copy 374,209 vs T3's kept first copy 374,209
12 models {'claude-fable-5-1': 46, 'claude-opus-5-5': 5}
13 tokens input_tokens 17,122,289, cache_read_input_tokens 0, cache_creation_input_tokens 0, output_tokens 374,209
14 cost at T3's own rates: claude-fable-5-1 $171.24, claude-opus-5-5 $7.48; total $178.72
15
16advisor turns where top-level != sum of executor iterations: 0 of 54

Screenshots, recordings, or supporting files

repro_usage.py: read-only mirror of the Claude usage reader (stdlib Python)
python
1"""Mirror T3 Code's Claude usage reader over ~/.claude/projects, then tally the advisor
2iterations it ignores. Read-only.
3
4usage: python3 -I repro_usage.py <projects_dir> <rates_json> <since_day> <until_day> <cutoff_iso>
5"""
6import collections
7import datetime as dt
8import json
9import os
10import sys
11
12projects, rates_path, since_day, until_day, cutoff_iso = sys.argv[1:6]
13cutoff = dt.datetime.fromisoformat(cutoff_iso)
14TZS = {"utc": dt.timezone.utc} # days are bucketed in UTC
15
16doc = json.load(open(rates_path))["document"]
17
18
19def rates(model):
20 e = doc.get(model.split("[")[0])
21 if e is None:
22 return None
23 inp, out = e["input_cost_per_token"], e["output_cost_per_token"]
24 return dict(
25 input=inp,
26 output=out,
27 read=e.get("cache_read_input_token_cost", inp),
28 write=e.get("cache_creation_input_token_cost", inp),
29 write_1h=e.get("cache_creation_input_token_cost_above_1hr"),
30 )
31
32
33def files(root):
34 # Same depth-first readdir walk as usageTranscriptReader.ts.
35 for entry in os.scandir(root):
36 if entry.is_dir(follow_symlinks=False):
37 yield from files(entry.path)
38 elif entry.name.endswith(".jsonl"):
39 yield entry.path
40
41
42def ts(record):
43 t = record.get("timestamp")
44 if not isinstance(t, str):
45 return None
46 try:
47 return dt.datetime.fromisoformat(t.replace("Z", "+00:00"))
48 except ValueError:
49 return None
50
51
52FIELDS = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens")
53first = {} # dedupe key -> first parsed record (what T3 keeps)
54copies = collections.defaultdict(list) # dedupe key -> every copy's usage (for max-per-field)
55no_key = []
56cost_usd_seen = 0
57for path in files(projects):
58 for line in open(path, errors="ignore"):
59 if '"usage"' not in line:
60 continue
61 try:
62 r = json.loads(line)
63 except ValueError:
64 continue
65 if not isinstance(r, dict) or r.get("type") != "assistant":
66 continue
67 m = r.get("message")
68 if not isinstance(m, dict) or not isinstance(m.get("usage"), dict):
69 continue
70 t = ts(r)
71 model = m.get("model") if isinstance(m.get("model"), str) else ""
72 if t is None or not model or model == "<synthetic>":
73 continue
74 if isinstance(r.get("costUSD"), (int, float)):
75 cost_usd_seen += 1
76 mid, rid = m.get("id"), r.get("requestId")
77 mid = mid if isinstance(mid, str) else None
78 rid = rid if isinstance(rid, str) else None
79 rec = dict(t=t, model=model, session=r.get("sessionId") or "", usage=m["usage"], path=path)
80 if mid is None and rid is None:
81 no_key.append(rec)
82 continue
83 key = f"{mid or ''}:{rid or ''}"
84 copies[key].append(rec)
85 first.setdefault(key, rec)
86
87print(f"records with costUSD: {cost_usd_seen}; keyless records: {len(no_key)}")
88
89
90def in_window(t, tz):
91 day = t.astimezone(tz).date().isoformat()
92 return since_day <= day <= until_day and t <= cutoff
93
94
95def n(v):
96 return v if isinstance(v, int) and v >= 0 else 0
97
98
99# 1. Reproduce the dashboard (first copy per key, top-level usage only).
100for tzname, tz in TZS.items():
101 per_model = collections.defaultdict(lambda: collections.Counter())
102 sessions = set()
103 fast = 0
104 for rec in list(first.values()) + no_key:
105 if not in_window(rec["t"], tz):
106 continue
107 u = rec["usage"]
108 c = per_model[rec["model"]]
109 for f in FIELDS:
110 c[f] += n(u.get(f))
111 sessions.add(rec["session"])
112 fast += u.get("speed") == "fast"
113 print(f"\n== dashboard reproduction, days bucketed in {tzname} (fast records: {fast}) ==")
114 total = 0.0
115 split = collections.Counter()
116 for model, c in sorted(per_model.items()):
117 r = rates(model)
118 if r is None:
119 print(f" {model}: UNPRICED {dict(c)}")
120 continue
121 parts = dict(
122 input=c["input_tokens"] * r["input"],
123 read=c["cache_read_input_tokens"] * r["read"],
124 write=c["cache_creation_input_tokens"] * r["write"],
125 output=c["output_tokens"] * r["output"],
126 )
127 split.update(parts)
128 cost = sum(parts.values())
129 total += cost
130 toks = sum(c[f] for f in FIELDS)
131 print(f" {model}: ${cost:,.2f} tokens {toks/1e6:,.1f}M")
132 print(f" TOTAL ${total:,.2f} sessions {len(sessions)} split " + ", ".join(f"{k} ${v:,.2f}" for k, v in split.items()))
133
134# 2. Advisor iterations, using the copy with the most iterations/tokens for each key.
135def best_copy(recs):
136 def weight(rec):
137 its = rec["usage"].get("iterations") or []
138 return (len(its), sum(n(it.get("output_tokens")) for it in its if isinstance(it, dict)))
139 return max(recs, key=weight)
140
141
142def adv_cost(it):
143 r = rates(it.get("model") or "")
144 if r is None:
145 return 0.0
146 return (
147 n(it.get("input_tokens")) * r["input"]
148 + n(it.get("cache_read_input_tokens")) * r["read"]
149 + n(it.get("cache_creation_input_tokens")) * r["write"]
150 + n(it.get("output_tokens")) * r["output"]
151 )
152
153
154iter_types = collections.Counter()
155mismatch = checked = 0
156for scope, tz in (("all time", None), ("dashboard window (utc)", TZS["utc"])):
157 calls = 0
158 tot = collections.Counter()
159 first_copy_calls = 0
160 first_copy_out = 0
161 models = collections.Counter()
162 cost_by_model = collections.Counter()
163 in_subagent = 0
164 for key, recs in copies.items():
165 rec = best_copy(recs)
166 if tz is not None and not in_window(rec["t"], tz):
167 continue
168 its = [it for it in (rec["usage"].get("iterations") or []) if isinstance(it, dict)]
169 adv = [it for it in its if it.get("type") == "advisor_message"]
170 if not adv:
171 continue
172 if tz is None:
173 for it in its:
174 iter_types[it.get("type")] += 1
175 # Top-level usage should equal the sum over executor ("message") iterations.
176 checked += 1
177 execs = [it for it in its if it.get("type") == "message"]
178 if any(n(rec["usage"].get(f)) != sum(n(it.get(f)) for it in execs) for f in FIELDS):
179 mismatch += 1
180 fc = first[key]["usage"].get("iterations") or []
181 fc_adv = [it for it in fc if isinstance(it, dict) and it.get("type") == "advisor_message"]
182 first_copy_calls += len(fc_adv)
183 first_copy_out += sum(n(it.get("output_tokens")) for it in fc_adv)
184 calls += len(adv)
185 in_subagent += len(adv) * ("/subagents/" in rec["path"])
186 for it in adv:
187 models[it.get("model")] += 1
188 cost_by_model[it.get("model")] += adv_cost(it)
189 if tz is None and it.get("model") != "claude-fable-5-1":
190 print(f" non-Fable advisor: {rec['t'].astimezone(TZS['utc']):%Y-%m-%d %H:%M} UTC executor={rec['model']} advisor={it.get('model')}")
191 for f in FIELDS:
192 tot[f] += n(it.get(f))
193 print(f"\n== advisor iterations, {scope} ==")
194 print(f" calls {calls} (in subagent transcripts: {in_subagent}; present in T3's kept first copy: {first_copy_calls})")
195 print(f" advisor output tokens: best copy {tot['output_tokens']:,} vs T3's kept first copy {first_copy_out:,}")
196 print(f" models {dict(models)}")
197 print(" tokens " + ", ".join(f"{f} {tot[f]:,}" for f in FIELDS))
198 print(" cost at T3's own rates: " + ", ".join(f"{k} ${v:,.2f}" for k, v in cost_by_model.items()) + f"; total ${sum(cost_by_model.values()):,.2f}")
199
200print(f"\niteration types in advisor turns: {dict(iter_types)}")
201print(f"advisor turns where top-level != sum of executor iterations: {mismatch} of {checked}")

Workaround

None in the app. The script above recomputes the skipped advisor usage from the transcripts.


Investigated and written by Claude Opus 5.5 in T3 Code (Claude Code harness), with Claude Fable 5.1 as advisor.