DeepSeek credit in Claude Code: real balance with a gauge, today's spend, the model it mostly went to, and this key's own spend against a limit

A Claude Code mod for when Claude Code runs on DeepSeek. It draws one line above the prompt:
◆ DeepSeek │ ☁ 44k ▅▂█ ▲ +6.3k 34% │ █████████▁ $9.22 of $10.00 left │ $0.78 today │ mostly deepseek-chat 92% │ key $0.22 of $1.00 left
From left to right:
| Part | What it shows |
|---|---|
☁ 44k ▅▂█ ▲ +6.3k 34% | Context window. A sky for how full it is (☀ under 25%, ☁ under 50%, ☂ under 75%, ☇ under 90%, red ↯ = compact soon), the tokens in it, one bar per recent prompt sized by the tokens it added, the last prompt's change, and the share of the window. A compaction shows at once (▼). |
█████████▁ $9.22 of $10.00 left | Real DeepSeek balance, read from DeepSeek's /user/balance. The yellow gauge is measured against your balance right after your last top-up (or the Gauge full at setting). It turns red under 10%. |
$0.78 today | Today's actual spend for the whole account: the first balance reading today, plus top-ups since, minus the balance now. |
mostly deepseek-chat 92% | Where the money went today: the model with the highest cost, estimated from the tokens each request used, at DeepSeek list prices. |
key $0.22 of $1.00 left | This API key's own spend against a limit you set. DeepSeek has no per-key limits, so the mod tracks them itself. |
If DeepSeek won't give the balance for your key, the gauge shows what's left of the key's own limit instead, and today's spend becomes an estimate (≈):
◆ DeepSeek │ ↯ 118k ▅██ ▲ +7k 92% │ ██▁▁▁▁▁▁▁▁ $0.22 of $1.00 key limit left │ ≈$0.78 today │ mostly deepseek-chat 92%
You also get:
/deepseek opens a side pane with the full breakdown: balance (topped up and granted), today's spend, context, cost per model, and the key's spend and limit. Press r or Refresh to read the balance again./deepseek refresh reads the balance now and prints a one-line summary.claude --version.~/.claude/settings.json: {
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
"ANTHROPIC_AUTH_TOKEN": "sk-...your DeepSeek key...",
"ANTHROPIC_MODEL": "deepseek-chat"
}
}
At the Claude Code prompt:
/plugin install deepseek-balance --marketplace oklahaha/deepseek-balance
Answer y to add the marketplace, then pick User scope so the mod runs in every session. Or from a shell:
claude plugin install deepseek-balance --marketplace oklahaha/deepseek-balance
The band appears in the session where you installed it, and in every session started after.
The mod needs a DeepSeek key to read the balance. It uses the first one it finds:
DEEPSEEK_API_KEY environment variable.ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY, only when ANTHROPIC_BASE_URL points at DeepSeek. An Anthropic key is never sent anywhere else.With the setup above, step 3 applies and there is nothing to set. To change settings, open /config (the mod's rows are under deepseek-balance). The API key is secret, so it isn't listed there: set it when you install, or from a shell with claude plugin configure deepseek-balance.
| Setting | Default | What it does |
|---|---|---|
| DeepSeek API key | empty | The key to read the balance with. Stored in secure storage. |
| This key's spend limit | 0 (none) | Limit for this key, in the balance currency. |
| Key limit period | day | What the limit counts: day, month or total. |
| Gauge full at | 0 (auto) | The balance that fills the gauge. Auto uses the balance right after your last top-up. |
| Context window (tokens) | 0 (auto) | The window the context is measured against. Auto uses Claude Code's figure for the model. Set 128000 to measure against DeepSeek V3.2's real limit. |
| Currency | auto | USD or CNY when the account holds both. |
| Refresh every (seconds) | 120 | How often the balance is read (at least 30). It is also read after each turn. |
| DeepSeek API base | https://api.deepseek.com | Where /user/balance is read from. |
claude plugin update deepseek-balance
claude plugin uninstall deepseek-balance
Then run /reload-plugins in an open session, or start a new one.
PRICES in hooks/ledger.ts. The balance and today's spend do not depend on them.GET <API base>/user/balance, sent with your DeepSeek key.sk-…ab12) to tell keys apart.| You see | Do this |
|---|---|
no API key (set DEEPSEEK_API_KEY or /config) | Set the DeepSeek API key setting, or DEEPSEEK_API_KEY, or point ANTHROPIC_BASE_URL at DeepSeek. |
key rejected (401) | The key is wrong or revoked. The key's own spend and limit still show. |
balance unavailable (…) | DeepSeek did not return a balance for this key. Spend shows as an estimate (≈). |
balance unreachable | No network to api.deepseek.com from this machine, or a proxy blocks it. |
| No band at all | Check claude --version (2.1.301+), that the mod is enabled in /plugin, then /reload-plugins. |
| The context share looks low | Claude Code may assume a larger window than DeepSeek's. Set Context window to 128000. |
git clone https://github.com/oklahaha/deepseek-balance
cd deepseek-balance
claude plugin validate .
claude plugin test .
claude --plugin-dir . # one session with your working copy loaded
| File | What it holds |
|---|---|
hooks/register.tsx | The hooks: reading the balance, tracking usage, the band, the pane, /deepseek. |
hooks/ledger.ts | Balance, today's spend, per-model and per-key costs, prices, the summary line. Pure functions. |
hooks/context.ts | The context weather: sky, per-prompt bars, trend. Pure functions. |
types/index.d.ts | The shapes the band and pane draw from. |
tests/ | claude plugin test suites: the pure logic, and the mod end to end with DeepSeek mocked. |
Guidelines for changes:
ledger.ts and context.ts as pure functions, and test it in tests/ledger.test.ts.http.fetch, session.usage, the store, the clock). Tests never call DeepSeek.claude plugin validate . and claude plugin test . before each commit.version in .claude-plugin/plugin.json for each release, so claude plugin update picks it up.The context weather (sky, per-prompt bars, trend) follows the idea of Anthropic's Token Weather example and the token-weather-usage mod by Eric Cologni (Apache-2.0). No code is copied.
hooks/register.tsx 505 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ContextView, KeyView, View } from '../types'
5import {
6 applyBalance,
7 applyUsage,
8 buildView,
9 clockTime,
10 dayKey,
11 emptyLedger,
12 money,
13 parseBalance,
14 statusLine,
15} from './ledger'
16import { pushReading, tokensShort, trendOf, turnBars, weatherOf } from './context'
17import type { Ledger } from './ledger'
18
19const PANE = 'deepseek-balance'
20const LEDGER = 'ledger'
21const view = atom({ plugin: 'deepseek-balance', key: 'view' } as const, null)
22const context = atom({ plugin: 'deepseek-balance', key: 'context' } as const, null)
23
24type Key = { secret: string; id: string; label: string }
25
26// The OpenRouter line's colors: a blue name and a solid yellow gauge.
27const BRAND = '#869cff'
28const GAUGE = '#fbd800'
29// Between the band's views, as on the Token Weather line.
30const SEPARATOR = ' │ '
31
32// The gauge stays yellow until it is nearly empty.
33const gaugeColor = (fraction: number) => (fraction < 0.1 ? 'error' : GAUGE)
34const filledCells = (fraction: number, cells: number) =>
35 Math.round(Math.max(0, Math.min(1, fraction)) * cells)
36
37let apiBase = 'https://api.deepseek.com'
38let refreshMs = 120_000
39let gaugeFull = 0
40let keyLimit = 0
41let keyLimitPeriod: KeyView['period'] = 'day'
42let currency = 'auto'
43let contextWindow = 0
44let apiKeyOption = ''
45
46let key: Key | null = null
47let timer: Timer | undefined
48let lastRefresh = 0
49// Store writes from a refresh and from a model step must not interleave.
50let queue: Promise<unknown> = Promise.resolve()
51function serial<T>(work: () => Promise<T>): Promise<T> {
52 const run = queue.then(work, work)
53 queue = run.catch(() => undefined)
54 return run
55}
56
57async function loadLedger($: EngineInterface): Promise<Ledger> {
58 const stored = (await $.store.get(LEDGER)) as Ledger | undefined
59 return stored ?? emptyLedger(dayKey(await $.clock.now()))
60}
61
62async function resolveKey($: EngineInterface): Promise<Key | null> {
63 const settingsEnv = ((await $.settings.read()).env ?? {}) as Record<string, string>
64 const baseUrl = (await $.env.get('ANTHROPIC_BASE_URL')) ?? settingsEnv.ANTHROPIC_BASE_URL ?? ''
65 const isDeepSeek = /deepseek/i.test(baseUrl)
66 const secret =
67 apiKeyOption ||
68 (await $.env.get('DEEPSEEK_API_KEY')) ||
69 settingsEnv.DEEPSEEK_API_KEY ||
70 // Claude Code's own credential goes to DeepSeek only when it already does.
71 (isDeepSeek
72 ? (await $.env.get('ANTHROPIC_AUTH_TOKEN')) ||
73 settingsEnv.ANTHROPIC_AUTH_TOKEN ||
74 (await $.env.get('ANTHROPIC_API_KEY')) ||
75 settingsEnv.ANTHROPIC_API_KEY
76 : '')
77 if (!secret) return null
78 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
79 const id = [...new Uint8Array(digest).slice(0, 6)]
80 .map(byte => byte.toString(16).padStart(2, '0'))
81 .join('')
82 return { secret, id, label: `sk-…${secret.slice(-4)}` }
83}
84
85async function readContext($: EngineInterface) {
86 const usage = await $.session.usage()
87 const held = await read($, context)
88 const next: ContextView = pushReading(held, usage.context.tokens, usage.context.window, contextWindow)
89 await update($, context, () => next)
90}
91
92async function publish($: EngineInterface, next: View) {
93 await update($, view, () => next)
94}
95
96async function warn($: EngineInterface, ledger: Ledger, shown: View): Promise<Ledger> {
97 const warned = { ...ledger.warned }
98 if (shown.gauge !== null && shown.gauge < 0.1 && warned.low !== ledger.day) {
99 warned.low = ledger.day
100 $.ui.toast(`DeepSeek balance is low: ${money(shown.balance?.total ?? 0, shown.currency)} left`)
101 }
102 const k = shown.key
103 if (k !== null && k.limit > 0 && k.spend >= k.limit) {
104 const period =
105 k.period === 'day' ? ledger.day : k.period === 'month' ? ledger.day.slice(0, 7) : 'total'
106 if (warned.limit !== period) {
107 warned.limit = period
108 $.ui.toast(
109 `DeepSeek key ${k.label} passed its ${k.period} limit: ${money(k.spend, shown.currency)} of ${money(k.limit, shown.currency)}`,
110 )
111 }
112 }
113 return { ...ledger, warned }
114}
115
116function refresh($: EngineInterface) {
117 return serial(async () => {
118 const now = await $.clock.now()
119 lastRefresh = now
120 let ledger = await loadLedger($)
121 const viewOf = (balance: View['balance'], status: View['status'], message: string) =>
122 buildView({
123 ledger,
124 balance,
125 status,
126 message,
127 gaugeFull,
128 key: key && { id: key.id, label: key.label },
129 keyLimit,
130 keyLimitPeriod,
131 now,
132 })
133
134 if (key === null) {
135 await publish($, viewOf(null, 'no-key', 'no API key (set DEEPSEEK_API_KEY or /config)'))
136 return
137 }
138 let shown: View
139 try {
140 const res = await $.http.fetch(`${apiBase}/user/balance`, {
141 headers: { Authorization: `Bearer ${key.secret}`, Accept: 'application/json' },
142 })
143 const balance = res.ok ? parseBalance(res.text, currency) : null
144 if (balance !== null) {
145 ledger = applyBalance(ledger, balance, now)
146 shown = viewOf(balance, 'ok', '')
147 } else if (res.status === 401) {
148 shown = viewOf(null, 'unauthorized', 'key rejected (401)')
149 } else if (res.ok) {
150 shown = viewOf(null, 'unavailable', 'no balance on this account')
151 } else {
152 shown = viewOf(null, 'unavailable', `balance unavailable (${res.status})`)
153 }
154 } catch (error) {
155 shown = viewOf(null, 'error', `balance unreachable`)
156 $.ui.log(`deepseek-balance: ${String(error)}`, { to: 'debug' })
157 }
158 ledger = await warn($, ledger, shown)
159 await $.store.set(LEDGER, ledger)
160 await publish($, shown)
161 })
162}
163
164export const register: Register = (on, options) => {
165 apiBase = String(options.apiBase || 'https://api.deepseek.com').replace(/\/+$/, '')
166 refreshMs = Math.max(30, Number(options.refreshSeconds) || 120) * 1000
167 gaugeFull = Number(options.gaugeFull) || 0
168 keyLimit = Number(options.keyLimit) || 0
169 keyLimitPeriod = String(options.keyLimitPeriod || 'day') as KeyView['period']
170 currency = String(options.currency || 'auto')
171 apiKeyOption = String(options.apiKey || '')
172 contextWindow = Number(options.contextWindow) || 0
173
174 on('session.start', async ($, e, next) => {
175 await $.command.register({
176 name: 'deepseek',
177 description: 'DeepSeek balance, today\'s spend and top model (args: refresh)',
178 })
179 key = await resolveKey($)
180 await publish(
181 $,
182 buildView({
183 ledger: await loadLedger($),
184 balance: null,
185 status: 'loading',
186 message: '',
187 gaugeFull,
188 key: key && { id: key.id, label: key.label },
189 keyLimit,
190 keyLimitPeriod,
191 now: await $.clock.now(),
192 }),
193 )
194 await readContext($)
195 timer?.cancel()
196 timer = $.clock.every(refreshMs, () => void refresh($))
197 void refresh($)
198 return next(e)
199 })
200
201 on('turn.step', async function* ($, e, next) {
202 const result = yield* next(e)
203 // The main conversation's window; a subagent's steps have windows of their own.
204 if (e.agentId === undefined) await readContext($)
205 const usage = result.usage
206 if (usage !== null) {
207 await serial(async () => {
208 const now = await $.clock.now()
209 const ledger = applyUsage(await loadLedger($), usage.model || e.model, usage, key?.id ?? null, now)
210 await $.store.set(LEDGER, ledger)
211 const held = await read($, view)
212 if (held !== null) {
213 await publish(
214 $,
215 buildView({
216 ledger,
217 balance: held.balance,
218 status: held.status,
219 message: held.message,
220 gaugeFull,
221 key: key && { id: key.id, label: key.label },
222 keyLimit,
223 keyLimitPeriod,
224 now,
225 }),
226 )
227 }
228 })
229 }
230 return result
231 })
232
233 // The balance moves as turns finish; read it again then, at most every 30 s.
234 on('turn.complete', async ($, e, next) => {
235 if (e.agentId === undefined) await readContext($)
236 if ((await $.clock.now()) - lastRefresh > 30_000) void refresh($)
237 return next(e)
238 })
239
240 // A compaction drops the context now, not at the next reply.
241 on('session.compact', async ($, e, next) => {
242 const result = await next(e)
243 if (e.agentId !== undefined || e.trigger === 'precompute' || 'skip' in result) return result
244 const held = await read($, context)
245 if (held !== null && result.tokensAfter !== undefined && result.tokensAfter > 0) {
246 await update($, context, () => pushReading(held, result.tokensAfter, held.window, 0))
247 } else {
248 await readContext($)
249 }
250 return result
251 })
252
253 on('command.run', { command: 'deepseek' }, async ($, e) => {
254 if (e.args.trim() === 'refresh') {
255 await refresh($)
256 const shown = await read($, view)
257 const window = await read($, context)
258 return { text: shown === null ? 'DeepSeek: no reading yet.' : statusLine(shown, window) }
259 }
260 await $.ui.open({ id: PANE, title: 'DeepSeek balance' })
261 return { text: 'DeepSeek balance pane opened.' }
262 })
263
264 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
265 const { Box, Button, Text } = $.ui.resolve(e)
266 const shown = await read($, view)
267 if (shown === null) {
268 return <Text dimColor>Reading the DeepSeek balance…</Text>
269 }
270 const cur = shown.currency
271 const cells = Math.max(10, Math.min(30, (e.props.bodyColumns ?? 40) - 16))
272 const b = shown.balance
273 const k = shown.key
274 const keyShare = k !== null && k.limit > 0 ? k.spend / k.limit : null
275 const ctx = await read($, context)
276
277 return (
278 <Box flexDirection="column">
279 {b === null ? (
280 <Text color="warning">Balance: {shown.message}</Text>
281 ) : (
282 <Box flexDirection="column">
283 <Text>
284 <Text bold>Balance </Text>
285 {money(b.total, cur)} {cur}
286 <Text dimColor>
287 {' '}
288 (topped up {money(b.toppedUp, cur)} · granted {money(b.granted, cur)})
289 </Text>
290 {b.isAvailable ? '' : <Text color="error"> insufficient</Text>}
291 </Text>
292 {shown.gauge !== null && (
293 <Text>
294 <Text backgroundColor={gaugeColor(shown.gauge)}>
295 {' '.repeat(filledCells(shown.gauge, cells))}
296 </Text>
297 <Text dimColor>{'▁'.repeat(cells - filledCells(shown.gauge, cells))}</Text>{' '}
298 {Math.round(shown.gauge * 100)}%
299 <Text dimColor> of {money(shown.gaugeFull ?? 0, cur)}</Text>
300 </Text>
301 )}
302 </Box>
303 )}
304 <Text>
305 <Text bold>Today </Text>
306 {shown.todayReal !== null
307 ? `${money(shown.todayReal, cur)} spent`
308 : `≈${money(shown.todayTracked, cur)} (estimated)`}
309 <Text dimColor>
310 {shown.sinceAt !== null && shown.todayReal !== null
311 ? ` since ${clockTime(shown.sinceAt)}`
312 : ''}
313 {shown.todayReal !== null ? ` · tracked here ≈${money(shown.todayTracked, cur)}` : ''}
314 </Text>
315 </Text>
316 {ctx !== null && (
317 <Box flexDirection="column">
318 <Text>
319 <Text bold>Context </Text>
320 {ctx.tokens === null || ctx.percent === null ? (
321 <Text dimColor>fills after the next reply · {tokensShort(ctx.window)} window</Text>
322 ) : (
323 <Text>
324 <Text color={weatherOf(ctx.percent).color} bold>
325 {weatherOf(ctx.percent).icon} {weatherOf(ctx.percent).word}
326 </Text>
327 {' '}
328 {tokensShort(ctx.tokens)} of {tokensShort(ctx.window)} tokens
329 </Text>
330 )}
331 </Text>
332 {ctx.percent !== null && (
333 <Text>
334 <Text backgroundColor={weatherOf(ctx.percent).color}>
335 {' '.repeat(filledCells(ctx.percent / 100, cells))}
336 </Text>
337 <Text dimColor>{'▁'.repeat(cells - filledCells(ctx.percent / 100, cells))}</Text>{' '}
338 {ctx.percent}% used
339 </Text>
340 )}
341 {ctx.history.length >= 2 && (
342 <Text>
343 <Text dimColor> last prompts </Text>
344 <Text dimColor>{turnBars(ctx.history).slice(0, -1)}</Text>
345 <Text color={weatherOf(ctx.percent ?? 0).color}>{turnBars(ctx.history).slice(-1)}</Text>
346 <Text dimColor> {trendOf(ctx.history)}</Text>
347 </Text>
348 )}
349 </Box>
350 )}
351 <Text bold>Where it went today</Text>
352 {shown.models.length === 0 && <Text dimColor> no model calls tracked yet today</Text>}
353 {shown.models.slice(0, 6).map(m => (
354 <Text>
355 {' '}
356 {m.name.padEnd(22)} {money(m.cost, cur).padStart(8)}{' '}
357 <Text dimColor>{String(Math.round(m.share * 100)).padStart(3)}%</Text>
358 </Text>
359 ))}
360 {k !== null && (
361 <Box flexDirection="column">
362 <Text>
363 <Text bold>This key </Text>
364 {k.label} {money(k.spend, cur)}{' '}
365 {k.period === 'day' ? 'today' : k.period === 'month' ? 'this month' : 'all time'}
366 {k.limit > 0 ? ` of ${money(k.limit, cur)} limit` : ' (no limit set)'}
367 </Text>
368 {keyShare !== null && (
369 <Text>
370 <Text backgroundColor={gaugeColor(1 - keyShare)}>
371 {' '.repeat(filledCells(keyShare, cells))}
372 </Text>
373 <Text dimColor>{'▁'.repeat(cells - filledCells(keyShare, cells))}</Text>{' '}
374 {Math.round(keyShare * 100)}% used
375 </Text>
376 )}
377 <Text dimColor> all time {money(k.total, cur)}</Text>
378 </Box>
379 )}
380 <Box flexDirection="row">
381 <Text dimColor>
382 {shown.updatedAt !== null ? `Updated ${clockTime(shown.updatedAt)} ` : ''}
383 </Text>
384 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
385 </Box>
386 </Box>
387 )
388 })
389
390 // The band above the prompt: the gauge in color, then what is left and today's spend.
391 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
392 const shown = await read($, view)
393 if (e.props.hasSurvey || shown === null) {
394 return next(e)
395 }
396 const { Box, Text } = $.ui.resolve(e)
397 const cur = shown.currency
398 const b = shown.balance
399 const k = shown.key
400 const keyLeft = k !== null && k.limit > 0 ? Math.max(0, k.limit - k.spend) : null
401 // The account balance when DeepSeek gives it, else what is left of this key's limit.
402 const fill = shown.gauge ?? (keyLeft !== null && k !== null ? keyLeft / k.limit : null)
403 const top = shown.models[0]
404 const today = shown.todayReal ?? (shown.todayTracked > 0 ? shown.todayTracked : null)
405 const isWide = (e.props.bodyColumns ?? 80) >= 90
406 const ctx = await read($, context)
407
408 // Each view of the band, left to right: context, balance, today, top model, key.
409 const views: JSX.Element[] = []
410 if (ctx !== null && ctx.tokens !== null && ctx.percent !== null) {
411 const sky = weatherOf(ctx.percent)
412 const bars = turnBars(ctx.history)
413 views.push(
414 <Text>
415 <Text color={sky.color} bold>{sky.icon}</Text> <Text bold>{tokensShort(ctx.tokens)}</Text>
416 {ctx.history.length >= 2 && (
417 <Text>
418 {' '}
419 <Text dimColor>{bars.slice(0, -1)}</Text>
420 <Text color={sky.color}>{bars.slice(-1)}</Text>
421 <Text dimColor> {trendOf(ctx.history)}</Text>
422 </Text>
423 )}
424 {isWide && <Text dimColor> {ctx.percent}%</Text>}
425 </Text>,
426 )
427 }
428 views.push(
429 <Text>
430 {fill !== null && (
431 <Text>
432 <Text backgroundColor={gaugeColor(fill)}>{' '.repeat(filledCells(fill, 10))}</Text>
433 <Text dimColor>{'▁'.repeat(10 - filledCells(fill, 10))}</Text>{' '}
434 </Text>
435 )}
436 {b !== null ? (
437 <Text>
438 <Text color={gaugeColor(fill ?? 1)} bold>{money(b.total, cur)}</Text>
439 <Text dimColor>
440 {shown.gaugeFull !== null ? ` of ${money(shown.gaugeFull, cur)}` : ''} left
441 </Text>
442 {b.isAvailable ? '' : <Text color="error"> insufficient</Text>}
443 </Text>
444 ) : keyLeft !== null && k !== null ? (
445 <Text>
446 <Text color={gaugeColor(fill ?? 1)} bold>{money(keyLeft, cur)}</Text>
447 <Text dimColor> of {money(k.limit, cur)} key limit left</Text>
448 </Text>
449 ) : (
450 <Text color="warning">{shown.status === 'loading' ? 'reading balance…' : shown.message}</Text>
451 )}
452 </Text>,
453 )
454 if (today !== null) {
455 views.push(
456 <Text>
457 <Text bold>{shown.todayReal === null ? '≈' : ''}{money(today, cur)}</Text>
458 <Text dimColor> today</Text>
459 </Text>,
460 )
461 }
462 if (top !== undefined && top.cost > 0) {
463 views.push(
464 <Text>
465 <Text dimColor>mostly </Text>
466 {top.name}
467 {isWide && <Text dimColor> {Math.round(top.share * 100)}%</Text>}
468 </Text>,
469 )
470 }
471 if (b !== null && k !== null && isWide) {
472 views.push(
473 <Text>
474 <Text dimColor>key </Text>
475 {keyLeft !== null ? (
476 <Text>
477 <Text bold>{money(keyLeft, cur)}</Text>
478 <Text dimColor> of {money(k.limit, cur)} left</Text>
479 </Text>
480 ) : (
481 <Text>
482 <Text bold>{money(k.spend, cur)}</Text>
483 <Text dimColor> spent</Text>
484 </Text>
485 )}
486 </Text>,
487 )
488 }
489
490 return (
491 <Box flexDirection="row">
492 <Text wrap="truncate">
493 <Text color={BRAND} bold>◆ DeepSeek</Text>
494 {views.map(one => (
495 <Text>
496 <Text dimColor>{SEPARATOR}</Text>
497 {one}
498 </Text>
499 ))}
500 </Text>
501 </Box>
502 )
503 })
504}
505hooks/ledger.ts 273 lines1import type { Balance, ContextView, Currency, KeyView, ModelCost, View } from '../types'
2import { contextLine } from './context'
3
4/** DeepSeek list prices per 1M tokens (deepseek-chat and deepseek-reasoner, V3.2). */
5const PRICES: Record<Currency, { hit: number; miss: number; out: number }> = {
6 USD: { hit: 0.028, miss: 0.28, out: 0.42 },
7 CNY: { hit: 0.2, miss: 2, out: 3 },
8}
9
10const EPSILON = 0.005
11
12export type Usage = {
13 input_tokens: number
14 output_tokens: number
15 cache_read_input_tokens: number
16 cache_creation_input_tokens: number
17}
18
19export type KeySpend = {
20 day: string
21 today: number
22 month: string
23 thisMonth: number
24 total: number
25}
26
27/** What the mod keeps in `$.store` across sessions. */
28export type Ledger = {
29 day: string
30 currency: Currency
31 /** First balance read today: the base "today's spend" is measured from. */
32 dayOpen: number | null
33 dayOpenAt: number | null
34 /** Balance increases seen today (top-ups, grants), added back to the spend. */
35 topUps: number
36 last: number | null
37 /** Balance right after the latest top-up: what fills the gauge by default. */
38 peak: number | null
39 /** Tracked cost today by the model that answered. */
40 models: Record<string, number>
41 keys: Record<string, KeySpend>
42 warned: { low?: string; limit?: string }
43}
44
45export const emptyLedger = (day: string): Ledger => ({
46 day,
47 currency: 'USD',
48 dayOpen: null,
49 dayOpenAt: null,
50 topUps: 0,
51 last: null,
52 peak: null,
53 models: {},
54 keys: {},
55 warned: {},
56})
57
58const pad = (n: number) => String(n).padStart(2, '0')
59
60export const dayKey = (ms: number) => {
61 const d = new Date(ms)
62 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
63}
64
65export const clockTime = (ms: number) => {
66 const d = new Date(ms)
67 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
68}
69
70/** Starts a new day: today's figures reset, the balance history stays. */
71export const rollover = (ledger: Ledger, day: string): Ledger =>
72 ledger.day === day
73 ? ledger
74 : { ...ledger, day, dayOpen: null, dayOpenAt: null, topUps: 0, models: {} }
75
76/** Reads DeepSeek's /user/balance body; picks the currency asked for or the funded one. */
77export const parseBalance = (text: string, want: string): Balance | null => {
78 const body = JSON.parse(text) as {
79 is_available?: boolean
80 balance_infos?: {
81 currency?: string
82 total_balance?: string | number
83 granted_balance?: string | number
84 topped_up_balance?: string | number
85 }[]
86 }
87 const all = (body.balance_infos ?? [])
88 .filter(info => info.currency === 'USD' || info.currency === 'CNY')
89 .map(info => ({
90 currency: info.currency as Currency,
91 total: Number(info.total_balance ?? 0),
92 granted: Number(info.granted_balance ?? 0),
93 toppedUp: Number(info.topped_up_balance ?? 0),
94 isAvailable: body.is_available !== false,
95 }))
96 return (
97 all.find(one => one.currency === want) ??
98 all.find(one => one.total > 0) ??
99 all[0] ??
100 null
101 )
102}
103
104export const applyBalance = (ledger: Ledger, balance: Balance, now: number): Ledger => {
105 let next = rollover(ledger, dayKey(now))
106 if (next.currency !== balance.currency) {
107 // Tracked costs were priced in the other currency: start them over.
108 next = { ...emptyLedger(next.day), currency: balance.currency, warned: next.warned }
109 }
110 const total = balance.total
111 let { topUps, peak } = next
112 if (next.last !== null && total > next.last + EPSILON) {
113 // A top-up: the gauge refills, and today's spend is not reduced by it.
114 if (next.dayOpen !== null) topUps += total - next.last
115 peak = total
116 }
117 if (peak === null || total > peak) {
118 peak = total
119 }
120 return {
121 ...next,
122 dayOpen: next.dayOpen ?? total,
123 dayOpenAt: next.dayOpenAt ?? now,
124 topUps,
125 peak,
126 last: total,
127 }
128}
129
130export const costOf = (usage: Usage, currency: Currency) => {
131 const price = PRICES[currency]
132 const miss = usage.input_tokens + usage.cache_creation_input_tokens
133 return (
134 (usage.cache_read_input_tokens * price.hit + miss * price.miss + usage.output_tokens * price.out) /
135 1_000_000
136 )
137}
138
139export const applyUsage = (
140 ledger: Ledger,
141 model: string,
142 usage: Usage,
143 keyId: string | null,
144 now: number,
145): Ledger => {
146 const day = dayKey(now)
147 const next = rollover(ledger, day)
148 const cost = costOf(usage, next.currency)
149 const models = { ...next.models, [model]: (next.models[model] ?? 0) + cost }
150 if (keyId === null) {
151 return { ...next, models }
152 }
153 const month = day.slice(0, 7)
154 const was = next.keys[keyId]
155 const spend: KeySpend = {
156 day,
157 today: (was?.day === day ? was.today : 0) + cost,
158 month,
159 thisMonth: (was?.month === month ? was.thisMonth : 0) + cost,
160 total: (was?.total ?? 0) + cost,
161 }
162 return { ...next, models, keys: { ...next.keys, [keyId]: spend } }
163}
164
165export const keySpendFor = (
166 spend: KeySpend | undefined,
167 period: KeyView['period'],
168 now: number,
169) => {
170 if (spend === undefined) return 0
171 const day = dayKey(now)
172 if (period === 'day') return spend.day === day ? spend.today : 0
173 if (period === 'month') return spend.month === day.slice(0, 7) ? spend.thisMonth : 0
174 return spend.total
175}
176
177export type ViewInput = {
178 ledger: Ledger
179 balance: Balance | null
180 status: View['status']
181 message: string
182 gaugeFull: number
183 key: { id: string; label: string } | null
184 keyLimit: number
185 keyLimitPeriod: KeyView['period']
186 now: number
187}
188
189export const buildView = (input: ViewInput): View => {
190 const { ledger, balance, now } = input
191 const current = rollover(ledger, dayKey(now))
192 const tracked = Object.values(current.models).reduce((sum, cost) => sum + cost, 0)
193 const models: ModelCost[] = Object.entries(current.models)
194 .map(([name, cost]) => ({ name, cost, share: tracked > 0 ? cost / tracked : 0 }))
195 .sort((a, b) => b.cost - a.cost)
196 const full = input.gaugeFull > 0 ? input.gaugeFull : current.peak
197 const gauge =
198 balance !== null && full !== null && full > 0
199 ? Math.max(0, Math.min(1, balance.total / full))
200 : null
201 const todayReal =
202 balance !== null && current.dayOpen !== null && current.last !== null
203 ? Math.max(0, current.dayOpen + current.topUps - current.last)
204 : null
205 const key: KeyView | null =
206 input.key === null
207 ? null
208 : {
209 label: input.key.label,
210 spend: keySpendFor(current.keys[input.key.id], input.keyLimitPeriod, now),
211 limit: input.keyLimit,
212 period: input.keyLimitPeriod,
213 total: current.keys[input.key.id]?.total ?? 0,
214 }
215 return {
216 status: input.status,
217 message: input.message,
218 currency: balance?.currency ?? current.currency,
219 balance,
220 gauge,
221 gaugeFull: balance === null ? null : full,
222 todayReal,
223 todayTracked: tracked,
224 sinceAt: current.dayOpenAt,
225 models,
226 key,
227 updatedAt: now,
228 }
229}
230
231export const money = (amount: number, currency: Currency) => {
232 const sign = currency === 'CNY' ? '¥' : '$'
233 const digits = Math.abs(amount) >= 0.1 || amount === 0 ? 2 : 3
234 return `${sign}${amount.toFixed(digits)}`
235}
236
237export const bar = (fraction: number, cells: number) => {
238 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * cells)
239 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
240}
241
242/** The one-line summary `/deepseek refresh` prints. */
243export const statusLine = (view: View, context: ContextView | null = null) => {
244 const cur = view.currency
245 // Same order as the band: context, balance, today, top model, key.
246 const parts: string[] = ['DeepSeek']
247 if (context !== null && context.percent !== null) {
248 parts.push(contextLine(context))
249 }
250 if (view.balance !== null) {
251 const gauge =
252 view.gauge === null ? '' : ` ${bar(view.gauge, 10)} ${Math.round(view.gauge * 100)}%`
253 const dry = view.balance.isAvailable ? '' : ' (insufficient)'
254 parts.push(`${money(view.balance.total, cur)}${gauge}${dry}`)
255 } else {
256 parts.push(view.status === 'loading' ? '…' : view.message)
257 }
258 if (view.todayReal !== null) {
259 parts.push(`today ${money(view.todayReal, cur)}`)
260 } else if (view.todayTracked > 0) {
261 parts.push(`today ≈${money(view.todayTracked, cur)}`)
262 }
263 const top = view.models[0]
264 if (top !== undefined && top.cost > 0) {
265 parts.push(`mostly ${top.name} ${Math.round(top.share * 100)}%`)
266 }
267 if (view.key !== null) {
268 const limit = view.key.limit > 0 ? `/${money(view.key.limit, cur)} ${view.key.period}` : ''
269 parts.push(`key ${view.key.label} ${money(view.key.spend, cur)}${limit}`)
270 }
271 return parts.join(' │ ')
272}
273hooks/context.ts 83 lines1import type { ContextView } from '../types'
2
3// The context's weather, after the Token Weather idea (see the README's credits):
4// a sky by how full the window is, one bar per recent prompt, the last prompt's change.
5
6/** Token readings kept for the bars: one more than the bars drawn. */
7const HISTORY = 6
8const BARS = '▁▂▃▄▅▆▇█'
9
10export type Weather = { word: string; icon: string; color: string }
11
12const SKIES: { below: number; weather: Weather }[] = [
13 { below: 25, weather: { word: 'clear', icon: '☀', color: '#e0b000' } },
14 { below: 50, weather: { word: 'cloudy', icon: '☁', color: '#1ba1c4' } },
15 { below: 75, weather: { word: 'showers', icon: '☂', color: '#2f68c0' } },
16 { below: 90, weather: { word: 'storm', icon: '☇', color: '#b04fc0' } },
17 { below: Infinity, weather: { word: 'compact soon', icon: '↯', color: 'error' } },
18]
19
20export const weatherOf = (percent: number): Weather =>
21 (SKIES.find(sky => percent < sky.below) ?? SKIES[SKIES.length - 1]!).weather
22
23/** 950, 6.3k, 98.3k, 128k, 1.2M: a decimal only where it says something. */
24export const tokensShort = (n: number) => {
25 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
26 if (n >= 100_000) return `${Math.round(n / 1000)}k`
27 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
28 return String(n)
29}
30
31/**
32 * Adds Claude Code's latest reading to the history. The window is Claude Code's,
33 * or the one set in the options; a repeated reading adds nothing.
34 */
35export const pushReading = (
36 held: ContextView | null,
37 tokens: number | undefined,
38 window: number,
39 override: number,
40): ContextView => {
41 const size = override > 0 ? override : window
42 const history = (held?.history ?? []).filter(n => n > 0)
43 if (tokens !== undefined && tokens > 0 && history[history.length - 1] !== tokens) {
44 history.push(tokens)
45 }
46 const now = tokens ?? null
47 return {
48 tokens: now,
49 window: size,
50 percent: now === null || size <= 0 ? null : Math.round((now / size) * 100),
51 history: history.slice(-HISTORY),
52 }
53}
54
55/** Tokens each recent prompt added, oldest first; a compaction counts as 0. */
56export const turnDeltas = (history: readonly number[]) =>
57 history.slice(1).map((tokens, i) => Math.max(0, tokens - history[i]!))
58
59/** One glyph per prompt, scaled to the heaviest one shown. */
60export const turnBars = (history: readonly number[]) => {
61 const deltas = turnDeltas(history)
62 const top = Math.max(...deltas, 1)
63 return deltas.map(d => BARS[Math.round((d / top) * (BARS.length - 1))]!).join('')
64}
65
66/** The last prompt's change: ▲ +6.3k, ▼ −120k, or = when it held. */
67export const trendOf = (history: readonly number[]) => {
68 if (history.length < 2) return ''
69 const delta = history[history.length - 1]! - history[history.length - 2]!
70 if (delta > 0) return `▲ +${tokensShort(delta)}`
71 if (delta < 0) return `▼ −${tokensShort(-delta)}`
72 return '='
73}
74
75/** The context as one line of text: `☂ 44k ▃▄▂█▆ ▲ +6.3k 34%`. */
76export const contextLine = (ctx: ContextView) => {
77 if (ctx.tokens === null || ctx.percent === null) return ''
78 const parts = [`${weatherOf(ctx.percent).icon} ${tokensShort(ctx.tokens)}`]
79 if (ctx.history.length >= 2) parts.push(turnBars(ctx.history), trendOf(ctx.history))
80 parts.push(`${ctx.percent}%`)
81 return parts.join(' ')
82}
83types/index.d.ts 54 lines1export type Currency = 'USD' | 'CNY'
2
3/** One entry of DeepSeek's GET /user/balance `balance_infos`, as numbers. */
4export type Balance = {
5 currency: Currency
6 total: number
7 granted: number
8 toppedUp: number
9 isAvailable: boolean
10}
11
12export type ModelCost = { name: string; cost: number; share: number }
13
14export type KeyView = {
15 label: string
16 spend: number
17 limit: number
18 period: 'day' | 'month' | 'total'
19 total: number
20}
21
22export type ViewStatus = 'loading' | 'ok' | 'no-key' | 'unauthorized' | 'unavailable' | 'error'
23
24/** Everything the status line and the pane draw from. */
25export type View = {
26 status: ViewStatus
27 message: string
28 currency: Currency
29 balance: Balance | null
30 gauge: number | null
31 gaugeFull: number | null
32 todayReal: number | null
33 todayTracked: number
34 sinceAt: number | null
35 models: ModelCost[]
36 key: KeyView | null
37 updatedAt: number | null
38}
39
40/** The live context window: the last response's input tokens against the window. */
41export type ContextView = {
42 tokens: number | null
43 window: number
44 percent: number | null
45 /** Recent token readings, oldest first: what the per-prompt bars are drawn from. */
46 history: number[]
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'deepseek-balance': { view: View | null; context: ContextView | null }
52 }
53}
54