Skip to content

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

Closed

Description

@izzat316

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):

{"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 @ eb1510d).

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 @ eb1510d

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

$ 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
# (trimmed to the relevant sections)
== dashboard reproduction, days bucketed in utc (fast records: 0) ==
  claude-haiku-5-5: $0.01  tokens 0.1M
  claude-opus-5-5: $291.24  tokens 865.3M
  claude-sonnet-5-5: $87.07  tokens 517.1M
  TOTAL $378.32  sessions 11  split input $0.03, read $220.73, write $82.39, output $75.17

== advisor iterations, dashboard window (utc) ==
  calls 51 (in subagent transcripts: 8; present in T3's kept first copy: 51)
  advisor output tokens: best copy 374,209 vs T3's kept first copy 374,209
  models {'claude-fable-5-1': 46, 'claude-opus-5-5': 5}
  tokens input_tokens 17,122,289, cache_read_input_tokens 0, cache_creation_input_tokens 0, output_tokens 374,209
  cost at T3's own rates: claude-fable-5-1 $171.24, claude-opus-5-5 $7.48; total $178.72

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)
"""Mirror T3 Code's Claude usage reader over ~/.claude/projects, then tally the advisor
iterations it ignores. Read-only.

usage: python3 -I repro_usage.py <projects_dir> <rates_json> <since_day> <until_day> <cutoff_iso>
"""
import collections
import datetime as dt
import json
import os
import sys

projects, rates_path, since_day, until_day, cutoff_iso = sys.argv[1:6]
cutoff = dt.datetime.fromisoformat(cutoff_iso)
TZS = {"utc": dt.timezone.utc}  # days are bucketed in UTC

doc = json.load(open(rates_path))["document"]


def rates(model):
    e = doc.get(model.split("[")[0])
    if e is None:
        return None
    inp, out = e["input_cost_per_token"], e["output_cost_per_token"]
    return dict(
        input=inp,
        output=out,
        read=e.get("cache_read_input_token_cost", inp),
        write=e.get("cache_creation_input_token_cost", inp),
        write_1h=e.get("cache_creation_input_token_cost_above_1hr"),
    )


def files(root):
    # Same depth-first readdir walk as usageTranscriptReader.ts.
    for entry in os.scandir(root):
        if entry.is_dir(follow_symlinks=False):
            yield from files(entry.path)
        elif entry.name.endswith(".jsonl"):
            yield entry.path


def ts(record):
    t = record.get("timestamp")
    if not isinstance(t, str):
        return None
    try:
        return dt.datetime.fromisoformat(t.replace("Z", "+00:00"))
    except ValueError:
        return None


FIELDS = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens")
first = {}  # dedupe key -> first parsed record (what T3 keeps)
copies = collections.defaultdict(list)  # dedupe key -> every copy's usage (for max-per-field)
no_key = []
cost_usd_seen = 0
for path in files(projects):
    for line in open(path, errors="ignore"):
        if '"usage"' not in line:
            continue
        try:
            r = json.loads(line)
        except ValueError:
            continue
        if not isinstance(r, dict) or r.get("type") != "assistant":
            continue
        m = r.get("message")
        if not isinstance(m, dict) or not isinstance(m.get("usage"), dict):
            continue
        t = ts(r)
        model = m.get("model") if isinstance(m.get("model"), str) else ""
        if t is None or not model or model == "<synthetic>":
            continue
        if isinstance(r.get("costUSD"), (int, float)):
            cost_usd_seen += 1
        mid, rid = m.get("id"), r.get("requestId")
        mid = mid if isinstance(mid, str) else None
        rid = rid if isinstance(rid, str) else None
        rec = dict(t=t, model=model, session=r.get("sessionId") or "", usage=m["usage"], path=path)
        if mid is None and rid is None:
            no_key.append(rec)
            continue
        key = f"{mid or ''}:{rid or ''}"
        copies[key].append(rec)
        first.setdefault(key, rec)

print(f"records with costUSD: {cost_usd_seen}; keyless records: {len(no_key)}")


def in_window(t, tz):
    day = t.astimezone(tz).date().isoformat()
    return since_day <= day <= until_day and t <= cutoff


def n(v):
    return v if isinstance(v, int) and v >= 0 else 0


# 1. Reproduce the dashboard (first copy per key, top-level usage only).
for tzname, tz in TZS.items():
    per_model = collections.defaultdict(lambda: collections.Counter())
    sessions = set()
    fast = 0
    for rec in list(first.values()) + no_key:
        if not in_window(rec["t"], tz):
            continue
        u = rec["usage"]
        c = per_model[rec["model"]]
        for f in FIELDS:
            c[f] += n(u.get(f))
        sessions.add(rec["session"])
        fast += u.get("speed") == "fast"
    print(f"\n== dashboard reproduction, days bucketed in {tzname} (fast records: {fast}) ==")
    total = 0.0
    split = collections.Counter()
    for model, c in sorted(per_model.items()):
        r = rates(model)
        if r is None:
            print(f"  {model}: UNPRICED {dict(c)}")
            continue
        parts = dict(
            input=c["input_tokens"] * r["input"],
            read=c["cache_read_input_tokens"] * r["read"],
            write=c["cache_creation_input_tokens"] * r["write"],
            output=c["output_tokens"] * r["output"],
        )
        split.update(parts)
        cost = sum(parts.values())
        total += cost
        toks = sum(c[f] for f in FIELDS)
        print(f"  {model}: ${cost:,.2f}  tokens {toks/1e6:,.1f}M")
    print(f"  TOTAL ${total:,.2f}  sessions {len(sessions)}  split " + ", ".join(f"{k} ${v:,.2f}" for k, v in split.items()))

# 2. Advisor iterations, using the copy with the most iterations/tokens for each key.
def best_copy(recs):
    def weight(rec):
        its = rec["usage"].get("iterations") or []
        return (len(its), sum(n(it.get("output_tokens")) for it in its if isinstance(it, dict)))
    return max(recs, key=weight)


def adv_cost(it):
    r = rates(it.get("model") or "")
    if r is None:
        return 0.0
    return (
        n(it.get("input_tokens")) * r["input"]
        + n(it.get("cache_read_input_tokens")) * r["read"]
        + n(it.get("cache_creation_input_tokens")) * r["write"]
        + n(it.get("output_tokens")) * r["output"]
    )


iter_types = collections.Counter()
mismatch = checked = 0
for scope, tz in (("all time", None), ("dashboard window (utc)", TZS["utc"])):
    calls = 0
    tot = collections.Counter()
    first_copy_calls = 0
    first_copy_out = 0
    models = collections.Counter()
    cost_by_model = collections.Counter()
    in_subagent = 0
    for key, recs in copies.items():
        rec = best_copy(recs)
        if tz is not None and not in_window(rec["t"], tz):
            continue
        its = [it for it in (rec["usage"].get("iterations") or []) if isinstance(it, dict)]
        adv = [it for it in its if it.get("type") == "advisor_message"]
        if not adv:
            continue
        if tz is None:
            for it in its:
                iter_types[it.get("type")] += 1
            # Top-level usage should equal the sum over executor ("message") iterations.
            checked += 1
            execs = [it for it in its if it.get("type") == "message"]
            if any(n(rec["usage"].get(f)) != sum(n(it.get(f)) for it in execs) for f in FIELDS):
                mismatch += 1
        fc = first[key]["usage"].get("iterations") or []
        fc_adv = [it for it in fc if isinstance(it, dict) and it.get("type") == "advisor_message"]
        first_copy_calls += len(fc_adv)
        first_copy_out += sum(n(it.get("output_tokens")) for it in fc_adv)
        calls += len(adv)
        in_subagent += len(adv) * ("/subagents/" in rec["path"])
        for it in adv:
            models[it.get("model")] += 1
            cost_by_model[it.get("model")] += adv_cost(it)
            if tz is None and it.get("model") != "claude-fable-5-1":
                print(f"  non-Fable advisor: {rec['t'].astimezone(TZS['utc']):%Y-%m-%d %H:%M} UTC executor={rec['model']} advisor={it.get('model')}")
            for f in FIELDS:
                tot[f] += n(it.get(f))
    print(f"\n== advisor iterations, {scope} ==")
    print(f"  calls {calls} (in subagent transcripts: {in_subagent}; present in T3's kept first copy: {first_copy_calls})")
    print(f"  advisor output tokens: best copy {tot['output_tokens']:,} vs T3's kept first copy {first_copy_out:,}")
    print(f"  models {dict(models)}")
    print("  tokens " + ", ".join(f"{f} {tot[f]:,}" for f in FIELDS))
    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}")

print(f"\niteration types in advisor turns: {dict(iter_types)}")
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.

Activity

  1. juliusmarminge commented on Oct 24, 2026

    @juliusmarminge
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions