/spend toggles a band above the prompt with live API-equivalent spend per model, cache state, context and a next-prompt estimate.

A Claude Code mod that adds a live spend and context band above the prompt. Type /spend to show it, click ✕ to hide it. It is built for people who switch between models (Claude and GPT through an Azure/OpenAI-compatible proxy) and want to see what each prompt will really cost, including the cache.

The spend, per-model costs and context bar are measured. The next-prompt line is only a guess: here it says "warm" right after a switch, and the real request turned out to re-write most of the cache. See below.
/usage, with token counts.Each turn is priced from the API's own token counts: uncached input, output, cache reads and cache writes, each at the rate of the model that answered. Subagent turns are counted too.
In Claude Code (CLI or desktop app):
/plugin marketplace add tsruu/claude-cost-bar
/plugin install cost-bar@trishan-mods
Start a new session and type /spend. That's it: the defaults work for most people. (Plugins load when a session starts, so chats that were already open need a restart.)
On a remote host (SSH sessions run Claude Code on that machine), install it there too.
Open /plugin, pick cost-bar and change the settings. No file editing needed.
| Setting | Default | What it does |
|---|---|---|
| Claude cache lifetime | 1h | 1h or 5m. This sets the warm/cold timer and the cache-write price. Claude Code reportedly uses 1h on a Claude subscription and 5m on an API key (unless ENABLE_PROMPT_CACHING_1H is set). If the "cold" warnings look wrong, try the other value. |
| GPT cache lifetime (hours) | 24 | How long an idle GPT cache counts as warm. Azure documents 24h for newer models; OpenAI's in-memory cache is minutes. |
| Recent-spend window (minutes) | 10 | The "last N minutes" figure. |
| Custom prices (JSON) | empty | Override or add a model, USD per 1M tokens. Matched by part of the model id. Example: {"gpt-6-sol":{"input":2,"output":10,"read":0.2,"write":2.5}} |
Optional fields for a custom price: longAt (token count where a higher tier starts) and long (that tier's input, output, read, write).
A model that isn't in the built-in table and has no custom price is counted as $0, and the band says so.
Each model keeps its own prompt cache, and a cache expires after its lifetime (1 hour in the author's account; the plugin settings let you change it). So the first request after you switch to a model is cold: the whole conversation is written to its cache at its cache-write price, which is the expensive part of a switch.
What happens to the cache you left is less clear. These are observations from the author's own saved sessions, a small sample. They don't add up to a rule, so the band's next-prompt number is a guess:
| Situation | What was observed |
|---|---|
| Claude → GPT → back to the same Claude model (one simple prompt on the GPT model) | The Claude cache survived. Two sessions: 274k tokens read before and after, and 72k before and 75k after. |
| Opus → Sonnet → Opus (short chat; the Sonnet turn did no thinking) | The Opus cache survived: 77k tokens read, 1.6k written. |
| Sonnet → Opus → Sonnet (long chat; Opus had thinking turns) | The Sonnet cache was mostly lost: 292k read before, 52k after, about 248k re-written (roughly $1 on Sonnet). |
| Opus → GPT-6 Sol and GPT-6.1 Sol (several turns, including tool and skill calls) → back to Opus | The Opus cache was lost: only 52k read and 76k re-written, in a request that had grown from 79k to 128k tokens. The saved request shows the first message of the conversation changed, so everything after it missed the cache. Why it changed is not known. |
| GPT → GPT | The new model starts cold (three conversations: 127k, 115k and 45k tokens written on the first request), then warms up. Whether the first GPT model's cache survives the detour was not tested. |
Possible causes for the lost caches, none proven: thinking blocks that one model can't read from another, and tool or skill activity during the detour changing early messages. A plain time-out was ruled out in the last two cases above, since the 1-hour lifetime had not passed.
Other things worth knowing:
All figures are API-equivalent estimates, not a bill.
cost-bar/hooks/register.tsx if you'd rather edit it there./spend leaves the command line in the chat. A mod can hide its output but not the typed command.MIT
hooks/register.tsx 319 lines1import { atom, read, update } from 'claude-code'
2import type { PluginOptions, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5
6// Built-in prices, USD per 1M tokens. Claude `write` figures are for the 1-hour cache; the
7// plugin setting `claudeCache` rescales them for a 5-minute cache. To change or add a price
8// without editing this file, use the `customPrices` setting.
9type Price = { input: number; output: number; read: number; write: number; long?: Omit<Price, 'long' | 'longAt'>; longAt?: number }
10
11// Azure bills the whole request at the long-context meter above this many input tokens.
12const LONG_CONTEXT = 272_000
13
14// Matched by substring, first hit wins, so the longer ids come first.
15const PRICES: Array<[string, Price]> = [
16 ['fable-5-1', { input: 10, output: 50, read: 0.25, write: 20 }],
17 ['fable-5', { input: 10, output: 50, read: 1, write: 20 }],
18 ['opus-5-5', { input: 4, output: 20, read: 0.2, write: 8 }],
19 ['opus-5', { input: 5, output: 25, read: 0.5, write: 10 }],
20 ['opus-4', { input: 5, output: 25, read: 0.5, write: 10 }],
21 ['sonnet-5-5', { input: 2, output: 10, read: 0.1, write: 4 }],
22 ['sonnet-5', { input: 2, output: 10, read: 0.2, write: 4 }],
23 ['sonnet-4', { input: 3, output: 15, read: 0.3, write: 6 }],
24 // Haiku 5.5 bills a request over 100K prompt tokens (cache reads and writes included) higher.
25 ['haiku-5-5', { input: 0.1, output: 0.5, read: 0.01, write: 0.2, longAt: 100_000, long: { input: 0.5, output: 2.5, read: 0.05, write: 1 } }],
26 ['haiku-4', { input: 1, output: 5, read: 0.1, write: 2 }],
27 // Azure Global Standard, from azurespeed.com (reads the Azure Retail Prices API; meters
28 // effective 2026-09-01). Microsoft's own pricing page still shows "$-" for these models.
29 ['gpt-6.1-sol', { input: 2, output: 10, read: 0.1, write: 2.5, long: { input: 4, output: 15, read: 0.2, write: 5 } }],
30 ['gpt-6-sol', { input: 2, output: 10, read: 0.2, write: 2.5, long: { input: 4, output: 15, read: 0.4, write: 5 } }],
31 ['gpt-6-luna', { input: 0.1, output: 0.5, read: 0.01, write: 0.125, long: { input: 0.2, output: 0.75, read: 0.02, write: 0.25 } }],
32]
33
34// Settings from the plugin's userConfig (the /plugin config menu). Every field has a default.
35const settings = (o: PluginOptions) => {
36 let custom: Array<[string, Price]> = []
37 let isCustomBad = false
38 try {
39 const parsed = JSON.parse(String(o.customPrices || '{}'))
40 custom = Object.entries(parsed).map(([id, v]) => [id.toLowerCase(), v as Price])
41 } catch {
42 isCustomBad = true
43 }
44
45 return {
46 is5m: o.claudeCache === '5m',
47 claudeTtl: (o.claudeCache === '5m' ? 5 : 60) * 60e3,
48 gptTtl: Math.max(0.1, Number(o.gptCacheHours) || 24) * 3600e3,
49 windowMs: Math.max(1, Number(o.windowMinutes) || 10) * 60e3,
50 custom,
51 isCustomBad,
52 }
53}
54type Settings = ReturnType<typeof settings>
55
56const ttlOf = (model: string, cfg: Settings) => (model.includes('gpt') ? cfg.gptTtl : cfg.claudeTtl)
57
58const ledger = atom({ plugin: 'cost-bar', key: 'ledger' } as const, [])
59const isShown = atom({ plugin: 'cost-bar', key: 'isShown' } as const, false)
60
61// Custom prices win, then the built-in table. A Claude model on a 5-minute cache writes at
62// 1.25x input instead of 2x, so its write rates scale by 0.625.
63const priceOf = (model: string, cfg: Settings): Price | undefined => {
64 const id = model.toLowerCase()
65 const hit = cfg.custom.find(([k]) => id.includes(k))?.[1] ?? PRICES.find(([k]) => id.includes(k))?.[1]
66 if (!hit || !cfg.is5m || id.includes('gpt') || cfg.custom.some(([k]) => id.includes(k))) return hit
67
68 return {
69 ...hit,
70 write: hit.write * 0.625,
71 long: hit.long && { ...hit.long, write: hit.long.write * 0.625 },
72 }
73}
74
75const usd = (n: number) => (n < 0.01 && n > 0 ? '<$0.01' : `$${n.toFixed(2)}`)
76const short = (model: string) => model.replace(/^.*\//, '')
77// The picker's id (ocx-claude-azure--gpt-6-luna[1m]) and the API's (gpt-6-luna) name one model.
78const key = (model: string) => model.toLowerCase().replace(/\[[^\]]*\]$/, '').replace(/^.*(--|\/)/, '')
79const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
80const mins = (ms: number) => (ms >= 3600e3 ? `${Math.floor(ms / 3600e3)}h ${Math.round((ms % 3600e3) / 60e3)}m` : `${Math.max(1, Math.round(ms / 60e3))}m`)
81
82export const register: Register = (on, options) => {
83 const cfg = settings(options)
84
85 on('session.start', async ($, e, next) => {
86 await $.command.register({
87 name: 'spend',
88 description: 'Show or hide the live API-cost band above the prompt',
89 })
90
91 return next(e)
92 })
93
94 on('command.run', { command: 'spend' }, async $ => {
95 const next = !(await read($, isShown))
96 await update($, isShown, () => next)
97
98 return {}
99 })
100
101 on('turn.complete', async ($, e, next) => {
102 const u = e.usage
103 if (u) {
104 const base = priceOf(u.model, cfg)
105 // The turn's usage is summed over its requests; the context size now is the best
106 // stand-in for the biggest request's input.
107 const context = (await $.session.usage()).context.tokens ?? 0
108 const p = base && base.long && context > (base.longAt ?? LONG_CONTEXT) ? base.long : base
109 const cost = p
110 ? (u.input_tokens * p.input +
111 u.output_tokens * p.output +
112 u.cache_read_input_tokens * p.read +
113 u.cache_creation_input_tokens * p.write) /
114 1e6
115 : 0
116 const entry: Entry = {
117 at: await $.clock.now(),
118 usd: cost,
119 model: u.model,
120 read: u.cache_read_input_tokens,
121 input: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
122 isPriced: base !== undefined,
123 out: u.output_tokens,
124 ctx: context,
125 }
126 await update($, ledger, list => [...list, entry].slice(-2000))
127 }
128
129 return next(e)
130 })
131
132 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133 if (e.props.hasSurvey || !(await read($, isShown))) {
134 return next(e)
135 }
136
137 const list = await read($, ledger)
138 const now = await $.clock.now()
139 const total = list.reduce((s, x) => s + x.usd, 0)
140 const recent = list.filter(x => now - x.at <= cfg.windowMs).reduce((s, x) => s + x.usd, 0)
141 const input = list.reduce((s, x) => s + x.input, 0)
142 const cached = list.reduce((s, x) => s + x.read, 0)
143 const hit = input > 0 ? Math.round((cached / input) * 100) : 0
144 const unpriced = [...new Set(list.filter(x => !x.isPriced).map(x => short(x.model)))]
145
146 const byModel = new Map<string, number>()
147 for (const x of list) byModel.set(key(x.model), (byModel.get(key(x.model)) ?? 0) + x.usd)
148
149 // What the next prompt is expected to cost, from the live context and recent turns.
150 const usage = await $.session.usage({ breakdown: 'summary' })
151 const ctxTokens = usage.context.tokens ?? 0
152 const ctxWindow = usage.context.window
153 const model = await $.session.model()
154 const base = priceOf(model, cfg)
155 const p = base && base.long && ctxTokens > (base.longAt ?? LONG_CONTEXT) ? base.long : base
156 const mine = list.filter(x => key(x.model) === key(model))
157 const last = mine.length > 0 ? mine[mine.length - 1] : undefined
158 const idle = last ? now - last.at : Infinity
159 const ttl = ttlOf(model, cfg)
160 const isWarm = idle < ttl
161 const recentTurns = mine.slice(-8)
162 const avgOut = recentTurns.length ? recentTurns.reduce((s, x) => s + (x.out ?? 0), 0) / recentTurns.length : 0
163 const avgSteps = recentTurns.length
164 ? recentTurns.reduce((s, x) => s + Math.max(1, Math.round(x.input / Math.max(x.ctx ?? x.input, 1))), 0) / recentTurns.length
165 : 1
166 // A warm cache holds the prefix as of this model's last turn; whatever the conversation
167 // gained since (another model's turns, a pasted file) is written fresh. Measured on
168 // saved sessions: a detour through a GPT model leaves a Claude cache intact, but another
169 // Claude model's turn does not (thinking blocks are bound to the model that wrote them),
170 // so only the system prompt, tools and MCP prefix is read back.
171 const isClaude = key(model).startsWith('claude')
172 const lastIdx = last ? list.lastIndexOf(last) : -1
173 const intruder = isClaude && lastIdx >= 0
174 ? list.slice(lastIdx + 1).find(x => key(x.model).startsWith('claude') && key(x.model) !== key(model))
175 : undefined
176 const floor = (usage.context.breakdown?.categories ?? [])
177 .filter(c => !c.isDeferred && /^(System prompt|System tools|MCP tools|MCP server instructions)$/.test(c.name))
178 .reduce((s, c) => s + c.tokens, 0)
179 const cachedPrefix = !isWarm ? 0 : intruder ? Math.min(floor, ctxTokens) : Math.min(last?.ctx ?? ctxTokens, ctxTokens)
180 const estimate = p && ctxTokens > 0
181 ? (cachedPrefix * p.read +
182 (ctxTokens - cachedPrefix) * p.write +
183 (avgSteps - 1) * ctxTokens * p.read +
184 avgOut * p.output) / 1e6
185 : undefined
186 const fresh = ctxTokens - cachedPrefix
187
188 const pct = ctxWindow > 0 ? Math.min(100, Math.round((ctxTokens / ctxWindow) * 100)) : 0
189
190 // Context groups, coloured to match /usage so the two read alike.
191 const cats = (usage.context.breakdown?.categories ?? []).filter(c => !c.isDeferred)
192 const sum = (pick: (name: string) => boolean) =>
193 cats.filter(c => pick(c.name)).reduce((s, c) => s + c.tokens, 0)
194 const groups: Array<{ label: string; tokens: number; color: string }> = [
195 { label: 'messages', tokens: sum(n => n === 'Messages'), color: '#4a8af4' },
196 { label: 'MCP tools', tokens: sum(n => n === 'MCP tools'), color: '#e07b39' },
197 { label: 'system tools', tokens: sum(n => n === 'System tools'), color: '#3fae8a' },
198 { label: 'system prompt', tokens: sum(n => n === 'System prompt'), color: '#d6a232' },
199 {
200 label: 'other',
201 tokens: sum(n => !/^(Messages|MCP tools|System tools|System prompt)$|free|buffer/i.test(n)),
202 color: '#9a9a9a',
203 },
204 { label: 'buffer', tokens: sum(n => /buffer/i.test(n)), color: '#5c5c5c' },
205 ].filter(g => g.tokens > 0)
206 const TRACK = '#2c2c2c'
207 const inner = Math.max(20, e.props.bodyColumns - 4)
208 const hasRoom = inner >= 70
209
210 const ui = $.ui.resolve(e)
211 const { Box, Button, Text } = ui
212 const Svg = e.surface === 'terminal' ? undefined : ui.Svg
213
214 // A rounded, proportional bar. SVG on the desktop; coloured cells in a terminal.
215 const barW = 1600
216 const barH = 22
217 let x = 0
218 const rects = groups
219 .map(g => {
220 const w = Math.max(6, (g.tokens / Math.max(ctxWindow, 1)) * barW)
221 const r = `<rect x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${barH}" fill="${g.color}"/>`
222 x += w
223 return r
224 })
225 .join('')
226 const svg =
227 `<svg xmlns="http://www.w3.org/2000/svg" width="${barW}" height="${barH}" viewBox="0 0 ${barW} ${barH}">` +
228 `<defs><clipPath id="c"><rect width="${barW}" height="${barH}" rx="${barH / 2}"/></clipPath></defs>` +
229 `<g clip-path="url(#c)"><rect width="${barW}" height="${barH}" fill="${TRACK}"/>${rects}</g></svg>`
230 const cellsTotal = Math.max(10, inner)
231 const cellBar = groups.map(g => ({
232 color: g.color,
233 n: Math.max(1, Math.round((g.tokens / Math.max(ctxWindow, 1)) * cellsTotal)),
234 }))
235 const used = cellBar.reduce((s, c) => s + c.n, 0)
236
237 const hitColor = hit >= 80 ? '#5fd7a0' : hit >= 50 ? '#e5c07b' : '#e06c75'
238
239 return (
240 <Box flexDirection="column" borderStyle="round" borderColor="#4b5263" paddingX={1}>
241 <Box justifyContent="space-between">
242 <Box>
243 <Text color="#56b6c2" bold>◆ spend </Text>
244 <Text color="#5fd7a0" bold>{usd(total)}</Text>
245 <Text dimColor> session </Text>
246 <Text color="#e5c07b" bold>{usd(recent)}</Text>
247 <Text dimColor> last {Math.round(cfg.windowMs / 60e3)}m </Text>
248 <Text color={hitColor} bold>{hit}%</Text>
249 <Text dimColor> cache hit</Text>
250 </Box>
251 <Button key="close" label="✕" onPress={() => update($, isShown, () => false)} />
252 </Box>
253 {byModel.size > 0 && (
254 <Box flexDirection="column">
255 {[...byModel].sort((x, y) => y[1] - x[1]).map(([m, c]) => (
256 <Box>
257 <Text dimColor>{m} </Text>
258 <Text color="#c678dd" bold>{usd(c)}</Text>
259 <Text dimColor> {total > 0 ? Math.round((c / total) * 100) : 0}%</Text>
260 </Box>
261 ))}
262 </Box>
263 )}
264 {ctxTokens > 0 && (
265 <Box flexDirection="column">
266 <Box justifyContent="space-between">
267 <Box>
268 <Text dimColor>context </Text>
269 <Text bold>{tok(ctxTokens)}</Text>
270 <Text dimColor> / {tok(ctxWindow)} · </Text>
271 <Text bold>{pct}%</Text>
272 </Box>
273 {estimate !== undefined && (
274 <Box>
275 <Text dimColor>next prompt </Text>
276 <Text color="#e5c07b" bold>≈ {usd(estimate)}</Text>
277 {isWarm && intruder ? (
278 <Text color="#e06c75"> · {short(intruder.model)} broke the cache, re-caching {tok(fresh)}</Text>
279 ) : isWarm ? (
280 <Text color="#5fd7a0"> · warm {mins(ttl - idle)} left{fresh > 1000 ? `, ${tok(fresh)} new` : ''}</Text>
281 ) : (
282 <Text color="#e06c75"> · cold{last ? ` (idle ${mins(idle)})` : ' (no turns on this model yet)'}, caching {tok(ctxTokens)}</Text>
283 )}
284 </Box>
285 )}
286 </Box>
287 {Svg ? (
288 <Svg source={svg} alt={`context ${pct}% full`} />
289 ) : (
290 <Box>
291 {cellBar.map(c => (
292 <Text backgroundColor={c.color}>{' '.repeat(c.n)}</Text>
293 ))}
294 <Text backgroundColor={TRACK}>{' '.repeat(Math.max(0, cellsTotal - used))}</Text>
295 </Box>
296 )}
297 {hasRoom && (
298 <Box>
299 {groups.filter(g => g.label !== 'buffer').map((g, i) => (
300 <Text>
301 <Text dimColor>{i > 0 ? ' ' : ''}</Text>
302 <Text color={g.color}>{g.label} {tok(g.tokens)}</Text>
303 </Text>
304 ))}
305 </Box>
306 )}
307 </Box>
308 )}
309 {cfg.isCustomBad && (
310 <Text color="#e06c75">cost-bar: the customPrices setting is not valid JSON, so it was ignored</Text>
311 )}
312 {unpriced.length > 0 && (
313 <Text color="#e5c07b">no price for {unpriced.join(', ')} (counted as $0)</Text>
314 )}
315 </Box>
316 )
317 })
318}
319types/index.d.ts 8 lines1export type Entry = { at: number; usd: number; model: string; read: number; input: number; isPriced: boolean; out?: number; ctx?: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'cost-bar': { ledger: Entry[]; isShown: boolean }
6 }
7}
8