Skip to content

How to read token and cost reporting

Once lh metrics ingest has populated the metrics database, lh status tokens is the command that answers questions about it. This page walks through the questions it is built for and the exact invocation for each.

If the numbers look empty or stale, the problem is upstream — see Metrics ingest for how rows get into the database in the first place.

lh status tokens vs lh metrics status

These sound alike and do different things. lh status tokens reports billed spend separately from an API-equivalent comparison. lh metrics status reports delivery: how many events are queued, in flight, or already shipped to each configured remote sink. If you are asking "what did this cost", you want lh status tokens.

The two knobs

Everything the command does comes from two independent choices.

--by picks the dimensions you break the numbers down by. It is repeatable, and the order you pass the flags is the order of the columns. Available dimensions: profile, project, model, host, workload, agent, day, week, month.

host is the machine that produced the sessions, stamped by whichever machine ingested them. workload is the label a caller passed to lh exec --workload, or inference:<role> for a framework inference call (the compound-loop evaluator is inference:distill), or inference:<role>:<label> when lh exec --role was given a --workload (filter with --workload <label>, a substring match); interactive sessions have none and group under unknown. An inference row's agent is the backend type (claude), not an adapter: no agent ran. Such a row is spend, not a session: its tokens and cost sum into every total and every --by group, but no session count includes it (the lh status header and Sessions line, lh status sessions, the sessions figure of lh status tokens, lh metrics status, and the launch-to-session ratio), so an inference:distill group reports 0 sessions. agent is the adapter that produced the row (claude-code, codex, copilot) — a permanent, stamped dimension, never re-derived from the profile's current config.

--period picks the rows that get counted at all. It takes the four keywords (today, week, month, all) plus a rolling window (30d), a calendar month (2026-04), or a single day (2026-04-15).

The two are orthogonal. --by month --period all breaks all of history down by month; --by model --period 2026-04 breaks one month down by model.

Worked examples

What is each profile costing me?

lh status tokens --by profile --period all
By: profile | Period: All time | 3411 sessions

┏━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓
┃Profile   ┃    In ┃   Out ┃ Cache% ┃ Billed cost┃ API-equivalent cost┃
┡━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩
│personal  │  3.5G │ 19.8M │    95% │ $2407.45│ —                  │
│work      │  6.8G │ 45.8M │    95% │ $5540.77│ —                  │
├──────────┼───────┼───────┼────────┼─────────┤
│Total     │ 10.3G │ 65.5M │    95% │ $7948.22│ —                  │
└──────────┴───────┴───────┴────────┴─────────┘

In is the sum of prompt tokens and both cache buckets; Cache% is the share of that figure served from cache reads, which is the number to watch — a high cache rate is most of what keeps the billed-cost column down. API-equivalent cost is a separate public-list-price comparison and is never presented as an invoice.

lh status tokens --by month --by profile --period all

Two dimensions, so the table adds a subtotal row per month:

┃Month   ┃ Profile  ┃    In ┃   Out ┃ Cache% ┃ Billed cost┃ API-equivalent cost┃
│2026-07 │ personal │ 425M  │  3.4M │    93% │     $271.14│ —                  │
│2026-07 │ work     │  2.2G │ 15.3M │    95% │    $1872.82│ —                  │
│2026-07 │ subtotal │  2.7G │ 18.7M │    95% │    $2143.97│ —                  │
│2026-08 │ personal │  2.3G │ 11.3M │    96% │    $1472.50│ —                  │
│2026-08 │ work     │  1.4G │  7.2M │    97% │     $987.38│ —                  │
│2026-08 │ subtotal │  3.7G │ 18.5M │    96% │    $2459.88│ —                  │

Subtotals key on the first dimension, so swapping the flag order to --by profile --by month subtotals per profile instead of per month.

What did one model cost across everything?

lh status tokens --by project --model opus --period 30d

Filters are case-insensitive substring matches, which is what makes this usable against real model strings — --model opus covers claude-opus-5 and claude-opus-4-8 without you typing either in full. The same holds for --profile and --project.

A filter narrows the rows; it does not add a column. Combine the two freely:

lh status tokens --profile work --by week

What did last Tuesday cost?

lh status tokens --by project --period 2026-04-15

Feeding it to something else

