Reproduced on 27e0c684c5 using the supplied transcript in an isolated Claude home and the web Usage page: only Opus appears at $0.13. The existing fix #24140 (GitHub #24139) shows Fable at $0.75 alongside the unchanged Opus usage, totaling $0.88. Runtime transcript. No duplicate PR opened.
[Bug]: Usage ignores Claude advisor calls, so advisor-model tokens and cost are missing#24129
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):
| 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}]}}} |
- Pass the line to
parseClaudeLineinapps/server/src/provider/Drivers/claudeUsage.ts, or append it to a.jsonlfile under~/.claude/projects/and open Usage for a range that includes 2026-10-22. - 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
| 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 | |
| 16 | advisor 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)
| 1 | """Mirror T3 Code's Claude usage reader over ~/.claude/projects, then tally the advisor |
| 2 | iterations it ignores. Read-only. |
| 3 | |
| 4 | usage: python3 -I repro_usage.py <projects_dir> <rates_json> <since_day> <until_day> <cutoff_iso> |
| 5 | """ |
| 6 | import collections |
| 7 | import datetime as dt |
| 8 | import json |
| 9 | import os |
| 10 | import sys |
| 11 | |
| 12 | projects, rates_path, since_day, until_day, cutoff_iso = sys.argv[1:6] |
| 13 | cutoff = dt.datetime.fromisoformat(cutoff_iso) |
| 14 | TZS = {"utc": dt.timezone.utc} # days are bucketed in UTC |
| 15 | |
| 16 | doc = json.load(open(rates_path))["document"] |
| 17 | |
| 18 | |
| 19 | def 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 | |
| 33 | def 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 | |
| 42 | def 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 | |
| 52 | FIELDS = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens") |
| 53 | first = {} # dedupe key -> first parsed record (what T3 keeps) |
| 54 | copies = collections.defaultdict(list) # dedupe key -> every copy's usage (for max-per-field) |
| 55 | no_key = [] |
| 56 | cost_usd_seen = 0 |
| 57 | for 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 | |
| 87 | print(f"records with costUSD: {cost_usd_seen}; keyless records: {len(no_key)}") |
| 88 | |
| 89 | |
| 90 | def in_window(t, tz): |
| 91 | day = t.astimezone(tz).date().isoformat() |
| 92 | return since_day <= day <= until_day and t <= cutoff |
| 93 | |
| 94 | |
| 95 | def 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). |
| 100 | for 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. |
| 135 | def 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 | |
| 142 | def 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 | |
| 154 | iter_types = collections.Counter() |
| 155 | mismatch = checked = 0 |
| 156 | for 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 | |
| 200 | print(f"\niteration types in advisor turns: {dict(iter_types)}") |
| 201 | print(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.