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

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.
claude --plugin-dir ./chai-meter
or add this repo as a marketplace and install it with /plugin.
| Option | Default | What it does |
|---|---|---|
chaiPriceInr | 20 | Rupees per cup of chai. |
usdToInr | 84 | Rupees 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.
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.
hooks/register.ts 131 lines1import 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}
131hooks/meter.ts 116 lines1import 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}
116hooks/prices.ts 75 lines1import 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}
75types/index.d.ts 30 lines1/** 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