lh status tokens --by profile --by model --period month --json
{
  "period": {"spec": "month", "label": "August 2026", "since": null},
  "dimensions": ["profile", "model"],
  "filters": {},
  "groups": [
    {
      "key": {"profile": "personal", "model": "claude-opus-5"},
      "input": 1934500000,
      "output": 7200000,
      "cache_read": 1800000000,
      "cache_create": 90000000,
      "cache_pct": 98,
      "cost": 1350.81,
      "billed_cost": 1350.81,
      "billed_coverage": {"priced": 1, "rows": 1},
      "api_equivalent_cost": null,
      "api_equivalent_coverage": {"priced": 0, "rows": 1},
      "api_equivalent_statuses": ["unknown_model"],
      "sessions": 412
    }
  ],
  "subtotals": [{"key": {"profile": "personal"}, "cost": 1616.47}],
  "total": {"cost": 4572.09, "sessions": 891}
}

groups, subtotals, and total all carry the same measure fields. The legacy cost key remains a billed-cost alias for v3 consumers; new consumers should use billed_cost and api_equivalent_cost with their coverage objects. key is absent on total and holds only the first dimension on each subtotal entry. subtotals is an empty list when fewer than two dimensions were requested.

Piped through jq, this is the shortest path to a question the table does not answer:

lh status tokens --by day --period 30d --json \
  | jq -r '.groups[] | select(.cost > 50) | "\(.key.day) $\(.cost)"'

Flat-rate agents and unknown models

Not every profile is billed per token. [profiles.<name>] billing_model = "flat_rate" (ADR-050) marks a profile whose usage runs against a subscription — a ChatGPT-plan Codex login, for example — where the per-token table has nothing to say about cost. lh status renders that distinction rather than guessing past it:

billing_model Billed cost API-equivalent cost billed source
per_token amount when priced independently priced or null pricing or unknown
flat_rate — (never $0.00) independently priced or null subscription

A flat_rate row short-circuits billed pricing entirely, so a model with no entry in DEFAULT_PRICING never falls into the per_token / no-rate row above — the subscription already paid for the usage, and reporting $0.00 would read as free rather than unmetered. A group or total with partial coverage prints the priced-row count beside the amount rather than silently presenting it as complete. API-equivalent pricing runs per response, and the context class is derived from the prompt the usage record reports: OpenAI's 272K boundary (ADR-067) and Haiku 5.5's 100K boundary (ADR-070). No reader has to supply it. codex-auto-review has no public price row and remains unknown_model; it is not aliased to another model. Each OpenAI model prices only inside its published window: gpt-5.6-sol from 2026-08-21 through the vendor's committed floor of 2026-11-21, gpt-6-astra from 2026-09-03, gpt-6-sol and gpt-6-luna from 2026-09-22, gpt-6.1-sol from 2026-09-29, and gpt-5.6-luna from 2026-07-30, all open-ended except Sol 5.6. Missing, malformed, or out-of-window response dates fail closed instead of treating a point-in-time snapshot as permanent pricing.

Codex today: the reader's turn_context line declares gpt-5-codex (measured 2026-09-17), which carries no DEFAULT_PRICING entry. The authoritative source (developers.openai.com/api/docs/pricing, checked 2026-09-17) lists only gpt-5.3-codex under "Specialized models" — a different, newer model id — so there is no rate to cite for gpt-5-codex itself yet. Concretely, today: a codex profile with billing_model = "flat_rate" renders —; the same profile under billing_model = "per_token" (an API-key login) renders unknown_models: gpt-5-codex on lh metrics ingest and — is never confused with "priced at zero."

lh status tokens --by profile
By: profile | Period: September 2026 | 2488 sessions

┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓
┃Profile             ┃     In ┃   Out ┃ Cache% ┃ Billed cost┃ API-equivalent cost┃
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩
│work                │   5.1G │ 22.6M │    97% │    $3212.9│ —                  │
│personal            │   5.4G │ 26.2M │    97% │   $3716.73│ —                  │
│lazy-codex          │ 222.9K │   153 │    43% │          —│ —                  │
├────────────────────┼────────┼───────┼────────┼─────────┤
│Total               │  10.4G │ 48.9M │    97% │ $6929.63 (2/3)│ —              │
└────────────────────┴────────┴───────┴────────┴─────────┘

Measured 2026-09-17 16:55, lh 0.71.1, one month of real data across all three profiles — lazy-codex renders —, never $0.00, exactly as described above, now shown next to two real per-token profiles instead of standing alone.

lh status overview and lh metrics launches were the two other pieces of this worked example still marked synthetic; both are measured too, from the same lh metrics ingest run:

