SLOPSHOPPER

deepseek-balance

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

newpanebandcommandtoastnetwork
v0.1.0no licenseupdated 2026-10-16oklahaha/deepseek-balance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deepseek-balance
│ ┃ DeepSeek balance ✕ › fix the failing auth test and add an audit log call │ ┃ Balance: no API key (set DEEPSEEK_API_KEY or │ ┃ /config) ⏺ Read(src/auth.ts) │ ┃ Today ≈$0.00 (estimated) ⎿ Read 6 lines │ ┃ Context ☁ cloudy 97.4k of 200k tokens ⏺ Update(src/auth.ts) │ ┃ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 49% used ⎿ Added 2 lines, removed 1 line │ ┃ Where it went today ⏺ Bash(bun test) │ ┃ no model calls tracked yet today ⎿ 3 pass, 1 fail │ ┃ Updated 08:53 [ Refresh ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /deepseek │ ⎿ deepseek-balance: DeepSeek balance pane opened. │ │ ◆ DeepSeek │ ☁ 97.4k 49% │ no API key (set DEEPSEEK_API_KEY or /config) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ DeepSeek │ ☁ 97.4k 49% │ no API key (set DEEPSEEK_API_KEY or /config)
Pane · DeepSeek balance
Balance: no API key (set DEEPSEEK_API_KEY or /config) Today ≈$0.00 (estimated) Context ☁ cloudy 97.4k of 200k tokens ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 49% used Where it went today no model calls tracked yet today Updated 08:53 [ Refresh ]
README

deepseek-balance

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:

PartWhat 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 leftReal 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 todayToday'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 leftThis 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.
  • A pop-up notice, at most once a day, when the balance drops under 10% or the key passes its limit.

Requirements

  • Claude Code 2.1.301 or newer (mods with hooks modules). Check with claude --version.
  • Claude Code pointed at DeepSeek's Anthropic-compatible API. For example, in ~/.claude/settings.json:
  {
    "env": {
      "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
      "ANTHROPIC_AUTH_TOKEN": "sk-...your DeepSeek key...",
      "ANTHROPIC_MODEL": "deepseek-chat"
    }
  }

Install

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.

Set up

The mod needs a DeepSeek key to read the balance. It uses the first one it finds:

  1. The DeepSeek API key setting of the mod.
  2. The DEEPSEEK_API_KEY environment variable.
  3. Claude Code's own 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.

SettingDefaultWhat it does
DeepSeek API keyemptyThe key to read the balance with. Stored in secure storage.
This key's spend limit0 (none)Limit for this key, in the balance currency.
Key limit perioddayWhat the limit counts: day, month or total.
Gauge full at0 (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.
CurrencyautoUSD or CNY when the account holds both.
Refresh every (seconds)120How often the balance is read (at least 30). It is also read after each turn.
DeepSeek API basehttps://api.deepseek.comWhere /user/balance is read from.

Update and uninstall

claude plugin update deepseek-balance
claude plugin uninstall deepseek-balance

Then run /reload-plugins in an open session, or start a new one.

How the numbers are worked out

  • Balance is DeepSeek's own figure. Everything else is computed by the mod on your machine.
  • Today starts at the first balance reading of the day (local time). Spend from before that first reading (for example, another app using the account before you open Claude Code) isn't counted.
  • Model and key costs are estimates: input tokens (cache hit and miss) and output tokens of each request this Claude Code sends, at DeepSeek's V3.2 list prices ($0.028 / $0.28 / $0.42 per million tokens, or ¥0.2 / ¥2 / ¥3). If DeepSeek changes its prices, update PRICES in hooks/ledger.ts. The balance and today's spend do not depend on them.
  • Context uses the figures Claude Code's own status line uses: the last reply's input tokens against the model's window.

Privacy

  • The only network request is GET <API base>/user/balance, sent with your DeepSeek key.
  • The key itself is never stored by the mod. It keeps a short hash and the last 4 characters (sk-…ab12) to tell keys apart.
  • Today's figures, per-model costs and per-key spend are kept in the mod's local storage on that machine.

Troubleshooting

You seeDo 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 unreachableNo network to api.deepseek.com from this machine, or a proxy blocks it.
No band at allCheck claude --version (2.1.301+), that the mod is enabled in /plugin, then /reload-plugins.
The context share looks lowClaude Code may assume a larger window than DeepSeek's. Set Context window to 128000.

Develop

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
FileWhat it holds
hooks/register.tsxThe hooks: reading the balance, tracking usage, the band, the pane, /deepseek.
hooks/ledger.tsBalance, today's spend, per-model and per-key costs, prices, the summary line. Pure functions.
hooks/context.tsThe context weather: sky, per-prompt bars, trend. Pure functions.
types/index.d.tsThe 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:

  • Keep the math in ledger.ts and context.ts as pure functions, and test it in tests/ledger.test.ts.
  • Mock every outside call in tests (http.fetch, session.usage, the store, the clock). Tests never call DeepSeek.
  • Never send a key anywhere but the configured DeepSeek API base, and never store a key in the mod's storage.
  • Run claude plugin validate . and claude plugin test . before each commit.
  • Bump version in .claude-plugin/plugin.json for each release, so claude plugin update picks it up.

Credits

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.

Source 4 files
hooks/register.tsx 505 lines
1import { 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}
505
hooks/ledger.ts 273 lines
1import 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}
273
hooks/context.ts 83 lines
1import 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}
83
types/index.d.ts 54 lines
1export 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