SLOPSHOPPER

chai-meter

A taxi meter for the session's cost in the status line, in dollars and cups of chai.

newcommandtoaststatus
v0.1.0MITupdated 2026-10-04ShriD5/claude-mods/chai-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chai-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /chai ⎿ chai-meter: ☕ This session: $0.42 ≈ 1.8 chai (₹35 at ₹20 a cup, $1 = ₹84) ⎿ chai-meter: input 0 tokens $0.00 ⎿ chai-meter: output 0 tokens $0.00 ⎿ chai-meter: cache write 0 tokens $0.00 ⎿ chai-meter: cache read 0 tokens $0.00 ⎿ chai-meter: Total from Claude Code's own cost ledger; the rows above use the table in hooks/prices.ts. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ chai-meter: ☕ $0.42 · ≈ 1.8 chai
README

chai-meter

A taxi meter for your Claude Code session, in the status line:

☕ $0.42 · ≈ 1.8 chai

The meter jumps when a request goes out and ticks up while the response streams. When the response ends, it settles on the real usage. After each turn it lines up with Claude Code's own cost ledger.

/chai prints the breakdown:

☕ This session: $0.74 ≈ 3.1 chai (₹62 at ₹20 a cup, $1 = ₹84)
  input              1,500 tokens     $0.01
  output            32,000 tokens     $0.64
  cache write       10,000 tokens     $0.05
  cache read       200,000 tokens     $0.04
  Priced with the table in hooks/prices.ts.
Priciest turn: $0.64, "now rewrite the whole billing module in Rust ple…"
All time: $10.74 ≈ 45 chai

The session total is kept in $.state, so a reload doesn't reset it. The all-time total is kept in $.store and carries across sessions.

Install

claude --plugin-dir ./chai-meter

or add this repo as a marketplace and install it with /plugin.

Options

OptionDefaultWhat it does
chaiPriceInr20Rupees per cup of chai.
usdToInr84Rupees per US dollar.
priceOverrides""JSON of model id to [input, output, cache write, cache read] dollars per million tokens, e.g. {"claude-opus-5-5": [4, 20, 5, 0.2]}. Rows you leave out keep the built-in prices.

The built-in prices live in hooks/prices.ts, one row per model. They are Anthropic API list prices as of 2026-09-25, so update them when prices change. Wherever Claude Code keeps a cost ledger, the session total comes from that ledger. The table only prices the live tick and the per-kind and per-turn rows. A model with no row is priced at Opus 5.5 rates, and /chai says so.

How it works

A streaming turn.step hook estimates the response as it arrives: the previous request's input cost, plus about four characters per output token. It then adds the response's real usage, priced from the table. Each session.measure sets the base to Claude Code's own cost.usd. turn.start and turn.complete attribute cost to each turn so /chai can name the priciest one.