lh status overview
Profiles  personal* · work · lazy-codex
Projects  personal: 129 · work: 77 · lazy-codex: 1
Sessions  personal: 4 today · 1315 this month · 4013 total
          work: 181 today · 1168 this month · 3907 total
          lazy-codex: 5 today · 5 this month · 5 total
          all:  190 today · 2488 this month · 7925 total
Tokens    personal: 79.0K in · 26.2M out · billed $3716.73 · API-equivalent — (Sep)
          work: 68.4K in · 22.6M out · billed $3212.9 · API-equivalent — (Sep)
          lazy-codex: 126.6K in · 153 out · billed — · API-equivalent — (Sep)
          all: 274.0K in · 48.9M out · billed $6929.63 (2/3) · API-equivalent — (Sep)
Cache     personal: 5.2G read · 116.9M write
          work: 4.9G read · 112.8M write
          lazy-codex: 96.4K read · 0 write
          all:  10.2G read · 229.6M write
Hooks     ✓ personal:session-context  ✓ personal:session-export  ✓ personal:compound-loop  ✓ work:session-context  ✓ work:session-export  ✓ work:compound-loop
Cron      ✓ graphify-update  ✓ knowledge-push  ✓ metrics-ingest  ✓ qmd-context-gen  ✓ qmd-embed  ✓ qmd-sync
Queue     1 pending · 232 done today

The panel's Tokens row reports raw input only, with both cache buckets broken out on its own Cache row instead — see "Reconciling the numbers" below for why that makes personal's 79.0K in look nothing like the 5.4G in the table above, and why that is not a bug.

Notice, too, that Sessions breaks down by profile here but lh status sessions cannot: that command only takes --period today|week|month|all (measured: lh status sessions --help), with no --profile filter. lh status tokens --profile <name> --by day is the closest existing substitute for a per-profile daily view — narrower in period granularity than the panel's month-to-date line, but the only command that actually accepts a profile filter. Recorded as an open gap, not fixed here.

lh metrics launches
work            claude-code  run    28
personal        claude-code  exec   8
personal        claude-code  run    3
lazy-codex      codex        run    7

work            launches=28 sessions=1404 ratio=0.02
personal        launches=11 sessions=2011 ratio=0.01
lazy-codex      launches=7 sessions=5 ratio=1.40

lazy-codex's ratio is 1.40 — above 1, because the ratio is launches over ingested sessions in the window, not a fraction clamped to a session's lifetime. Several of that day's launches were probes against a throwaway CODEX_HOME that never produced a transcript, so lh metrics ingest never saw a session for them: a launch with no matching session still counts in the numerator.

Reconciling the numbers

lh status tokens's In column and lh status overview's Tokens line both claim to report input tokens for the same profile and month, and they disagree on purpose. From the worked examples above, personal for September 2026: the table's In is 5.4G; the panel's Tokens line is 79.0K in, with 5.2G read · 116.9M write on its own Cache line. The table's In is input + cache_read + cache_create (Bucket.total_input in monitoring/aggregate.py) — "the sum of prompt tokens and both cache buckets" from the first worked example above. The panel's Tokens row is raw input alone, by design (see the comment above it in monitoring/views/overview.py): it matches how ccusage and Anthropic's own billing report input, and reporting cache there too would bury the number that dominates a long session by four or five orders of magnitude. Its Cache row exists precisely so that number is not lost, just kept off the Tokens line.

Adding the panel's three numbers back up: 79.0K + 5.2G + 116.9M ≈ 5.32G — visibly short of the table's 5.4G, and that gap is expected, not a defect: each of the three figures is independently rounded to one decimal at its own magnitude (K, M, or G) before display, so summing three already-rounded numbers does not reproduce a total that was rounded once, from the unrounded per-token sum, on the table's side. Out and Billed cost do not have this split — both views agree on them exactly (26.2M and $3716.73) — which is the evidence that the In gap is a definition difference, not the two commands reading different data.

The total printed by lh status tokens is computed at full precision and rounded once, at the end. It reconciles exactly with the database's own sum, so if you want to check the command against the raw table:

sqlite3 ~/.local/share/lazy-harness/metrics.db \
  "SELECT ROUND(SUM(cost), 2), COUNT(DISTINCT CASE WHEN workload LIKE 'inference:%' \
   THEN NULL ELSE session END) FROM session_stats"

Those two figures match the Total row and the session count in the header. A mismatch means the database moved between the two commands — usually an ingest running in between.