SLOPSHOPPER

cost-bar

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

newbandcommand
★ 1v0.2.0MITupdated 2026-10-23tsruu/claude-cost-bar/cost-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cost-bar
› 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 › /spend ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ spend $0.09 session $0.09 last 10m 93% cache hit [ ✕ ] │ │ claude-opus-5-5 $0.09 100% │ │ context 97k / 200k · 49% next prompt ≈ $0.05 · warm 60m left │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ spend $0.09 session $0.09 last 10m 93% cache hit [ ✕ ] │ │ claude-opus-5-5 $0.09 100% │ │ context 97k / 200k · 49% next prompt ≈ $0.05 · warm 60m left │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

claude-cost-bar

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 band across three models (Opus 5.5, GPT-6 Sol, Sonnet 5.5): session spend, per-model costs, context bar and next-prompt estimate

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.

What it shows

  • Spend: session total, spend in the last 10 minutes, and cache-hit %.
  • Per model: one row per model with its cost and share of the total, so mid-session model switches add up correctly.
  • Context: a rounded, full-width bar split into messages, MCP tools, system tools, system prompt and other, coloured like /usage, with token counts.
  • Next prompt: a guess at what your next prompt will cost, and whether the cache looks warm (with time left) or cold. It is based on timers and on what the author observed, and a model switch can lose a cache the timer says is still good (see below).

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.

Install

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.

Settings

Open /plugin, pick cost-bar and change the settings. No file editing needed.

SettingDefaultWhat it does
Claude cache lifetime1h1h 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)24How 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)10The "last N minutes" figure.
Custom prices (JSON)emptyOverride 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.

What switching models does to the cache

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:

SituationWhat 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 OpusThe 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 → GPTThe 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:

  • GPT models count tokens differently. For the same conversation one GPT model reported about 50k tokens where Claude counted 79k. The band's context figure comes from the last reply, so right after a GPT turn it shows the GPT count, not what Claude will see.
  • The band currently assumes that a turn from a different Claude model breaks the cache you left, and that a GPT detour doesn't. The observations above show both assumptions can be wrong, which is why the next-prompt number is a guess.
  • The cost of a cold switch is the whole context times the target model's cache-write price, so switching early, or after compacting, costs least. Sticking to one model per conversation is the safest.
  • After more than the cache lifetime away, everything is cold again.

Prices and assumptions

All figures are API-equivalent estimates, not a bill.

  • Built-in Claude prices come from Anthropic's published pricing page; cache writes assume the 1-hour rate unless you change the setting.
  • The built-in GPT-6 prices are from a third-party aggregator that reads the Azure Retail Prices API. Microsoft's own pricing page did not list them when this was written. Check them against your own Azure pricing, and override with Custom prices if they differ (Data Zone and Flex rates, for example, are different).
  • Long-context tiers are included for GPT-6 (above 272k tokens) and Claude Haiku 5.5 (above 100k).
  • Requests that fail (for example rate-limit errors) report no usage and count as $0.
  • The built-in table is in cost-bar/hooks/register.tsx if you'd rather edit it there.

Limits

  • The next-prompt estimate can be badly wrong after a model switch (see above). Spend, per-model costs and the context breakdown are measured from the API's own token counts.
  • Totals are per session and reset when the session id changes (for example after a rewind or restart).
  • Typing /spend leaves the command line in the chat. A mod can hide its output but not the typed command.
  • Tested on Claude Code 2.1.308 (desktop app). Mods are new; the API may change.

License

MIT

Source 2 files
hooks/register.tsx 319 lines
1import { 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}
319
types/index.d.ts 8 lines
1export 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