Source 4 files
hooks/register.ts 131 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { ChaiMeterState } from '../types'
4import {
5  addStep,
6  breakdown,
7  type ChaiPrice,
8  closeTurn,
9  emptyMeter,
10  measure,
11  openTurn,
12  statusText,
13  streamingUsd,
14  totalUsd,
15  turnLabel,
16} from './meter'
17import { costOf, parseOverrides, ratesFor } from './prices'
18
19const METER = { plugin: 'chai-meter', key: 'meter' } as const
20const ALL_TIME = 'allTimeUsd'
21
22// The session's meter, mirrored from $.state (which outlives a hot reload).
23let meter: ChaiMeterState | undefined
24// What responses still streaming will roughly add, by step.
25const streaming = new Map<string, number>()
26// The input side of each loop's last response: the meter's flag drop for the next one.
27const lastInputUsd = new Map<string, number>()
28let shown = ''
29
30async function ensureLoaded($: EngineInterface) {
31  const sessionId = await $.session.id()
32  if (meter?.sessionId === sessionId) return
33  const { value } = await $.state.get(METER)
34  meter = value?.sessionId === sessionId ? value : emptyMeter(sessionId)
35}
36
37function show($: EngineInterface, price: ChaiPrice) {
38  if (!meter) return
39  let usd = totalUsd(meter)
40  for (const estimate of streaming.values()) usd += estimate
41  const text = statusText(usd, price)
42  if (text !== shown) $.ui.status((shown = text))
43}
44
45/** Applies a change to the meter, saves it, adds what it cost to all time. */
46async function change($: EngineInterface, price: ChaiPrice, apply: (meter: ChaiMeterState) => ChaiMeterState) {
47  await ensureLoaded($)
48  meter = apply(meter!)
49  const total = totalUsd(meter)
50  const added = total - meter.banked
51  if (added > 0) meter = { ...meter, banked: total }
52  await $.state.set(METER, meter)
53  show($, price)
54  if (added > 0) await $.store.set(ALL_TIME, Number((await $.store.get(ALL_TIME)) ?? 0) + added)
55}
56
57export const register: Register = (on, options) => {
58  const price: ChaiPrice = {
59    chaiInr: Number(options.chaiPriceInr) > 0 ? Number(options.chaiPriceInr) : 20,
60    usdToInr: Number(options.usdToInr) > 0 ? Number(options.usdToInr) : 84,
61  }
62  const { overrides, error } = parseOverrides(String(options.priceOverrides ?? ''))
63
64  on('session.start', async ($, e, next) => {
65    await $.command.register({ name: 'chai', description: "This session's cost in tokens, dollars and cups of chai" })
66    if (error) $.ui.toast(`☕ ${error}`)
67    const { cost } = await $.session.usage().catch(() => ({ cost: undefined }))
68    await change($, price, m => (cost && m.ledgerUsd === null ? measure(m, cost.usd) : m))
69    return next(e)
70  })
71
72  on('command.run', { command: 'chai' }, async $ => {
73    await ensureLoaded($)
74    const allTime = Number((await $.store.get(ALL_TIME)) ?? 0)
75    return { text: breakdown(meter!, allTime, price) }
76  })
77
78  on('turn.start', async ($, e, next) => {
79    await change($, price, m => openTurn(m, e.turnId, turnLabel(e.text)))
80    return next(e)
81  })
82
83  // Ticks while the response streams: the input side of the loop's last
84  // response the moment the request goes, then the output as it arrives.
85  // The response's real usage replaces the estimate when it ends.
86  on('turn.step', async function* ($, e, next) {
87    const loop = e.agentId ?? 'main'
88    const step = `${loop}:${e.turnId}:${e.index}`
89    const flagDrop = lastInputUsd.get(loop) ?? 0
90    let characters = 0
91    streaming.set(step, flagDrop)
92    show($, price)
93
94    const stream = next(e)
95    try {
96      for await (const chunk of stream) {
97        if (chunk.kind === 'text' || chunk.kind === 'thinking') characters += chunk.text.length
98        if (chunk.kind === 'input') characters += chunk.json.length
99        streaming.set(step, flagDrop + streamingUsd(e.model, characters, overrides))
100        show($, price)
101        yield chunk
102      }
103    } finally {
104      streaming.delete(step)
105    }
106
107    const result = await stream.result
108    const usage = result.usage
109    if (usage) {
110      const cost = costOf(usage, ratesFor(usage.model, overrides).rates)
111      lastInputUsd.set(loop, cost.input + cost.cacheWrite + cost.cacheRead)
112      await change($, price, m => addStep(openTurn(m, e.turnId, e.agentId ? 'a subagent turn' : 'a turn'), e.turnId, usage, overrides))
113    } else {
114      show($, price)
115    }
116    return result
117  })
118
119  on('turn.complete', async ($, e, next) => {
120    await change($, price, m => closeTurn(m, e.turnId))
121    return next(e)
122  })
123
124  // Claude Code's own ledger, after each turn: it corrects the table's estimate.
125  on('session.measure', async ($, e, next) => {
126    const cost = e.cost
127    if (cost) await change($, price, m => measure(m, cost.usd))
128    return next(e)
129  })
130}
131
hooks/meter.ts 116 lines
1import type { TurnUsage } from 'claude-code'
2
3import type { ChaiMeterKinds, ChaiMeterState } from '../types'
4import { costOf, type Rates, ratesFor } from './prices'
5
6// The meter's arithmetic and wording, kept pure so the tests can drive it.
7
8export type ChaiPrice = { chaiInr: number; usdToInr: number }
9
10const zero = (): ChaiMeterKinds => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0 })
11const sum = (kinds: ChaiMeterKinds) => kinds.input + kinds.output + kinds.cacheWrite + kinds.cacheRead
12
13export const emptyMeter = (sessionId: string): ChaiMeterState => ({
14  sessionId,
15  tokens: zero(),
16  priced: zero(),
17  ledgerUsd: null,
18  pricedAtLedger: 0,
19  banked: 0,
20  open: {},
21  priciest: null,
22  unknownModels: [],
23})
24
25/** The session's cost: Claude Code's ledger plus what the table priced since it last spoke. */
26export const totalUsd = (meter: ChaiMeterState) =>
27  meter.ledgerUsd === null ? sum(meter.priced) : meter.ledgerUsd + sum(meter.priced) - meter.pricedAtLedger
28
29export function openTurn(meter: ChaiMeterState, turnId: string, label: string): ChaiMeterState {
30  if (meter.open[turnId]) return meter
31  return { ...meter, open: { ...meter.open, [turnId]: { usd: 0, label } } }
32}
33
34/** One model response's usage, priced and added to its turn. */
35export function addStep(meter: ChaiMeterState, turnId: string, usage: TurnUsage, overrides?: Record<string, Rates>): ChaiMeterState {
36  const { rates, isKnown } = ratesFor(usage.model, overrides)
37  const cost = costOf(usage, rates)
38  const turn = meter.open[turnId] ?? { usd: 0, label: 'a subagent turn' }
39  return {
40    ...meter,
41    tokens: {
42      input: meter.tokens.input + usage.input_tokens,
43      output: meter.tokens.output + usage.output_tokens,
44      cacheWrite: meter.tokens.cacheWrite + usage.cache_creation_input_tokens,
45      cacheRead: meter.tokens.cacheRead + usage.cache_read_input_tokens,
46    },
47    priced: {
48      input: meter.priced.input + cost.input,
49      output: meter.priced.output + cost.output,
50      cacheWrite: meter.priced.cacheWrite + cost.cacheWrite,
51      cacheRead: meter.priced.cacheRead + cost.cacheRead,
52    },
53    open: { ...meter.open, [turnId]: { ...turn, usd: turn.usd + sum(cost) } },
54    unknownModels: isKnown || meter.unknownModels.includes(usage.model) ? meter.unknownModels : [...meter.unknownModels, usage.model],
55  }
56}
57
58/** A turn ended: it may be the priciest yet. */
59export function closeTurn(meter: ChaiMeterState, turnId: string): ChaiMeterState {
60  const turn = meter.open[turnId]
61  if (!turn) return meter
62  const { [turnId]: _, ...open } = meter.open
63  const priciest = meter.priciest === null || turn.usd > meter.priciest.usd ? turn : meter.priciest
64  return { ...meter, open, priciest }
65}
66
67/** Claude Code reported the session's cost: it becomes the base of the total. */
68export const measure = (meter: ChaiMeterState, usd: number): ChaiMeterState => ({
69  ...meter,
70  ledgerUsd: usd,
71  pricedAtLedger: sum(meter.priced),
72})
73
74/** What the output streamed so far will roughly cost (about four characters a token). */
75export function streamingUsd(model: string, characters: number, overrides?: Record<string, Rates>) {
76  return ((characters / 4) * ratesFor(model, overrides).rates[1]) / 1e6
77}
78
79export const cups = (usd: number, price: ChaiPrice) => (usd * price.usdToInr) / price.chaiInr
80
81const cupsText = (n: number) => (n < 10 ? n.toFixed(1) : String(Math.round(n)))
82const usdText = (usd: number) => `$${usd.toFixed(2)}`
83const grouped = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
84
85/** `☕ $0.42 · ≈ 1.8 chai` */
86export const statusText = (usd: number, price: ChaiPrice) => `☕ ${usdText(usd)} · ≈ ${cupsText(cups(usd, price))} chai`
87
88/** What /chai prints. */
89export function breakdown(meter: ChaiMeterState, allTimeUsd: number, price: ChaiPrice): string {
90  const total = totalUsd(meter)
91  const rupees = Math.round(total * price.usdToInr)
92  const row = (label: string, kind: keyof ChaiMeterKinds) =>
93    `  ${label.padEnd(12)}${grouped(meter.tokens[kind]).padStart(12)} tokens  ${usdText(meter.priced[kind]).padStart(8)}`
94  const lines = [
95    `☕ This session: ${usdText(total)} ≈ ${cupsText(cups(total, price))} chai (₹${rupees} at ₹${price.chaiInr} a cup, $1 = ₹${price.usdToInr})`,
96    row('input', 'input'),
97    row('output', 'output'),
98    row('cache write', 'cacheWrite'),
99    row('cache read', 'cacheRead'),
100    meter.ledgerUsd === null
101      ? '  Priced with the table in hooks/prices.ts.'
102      : "  Total from Claude Code's own cost ledger; the rows above use the table in hooks/prices.ts.",
103  ]
104  if (meter.unknownModels.length > 0) lines.push(`  No price row for ${meter.unknownModels.join(', ')}: priced at the default rates.`)
105  if (meter.priciest) lines.push(`Priciest turn: ${usdText(meter.priciest.usd)}, ${meter.priciest.label}`)
106  lines.push(`All time: ${usdText(allTimeUsd)} ≈ ${cupsText(cups(allTimeUsd, price))} chai`)
107  return lines.join('\n')
108}
109
110/** `"refactor the auth middleware…"`: a turn named by its prompt. */
111export function turnLabel(prompt: string) {
112  const oneLine = prompt.replace(/\s+/g, ' ').trim()
113  if (oneLine === '') return 'a turn with no prompt'
114  return `"${oneLine.length > 48 ? `${oneLine.slice(0, 48)}…` : oneLine}"`
115}
116
hooks/prices.ts 75 lines
1import type { ModelUsage } from 'claude-code'
2
3/** US dollars per million tokens: [input, output, cache write (5 min), cache read]. */
4export type Rates = readonly [input: number, output: number, cacheWrite: number, cacheRead: number]
5
6// UPDATE ME when prices change: Anthropic API list prices as of 2026-09-25.
7// Cache writes are the 5-minute rate (1.25x input). Claude Code's own cost
8// ledger wins whenever the session has one; this table prices the live tick
9// and the per-turn breakdown. Override any row with the `priceOverrides`
10// option instead of editing this file.
11export const PRICES: Record<string, Rates> = {
12  'claude-fable-5-1': [10, 50, 12.5, 0.25],
13  'claude-mythos-5-1': [10, 50, 12.5, 0.25],
14  'claude-fable-5': [10, 50, 12.5, 1],
15  'claude-mythos-5': [10, 50, 12.5, 1],
16  'claude-opus-5-5': [4, 20, 5, 0.2],
17  'claude-opus-5': [5, 25, 6.25, 0.5],
18  'claude-opus-4-8': [5, 25, 6.25, 0.5],
19  'claude-opus-4-7': [5, 25, 6.25, 0.5],
20  'claude-opus-4-6': [5, 25, 6.25, 0.5],
21  'claude-sonnet-5-5': [2, 10, 2.5, 0.2],
22  'claude-sonnet-5': [2, 10, 2.5, 0.2],
23  'claude-sonnet-4-6': [3, 15, 3.75, 0.3],
24  'claude-haiku-4-5': [1, 5, 1.25, 0.1],
25}
26
27/** What a model no row matches is priced at. */
28export const DEFAULT_RATES: Rates = PRICES['claude-opus-5-5']!
29
30/** `us.anthropic.claude-opus-5-5-20260101[1m]` -> `claude-opus-5-5-20260101`. */
31export const normalizeModel = (model: string) =>
32  model
33    .toLowerCase()
34    .replace(/\[.*\]$/, '')
35    .replace(/@.*$/, '')
36    .replace(/^(?:[a-z]+\.)*(?=claude-)/, '')
37
38/** The rates for a model id: the longest table key it starts with. */
39export function ratesFor(model: string, overrides: Record<string, Rates> = {}): { rates: Rates; isKnown: boolean } {
40  const id = normalizeModel(model)
41  const table = { ...PRICES, ...overrides }
42  const key = Object.keys(table)
43    .filter(prefix => id === prefix || id.startsWith(`${prefix}-`))
44    .sort((a, b) => b.length - a.length)[0]
45  return key ? { rates: table[key]!, isKnown: true } : { rates: DEFAULT_RATES, isKnown: false }
46}
47
48/** Dollars by kind for one response's usage. */
49export function costOf(usage: ModelUsage, rates: Rates) {
50  const [input, output, cacheWrite, cacheRead] = rates
51  return {
52    input: (usage.input_tokens * input) / 1e6,
53    output: (usage.output_tokens * output) / 1e6,
54    cacheWrite: (usage.cache_creation_input_tokens * cacheWrite) / 1e6,
55    cacheRead: (usage.cache_read_input_tokens * cacheRead) / 1e6,
56  }
57}
58
59/** `{"claude-opus-5-5": [4, 20, 5, 0.2]}` from the option; bad rows dropped. */
60export function parseOverrides(json: string): { overrides: Record<string, Rates>; error?: string } {
61  if (json.trim() === '') return { overrides: {} }
62  try {
63    const parsed: unknown = JSON.parse(json)
64    const overrides: Record<string, Rates> = {}
65    for (const [model, rates] of Object.entries(parsed as Record<string, unknown>)) {
66      if (Array.isArray(rates) && rates.length === 4 && rates.every(n => typeof n === 'number' && n >= 0)) {
67        overrides[normalizeModel(model)] = rates as unknown as Rates
68      }
69    }
70    return { overrides }
71  } catch {
72    return { overrides: {}, error: 'priceOverrides is not JSON; using the built-in prices' }
73  }
74}
75
types/index.d.ts 30 lines
1/** Token counts or dollars, by kind. */
2export type ChaiMeterKinds = { input: number; output: number; cacheWrite: number; cacheRead: number }
3
4export type ChaiMeterTurn = { usd: number; label: string }
5
6/** The running meter of one session, kept in `$.state` so a reload keeps it. */
7export type ChaiMeterState = {
8  sessionId: string
9  tokens: ChaiMeterKinds
10  /** Dollars by kind, from the price table. */
11  priced: ChaiMeterKinds
12  /** Claude Code's own cost total when last measured, or null where it keeps none. */
13  ledgerUsd: number | null
14  /** The table's total at that measurement: what was priced since is added on top. */
15  pricedAtLedger: number
16  /** How much of the total has been added to the all-time figure. */
17  banked: number
18  /** Turns still running, by turn id. */
19  open: Record<string, ChaiMeterTurn>
20  priciest: ChaiMeterTurn | null
21  /** Models no price-table row matched (priced at the default rates). */
22  unknownModels: string[]
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'chai-meter': { meter: ChaiMeterState }
28  }
29}
30