Token and USD meter for pay-per-token Amazon Bedrock sessions: a band above the prompt with cost, context, cache and today's total, priced at AWS's own…

A token and cost meter for Claude Code on Amazon Bedrock (pay per token, no subscription).
hooks/bands.ts (MIT), in the order their checklist saves./bedrock-cost: opens a pane with a per-model breakdown and the session and daily totals, next to Claude Code's own /cost estimate.hooks/prices.ts, generated from the AWS price list (us-east-1) by .github/scripts/bedrock_prices.py. A global. profile bills the global rates; in-region and geo profiles (us., eu., ...) the regional ones, which AWS sets about 10% higher for most current models and not at all for some. Cache writes bill at AWS's 5-minute cache-write rate, the TTL Claude Code uses by default; requests that write the 1-hour cache are underestimated. A weekly workflow reruns the script and opens a pull request when a price moves./config): a fallback regional premium % (for a model listed with global prices only), a session budget that shows a toast when it is passed, and whether the pane opens when a session starts.Every model request is counted, including subagents and compaction. Requests made before the mod loaded (or before a resume) are read back from the session transcript. For global. profiles the session cost matches Claude Code's own /cost, which prices at the global rates; for regional ones it comes out higher by AWS's regional surcharge. Daily totals carry across sessions, by local calendar day, and are kept for 31 days. /clear resets the session meter.
Costs are estimates from the price table (the pane says which AWS list it is from); your AWS bill is the final word.
python3 .github/scripts/bedrock_prices.py
From the repository's root: it fetches the AWS price list, rewrites bedrock-meter/hooks/prices.ts and bumps the mod's patch version when a price changed. Or edit a row of prices.ts directly (USD per million tokens). Then run claude plugin test bedrock-meter, commit and push.
At the prompt of a Claude Code terminal session (tested with Claude Code 2.1.306):
/plugin install bedrock-meter --marketplace weidu-tpvision/claude-mods
Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install bedrock-meter --marketplace weidu-tpvision/claude-mods.
hooks/register.tsx 356 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Context, Meter, Tally } from '../types'
5import { drawBand, hitRate, tokens, usd } from './band'
6import { bandsFile, parseOrder, stackBands } from './bands'
7import { PUBLISHED } from './prices'
8import { costOf, scopeFor } from './pricing'
9import type { Usage } from './pricing'
10
11const PANE = 'bedrock-meter'
12const EMPTY: Meter = { byModel: {}, isWarned: false, isBackfilled: false }
13const EARLIER = 'earlier requests (Claude Code estimate)'
14
15const ZERO: Tally = { requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, isPriced: true }
16
17const meter = atom({ plugin: 'bedrock-meter', key: 'meter' } as const, EMPTY)
18const context = atom({ plugin: 'bedrock-meter', key: 'context' } as const, null as Context | null)
19const todayTally = atom({ plugin: 'bedrock-meter', key: 'today' } as const, ZERO)
20
21// Daily totals older than this are dropped at each session start.
22const KEEP_DAYS = 31
23const DAY_MS = 86_400_000
24const TICK_MS = 60_000
25
26// `us.anthropic.claude-opus-5-5` and the transcript's `claude-opus-5-5` share a row.
27const rowOf = (model: string) => model.replace(/^.*anthropic\./, '')
28
29const add = (t: Tally, u: Usage, cost: number | undefined): Tally => ({
30 requests: t.requests + 1,
31 input: t.input + u.input_tokens,
32 output: t.output + u.output_tokens,
33 cacheRead: t.cacheRead + u.cache_read_input_tokens,
34 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
35 usd: t.usd + (cost ?? 0),
36 isPriced: t.isPriced && cost !== undefined,
37})
38
39const sum = (tallies: Tally[]): Tally =>
40 tallies.reduce(
41 (all, t) => ({
42 requests: all.requests + t.requests,
43 input: all.input + t.input,
44 output: all.output + t.output,
45 cacheRead: all.cacheRead + t.cacheRead,
46 cacheWrite: all.cacheWrite + t.cacheWrite,
47 usd: all.usd + t.usd,
48 isPriced: all.isPriced && t.isPriced,
49 }),
50 ZERO,
51 )
52
53const pad = (n: number) => String(n).padStart(2, '0')
54
55// The local calendar day, so "today" turns over at the person's midnight.
56export const dateOf = (ms: number) => {
57 const d = new Date(ms)
58 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
59}
60
61// Daily totals are kept per session (`day:<date>:<session>`), so rebuilding
62// one session's figures never counts another's twice.
63async function refreshToday($: EngineInterface) {
64 const prefix = `day:${dateOf(await $.clock.now())}:`
65 const keys = (await $.store.keys()).filter(key => key.startsWith(prefix))
66 const day = sum(await Promise.all(keys.map(async key => ((await $.store.get(key)) as Tally | undefined) ?? ZERO)))
67 await update($, todayTally, () => day)
68}
69
70// Drops the days past KEEP_DAYS, and the per-day keys of the first version.
71async function prune($: EngineInterface) {
72 const cutoff = dateOf((await $.clock.now()) - KEEP_DAYS * DAY_MS)
73 for (const key of await $.store.keys()) {
74 const date = /^day:(\d{4}-\d\d-\d\d)(:|$)/.exec(key)
75 if (date && (date[2] === '' || date[1]! < cutoff)) await $.store.delete(key)
76 }
77}
78
79type Settings = { premiumPct: number; budgetUsd: number }
80
81async function record($: EngineInterface, model: string, u: Usage, settings: Settings) {
82 const cost = costOf(model, u, scopeFor(model, await $.session.model()), settings.premiumPct)
83 await update($, meter, m => ({
84 ...m,
85 byModel: { ...m.byModel, [rowOf(model)]: add(m.byModel[rowOf(model)] ?? ZERO, u, cost) },
86 }))
87
88 const key = `day:${dateOf(await $.clock.now())}:${await $.session.id()}`
89 await $.store.set(key, add(((await $.store.get(key)) as Tally | undefined) ?? ZERO, u, cost))
90 await refreshToday($)
91
92 const m = await read($, meter)
93 const spent = sum(Object.values(m.byModel)).usd
94 if (settings.budgetUsd > 0 && !m.isWarned && spent >= settings.budgetUsd) {
95 await update($, meter, one => ({ ...one, isWarned: true }))
96 $.ui.toast(`Bedrock session passed ${usd(settings.budgetUsd)} (now ${usd(spent)})`)
97 }
98}
99
100// `at`: the response's time in ms, NaN when the line carries none.
101type Turn = { model: string; usage: Usage; at: number }
102
103// One response per message id: the transcript writes a line per content block.
104function parseTranscript(text: string, into: Map<string, Turn>) {
105 for (const line of text.split('\n')) {
106 if (!line.includes('"usage"')) continue
107 try {
108 const row = JSON.parse(line)
109 const message = row?.message
110 const u = message?.usage
111 if (row?.type !== 'assistant' || !u || typeof message.id !== 'string') continue
112 into.set(message.id, {
113 model: String(message.model ?? 'unknown'),
114 at: Date.parse(String(row.timestamp ?? '')),
115 usage: {
116 input_tokens: u.input_tokens ?? 0,
117 output_tokens: u.output_tokens ?? 0,
118 cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
119 cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
120 },
121 })
122 } catch {
123 // A line cut short while being written: the next load reads it whole.
124 }
125 }
126}
127
128// The session's transcript and its subagents', or undefined when one cannot
129// be found or read (over the 4 MiB a read allows).
130async function transcriptFiles($: EngineInterface, sid: string): Promise<string[] | undefined> {
131 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
132 const projects = `${config}/projects`
133 const slug = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
134 let dir = `${projects}/${slug}`
135 if (!(await $.fs.exists(`${dir}/${sid}.jsonl`))) {
136 const all = await $.fs.list(projects)
137 const found = []
138 for (const entry of all) {
139 if (entry.kind === 'dir' && (await $.fs.exists(`${projects}/${entry.name}/${sid}.jsonl`))) found.push(entry.name)
140 }
141 if (found.length === 0) return undefined
142 dir = `${projects}/${found[0]}`
143 }
144 const files = [`${dir}/${sid}.jsonl`]
145 const subagents = `${dir}/${sid}/subagents`
146 if (await $.fs.exists(subagents)) {
147 for (const entry of await $.fs.list(subagents)) {
148 if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) files.push(`${subagents}/${entry.name}`)
149 }
150 }
151 return files
152}
153
154// Counts what the session spent before the mod loaded, once per session.
155async function backfill($: EngineInterface, settings: Settings) {
156 if ((await read($, meter)).isBackfilled) return
157 const sid = await $.session.id()
158 const sessionModel = await $.session.model()
159 const now = await $.clock.now()
160
161 for (const key of await $.store.keys()) {
162 if (key.startsWith('day:') && key.endsWith(`:${sid}`)) await $.store.delete(key)
163 }
164
165 const turns = new Map<string, Turn>()
166 let isRead = false
167 try {
168 const files = await transcriptFiles($, sid)
169 if (files) {
170 for (const file of files) parseTranscript(await $.fs.read(file), turns)
171 isRead = true
172 }
173 } catch {
174 isRead = false
175 }
176
177 const byModel: Record<string, Tally> = {}
178 const days: Record<string, Tally> = {}
179 if (isRead) {
180 for (const turn of turns.values()) {
181 const cost = costOf(turn.model, turn.usage, scopeFor(turn.model, sessionModel), settings.premiumPct)
182 const date = dateOf(Number.isFinite(turn.at) ? turn.at : now)
183 byModel[rowOf(turn.model)] = add(byModel[rowOf(turn.model)] ?? ZERO, turn.usage, cost)
184 days[date] = add(days[date] ?? ZERO, turn.usage, cost)
185 }
186 } else {
187 // Claude Code prices at the global rates: a regional session adds the premium.
188 const engine = (await $.session.usage()).cost?.usd ?? 0
189 if (engine > 0) {
190 const premium = scopeFor(sessionModel, sessionModel) === 'regional' ? settings.premiumPct : 0
191 const tally = { ...ZERO, usd: engine * (1 + premium / 100) }
192 byModel[EARLIER] = tally
193 days[dateOf(now)] = tally
194 }
195 }
196
197 await update($, meter, () => ({ byModel, isWarned: false, isBackfilled: true }))
198 for (const [date, tally] of Object.entries(days)) await $.store.set(`day:${date}:${sid}`, tally)
199}
200
201// The band order file, as hamzafer's claude-code-mods checklist saves it; [] on any error.
202async function readBandOrder($: EngineInterface): Promise<string[]> {
203 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
204 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
205}
206
207export const register: Register = (on, options) => {
208 const settings: Settings = {
209 premiumPct: Number(options.regionalPremiumPct ?? 10),
210 budgetUsd: Number(options.budgetUsd ?? 0),
211 }
212 let lastModel = ''
213 let timer: Timer | undefined
214 let bandOrder: Promise<string[]> | undefined // read once per load
215
216 on('session.start', async ($, e, next) => {
217 const r = await next(e)
218 await $.command.register({
219 name: 'bedrock-cost',
220 description: 'Show Bedrock token usage and estimated cost by model',
221 })
222 if (options.openPane === true) void $.ui.open({ id: PANE, title: 'Bedrock meter' })
223 $.ui.status(undefined) // the band took over from the status line of the first version
224 await prune($)
225 await backfill($, settings)
226 await refreshToday($)
227 const usage = await $.session.usage().catch(() => undefined)
228 if (usage) {
229 const { tokens, window, percent } = usage.context
230 await update($, context, () => ({ tokens, window, percent }))
231 }
232
233 // Moves "today" on at the local midnight, with no request to trigger it.
234 let day = dateOf(await $.clock.now())
235 timer?.cancel()
236 timer = $.clock.every(TICK_MS, () => {
237 void (async () => {
238 const now = dateOf(await $.clock.now())
239 if (now !== day) {
240 day = now
241 await refreshToday($)
242 }
243 })().catch(() => {})
244 })
245 return r
246 })
247
248 on('command.run', { command: 'bedrock-cost' }, async $ => {
249 const opened = await $.ui.open({ id: PANE, title: 'Bedrock meter' })
250 const total = sum(Object.values((await read($, meter)).byModel))
251 const line =
252 total.requests > 0
253 ? `${usd(total.usd)} this session over ${total.requests} requests (cache hit ${hitRate(total)}%).`
254 : `${usd(total.usd)} this session (Claude Code's estimate of the requests before the meter loaded).`
255 return { text: opened.isPlaced ? line : `${line} Widen the terminal to see the pane.` }
256 })
257
258 // Every model request of every turn, main loop and subagents alike.
259 on('turn.step', async function* ($, e, next) {
260 const r = yield* next(e)
261 if (r.usage) {
262 lastModel = r.usage.model
263 await record($, r.usage.model, r.usage, settings)
264 }
265 return r
266 })
267
268 // The summarizer's own request when the engine compacts. Its usage names no
269 // model: the last one seen, or the session's before any request this load.
270 on('session.compact', async ($, e, next) => {
271 const r = await next(e)
272 if ('usage' in r && r.usage) await record($, lastModel || (await $.session.model()), r.usage, settings)
273 return r
274 }).catch(($, e, next) => next(e)) // a failed count never holds up the compaction: replays it, or runs it
275
276 on('session.measure', async ($, e, next) => {
277 const r = await next(e)
278 await update($, context, () => ({ tokens: e.context.tokens, window: e.context.window, percent: e.context.percent }))
279 return r
280 })
281
282 // A cleared session starts from nothing; there is no transcript to read back.
283 on('session.end', async ($, e, next) => {
284 if (e.reason === 'clear') await update($, meter, () => ({ ...EMPTY, isBackfilled: true }))
285 return next(e)
286 })
287
288 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
289 const rest = await next(e) // what other mods and Claude Code draw here stays
290 if (e.props.hasSurvey) return rest
291 const m = await read($, meter)
292 const figures = {
293 total: sum(Object.values(m.byModel)),
294 today: await read($, todayTally),
295 budgetUsd: settings.budgetUsd,
296 context: await read($, context),
297 }
298 const mine = drawBand($.ui.resolve(e), figures, e.props.bodyColumns - 2)
299 if (!mine) return rest
300 bandOrder ??= readBandOrder($).catch(() => [])
301 return stackBands(rest, 'bedrock-meter', mine, 'above', await bandOrder)
302 })
303
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305 const { Box, Text } = $.ui.resolve(e)
306 const m = await read($, meter)
307 const total = sum(Object.values(m.byModel))
308 const day = await read($, todayTally)
309 const ctx = await read($, context)
310 const engine = (await $.session.usage()).cost?.usd
311 const rows = Object.entries(m.byModel).sort(([, a], [, b]) => b.usd - a.usd)
312
313 return (
314 <Box flexDirection="column">
315 {rows.length === 0 && <Text dimColor>No Bedrock requests yet.</Text>}
316 {rows.map(([model, t]) => (
317 <Box flexDirection="column" key={model}>
318 <Text bold>{model}</Text>
319 <Text>
320 {' '}
321 {usd(t.usd)}
322 {t.isPriced ? '' : ' (unpriced model)'}
323 {t.requests > 0 ? ` · ${t.requests} req` : ''}
324 </Text>
325 {t.requests > 0 && (
326 <Text dimColor>
327 {' '}in {tokens(t.input)} · write {tokens(t.cacheWrite)} · read {tokens(t.cacheRead)} · out{' '}
328 {tokens(t.output)}
329 </Text>
330 )}
331 </Box>
332 ))}
333 {rows.length > 0 && (
334 <Box flexDirection="column" marginTop={1}>
335 <Text bold>
336 Session {usd(total.usd)} · cache hit {hitRate(total)}%
337 </Text>
338 <Text>Today {usd(day.usd)} · out {tokens(day.output)}</Text>
339 {ctx?.percent !== undefined && (
340 <Text>
341 Context {Math.round(ctx.percent)}%
342 {ctx.tokens !== undefined ? ` · ${tokens(ctx.tokens)} of ${tokens(ctx.window)}` : ''}
343 </Text>
344 )}
345 {engine !== undefined && <Text dimColor>Claude Code /cost (global rates) {usd(engine)}</Text>}
346 <Text dimColor>
347 Prices: AWS list of {PUBLISHED.slice(0, 10)}
348 {settings.budgetUsd > 0 ? ` · budget ${usd(settings.budgetUsd)}` : ''}
349 </Text>
350 </Box>
351 )}
352 </Box>
353 )
354 })
355}
356hooks/band.tsx 111 lines1import type { Elements } from 'claude-code'
2
3import type { Context, Tally } from '../types'
4
5type Ui = Pick<Elements['terminal'], 'Box' | 'Text'>
6
7export const tokens = (n: number) =>
8 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
9export const usd = (n: number) => (n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`)
10
11// Share of the prompt the cache served: the figure that drives the bill most.
12export const hitRate = (t: Tally) => {
13 const prompt = t.input + t.cacheRead + t.cacheWrite
14 return prompt === 0 ? 0 : Math.round((t.cacheRead / prompt) * 100)
15}
16
17export type Figures = { total: Tally; today: Tally; budgetUsd: number; context: Context | null }
18
19type Segment = { text: string; color?: string; dim?: boolean; bold?: boolean }
20
21type Layout = { isLong: boolean; cells: number; hasTokens: boolean; hasCache: boolean; hasToday: boolean }
22
23// Widest first: the band sheds token counts, then the cache figure and the
24// labels, then the bar and today's total, so it keeps to one line.
25const LAYOUTS: Layout[] = [
26 { isLong: true, cells: 8, hasTokens: true, hasCache: true, hasToday: true },
27 { isLong: true, cells: 8, hasTokens: false, hasCache: true, hasToday: true },
28 { isLong: false, cells: 5, hasTokens: false, hasCache: false, hasToday: true },
29 { isLong: false, cells: 0, hasTokens: false, hasCache: false, hasToday: false },
30]
31
32const SPLIT = ' · '
33const BLOCKS = '▁▂▃▄▅▆▇█'
34
35// Context fill: green, yellow from 75%, red from 90%, as usage-meter colors its windows.
36export const colorFor = (percent: number) => (percent >= 90 ? 'red' : percent >= 75 ? 'yellow' : 'green')
37
38// Spend against the budget: plain under 70%, yellow from there, red once passed.
39export const budgetColor = (usdSpent: number, budgetUsd: number) => {
40 if (budgetUsd <= 0) return undefined
41 const share = usdSpent / budgetUsd
42 return share >= 1 ? 'red' : share >= 0.7 ? 'yellow' : undefined
43}
44
45// Eighth blocks, so a few cells still move with each percent.
46export function bar(percent: number, cells: number) {
47 const filled = (Math.min(Math.max(percent, 0), 100) / 100) * cells
48 let out = ''
49 for (let i = 0; i < cells; i++) {
50 out += BLOCKS[Math.round(Math.min(Math.max(filled - i, 0), 1) * (BLOCKS.length - 1))]
51 }
52 return out
53}
54
55function parts(f: Figures, l: Layout): Segment[][] {
56 const out: Segment[][] = []
57 const { total, context } = f
58
59 if (total.requests > 0 || total.usd > 0) {
60 const color = budgetColor(total.usd, f.budgetUsd)
61 out.push([
62 { text: l.isLong ? 'Bedrock ' : '', dim: true },
63 { text: `${usd(total.usd)}${total.isPriced ? '' : '+'}`, color, bold: color !== undefined },
64 { text: l.isLong && f.budgetUsd > 0 ? ` of ${usd(f.budgetUsd)}` : '', dim: true },
65 ])
66 }
67
68 if (context?.percent !== undefined) {
69 const color = colorFor(context.percent)
70 out.push([
71 { text: 'ctx ', dim: true },
72 { text: l.cells > 0 ? `${bar(context.percent, l.cells)} ` : '', color },
73 { text: `${Math.round(context.percent)}%`, color },
74 {
75 text: l.isLong && context.tokens !== undefined ? ` ${tokens(context.tokens)}/${tokens(context.window)}` : '',
76 dim: true,
77 },
78 ])
79 }
80
81 if (total.requests > 0 || total.usd > 0) {
82 if (l.hasTokens) out.push([{ text: `in ${tokens(total.input + total.cacheWrite)} out ${tokens(total.output)}`, dim: true }])
83 if (l.hasCache) out.push([{ text: 'cache ', dim: true }, { text: `${tokens(total.cacheRead)} ${hitRate(total)}%` }])
84 if (l.hasToday) out.push([{ text: 'today ', dim: true }, { text: usd(f.today.usd) }])
85 }
86 return out
87}
88
89const join = (groups: Segment[][]): Segment[] =>
90 groups.flatMap((g, i) => (i > 0 ? [{ text: SPLIT, dim: true }, ...g] : g)).filter(s => s.text !== '')
91
92const widthOf = (segs: Segment[]) => [...segs.map(s => s.text).join('')].length
93
94// The meter at the widest layout that fits `columns`; undefined with nothing to show yet.
95export function drawBand(ui: Ui, f: Figures, columns: number) {
96 const { Box, Text } = ui
97 const fitting = LAYOUTS.map(l => join(parts(f, l))).find(segs => widthOf(segs) <= columns)
98 const segs = fitting ?? join(parts(f, LAYOUTS[LAYOUTS.length - 1]!))
99 if (segs.length === 0) return undefined
100
101 return (
102 <Box flexDirection="row" paddingX={1}>
103 {segs.map((s, i) => (
104 <Text key={String(i)} color={s.color} dimColor={s.dim} bold={s.bold} wrap="truncate">
105 {s.text}
106 </Text>
107 ))}
108 </Box>
109 )
110}
111hooks/bands.ts 75 lines1// From hamzafer/claude-code-mods (shared/bands.ts, MIT, (c) 2026 Hamza Zafar), unchanged,
2// so this band stacks in order with that repo's band mods (context-bar, token-weather, ...).
3// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
4// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
5// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
6// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
7// column, adds its own band and sorts again, so the last one to draw sorts them all.
8// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
9// Reading the file needs $, which Claude Code follows only within one file, so each mod
10// reads it in its own register file with bandsFile and parseOrder, once per session.
11import type { RenderElement, RenderNode } from 'claude-code'
12
13const COLUMN = 'bands'
14const PREFIX = 'band:'
15
16// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
17export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
18 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
19 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
20}
21
22// The names in an order file; [] when it is no JSON or not { order: string[] }.
23export function parseOrder(text: string): string[] {
24 try {
25 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
26 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
27 } catch {
28 return []
29 }
30}
31
32/**
33 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
34 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
35 * With an order, items that are no band come first, then bands not in the
36 * order (in their current order), then the listed ones in file order. With
37 * no order nothing moves, so the screen looks as it did before.
38 */
39export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
40 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
41 const others = unpack(rest)
42 const items = at === 'above' ? [own, ...others] : [...others, own]
43 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
44}
45
46// Another band mod's column gives up its items; anything else is one item.
47function unpack(rest: RenderElement | null | undefined): RenderNode[] {
48 if (!rest) return []
49 if (!isColumn(rest)) return [rest]
50 const kids = rest.children ?? []
51 const owner = rest.part?.plugin
52 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
53 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
54}
55
56function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
57 return el.type === 'Box' && el.props?.key === COLUMN
58}
59
60function bandName(node: RenderNode): string | undefined {
61 if (typeof node === 'string' || node.type !== 'Box') return undefined
62 const key = node.props?.key
63 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
64}
65
66function sorted(items: RenderNode[], order: string[]): RenderNode[] {
67 const rank = (n: RenderNode) => {
68 const name = bandName(n)
69 if (name === undefined) return -2 // not a band: first
70 const i = order.indexOf(name)
71 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
72 }
73 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
74}
75hooks/prices.ts 119 lines1// Generated by .github/scripts/bedrock_prices.py from the AWS price list
2// (AmazonBedrockFoundationModels, us-east-1). Rerun it, or edit by hand.
3// USD per million tokens, on-demand: `global` for global. inference profiles,
4// `regional` for in-region and geo (us., eu., apac.) ones; `*Long` past the
5// model's long-context threshold. Cache writes are the 5-minute TTL's.
6import type { Row } from './pricing'
7
8export const PUBLISHED = '2026-10-12T08:40:08Z'
9
10// Longest family first, so `opus-5-5` wins over `opus-5`.
11export const ROWS: Row[] = [
12 {
13 family: '3-5-sonnet',
14 regional: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
15 },
16 {
17 family: '3-7-sonnet',
18 regional: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
19 },
20 {
21 family: 'mythos-5-1',
22 global: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
23 regional: { input: 11, output: 55, cacheRead: 0.275, cacheWrite: 13.75 },
24 },
25 {
26 family: 'sonnet-4-5',
27 global: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
28 regional: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
29 },
30 {
31 family: 'sonnet-4-6',
32 global: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
33 regional: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
34 },
35 {
36 family: 'sonnet-5-5',
37 global: { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 },
38 regional: { input: 2.2, output: 11, cacheRead: 0.11, cacheWrite: 2.75 },
39 },
40 {
41 family: '3-5-haiku',
42 regional: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
43 },
44 {
45 family: 'fable-5-1',
46 global: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
47 regional: { input: 11, output: 55, cacheRead: 0.275, cacheWrite: 13.75 },
48 },
49 {
50 family: 'haiku-4-5',
51 global: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
52 regional: { input: 1.1, output: 5.5, cacheRead: 0.11, cacheWrite: 1.375 },
53 },
54 {
55 family: 'haiku-5-5',
56 global: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
57 regional: { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 },
58 globalLong: { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 },
59 regionalLong: { input: 0.55, output: 2.75, cacheRead: 0.055, cacheWrite: 0.6875 },
60 },
61 {
62 family: 'mythos-5',
63 regional: { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 },
64 },
65 {
66 family: 'opus-4-1',
67 regional: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
68 },
69 {
70 family: 'opus-4-5',
71 global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
72 regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
73 },
74 {
75 family: 'opus-4-6',
76 global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
77 regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
78 },
79 {
80 family: 'opus-4-7',
81 global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
82 regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
83 },
84 {
85 family: 'opus-4-8',
86 global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
87 regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
88 },
89 {
90 family: 'opus-5-5',
91 global: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
92 regional: { input: 4.4, output: 22, cacheRead: 0.22, cacheWrite: 5.5 },
93 },
94 {
95 family: 'sonnet-4',
96 global: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
97 regional: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
98 },
99 {
100 family: 'sonnet-5',
101 global: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
102 regional: { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 },
103 },
104 {
105 family: 'fable-5',
106 global: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
107 regional: { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 },
108 },
109 {
110 family: 'opus-4',
111 regional: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
112 },
113 {
114 family: 'opus-5',
115 global: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
116 regional: { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 },
117 },
118]
119hooks/pricing.ts 64 lines1import { ROWS } from './prices'
2
3// USD per million tokens. The table is in `prices.ts`, generated from AWS's
4// price list by `.github/scripts/bedrock_prices.py`, which a weekly workflow
5// runs to propose a change when AWS moves a price.
6export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
7
8// `global` serves global. inference profiles; `regional` in-region and geo
9// ones (us., eu., apac.). A model AWS sells in one kind only has that one.
10export type Row = { family: string; global?: Price; regional?: Price; globalLong?: Price; regionalLong?: Price }
11
12export type Scope = 'global' | 'regional'
13
14// Prompts over this many tokens bill at a model's `*Long` rates, where it has them.
15const LONG_PROMPT: Record<string, number> = { 'haiku-5-5': 100_000 }
16
17// `us.anthropic.claude-opus-5-5`, `anthropic.claude-sonnet-4-5-20250929-v1:0`,
18// an inference-profile ARN: all name the family after `claude-`.
19export const familyOf = (model: string): string | undefined => {
20 const id = model.toLowerCase()
21 return ROWS.find(row => {
22 const name = `claude-${row.family}`
23 const at = id.indexOf(name)
24 return at >= 0 && !/[a-z0-9]/.test(id[at + name.length] ?? '')
25 })?.family
26}
27
28export const isGlobal = (model: string) => /(^|\/)global\./.test(model)
29
30// The transcript names models bare (`claude-opus-5-5`); the session's own
31// model id then says which kind of inference profile served them.
32export const scopeFor = (model: string, sessionModel: string): Scope => {
33 const isNamed = /anthropic\./.test(model) || model.startsWith('arn:')
34 return isGlobal(isNamed ? model : sessionModel) ? 'global' : 'regional'
35}
36
37export type Usage = {
38 input_tokens: number
39 output_tokens: number
40 cache_read_input_tokens: number
41 cache_creation_input_tokens: number
42}
43
44// The request's cost in USD, or undefined for a model the table lacks. A
45// regional request on a model AWS lists globally alone is priced at the global
46// rates plus `premiumPct`, the surcharge AWS puts on the others.
47export const costOf = (model: string, u: Usage, scope: Scope = 'global', premiumPct = 0): number | undefined => {
48 const row = ROWS.find(one => one.family === familyOf(model))
49 if (!row) return undefined
50 const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
51 const isLong = prompt > (LONG_PROMPT[row.family] ?? Infinity)
52 const pick = (s: Scope) => (isLong && row[`${s}Long`]) || row[s]
53 const p = pick(scope) ?? pick(scope === 'global' ? 'regional' : 'global')
54 if (!p) return undefined
55 const surcharge = !pick(scope) && scope === 'regional' ? 1 + premiumPct / 100 : 1
56 const usd =
57 (u.input_tokens * p.input +
58 u.cache_creation_input_tokens * p.cacheWrite +
59 u.cache_read_input_tokens * p.cacheRead +
60 u.output_tokens * p.output) /
61 1_000_000
62 return usd * surcharge
63}
64types/index.d.ts 23 lines1export type Tally = {
2 requests: number
3 input: number
4 output: number
5 cacheRead: number
6 cacheWrite: number
7 usd: number
8 isPriced: boolean
9}
10
11// `isBackfilled`: the requests before the mod loaded are counted, from the
12// session's transcript or, failing that, Claude Code's own estimate.
13export type Meter = { byModel: Record<string, Tally>; isWarned: boolean; isBackfilled: boolean }
14
15// The live context window, as `session.measure` last reported it.
16export type Context = { tokens?: number; window: number; percent?: number }
17
18declare module 'claude-code' {
19 interface PluginState {
20 'bedrock-meter': { meter: Meter; context: Context | null; today: Tally }
21 }
22}
23