SLOPSHOPPER

deepseek-balance

The DeepSeek account balance as one line above the prompt, refreshed every five minutes and by /balance.

newbandcommandnetworktimer
v0.1.0no licenseupdated 2026-10-15zhuobichen/zhaowen-claudemod/deepseek-balance
A shopper browsing a rack in a slop shop
README

deepseek-balance

DeepSeek 账户余额 + 最近五个整点每小时花了多少,钉在提示行上面一行;每 5 分钟自己刷一次,/balance 手动再读一次。

DeepSeek ¥14.70 · 1m ago · 5h ? ? 0.34 1.20 0.00

那五个数是怎么来的(以及为什么会是 ?)

DeepSeek 官方只有 /user/balance 一个公开接口,没有"按小时/按天用量"接口(社区从 2026-06 起一直在要)。网页控制台上那些数是私有的 platform.deepseek.com/api/v0/usage/...,要登录态的 userToken、不是 API key —— 本 mod 不去碰它。

所以小时花费是自己攒的:每次读到余额就存一条 {时间, 金额} 进 mod 的 store(跨会话保留),某小时的花费 = 该小时起点那一刻的余额 − 终点那一刻的余额。

由此而来的三条硬限制,都体现在显示上:

  • 没观测到的小时显示 ?,不是 0。 只有在该小时的起点/终点前后 15 分钟内有过读数,那一个小时才算知道。没开会话、或断得久了,就是 ?。
  • 小时边界对齐在整点(本地时间),当前这个没走完的小时不算 —— 半截的小时会读起来像个便宜的小时。
  • 余额上升(充值)显示成 +15.00,不会伪装成负的花费。

精度:读数每 5 分钟一条,所以每个小时的数在边界处有最多约 5 分钟的归属误差。想要更细就把 REFRESH_MS 调小(代价是请求变多)。

安装

/plugin install deepseek-balance --marketplace zhuobichen/zhaowen-claudemod

装的是目录(不是快照),改完源码下一个会话生效;当前会话里可以敲 /reload-plugins 立刻生效,不用重装。

本机自己那份是从这个仓库的本地克隆装的(marketplace 指向仓库目录),所以改仓库文件就等于改自己在跑的那份。

为什么要单做一个

Claude Code 的 session.usage() 报的是订阅套餐的 5 小时/7 天窗口。API-key 会话(尤其是接到 DeepSeek 这种第三方端点的)报不出来,所以那类 meter 在这里只会显示 no plan limits reported yet。这个 mod 不问 Claude,直接问 DeepSeek 自己的接口。

key 从哪来

按顺序找两个环境变量,只用不存、不打印:

  1. ANTHROPIC_AUTH_TOKEN —— 本会话实际计费的那把(settings.json 的 env 里配的)
  2. DEEPSEEK_API_KEY —— 兜底

两个都没有时,那一行会写清楚它找过哪两个名字,而不是空着。

它不骗你的地方

  • 一次刷新失败不会把已经读到的好数字擦掉:数字和它的读取时间一起留着,后面标 · 12m ago · refresh failed: HTTP 500。旧的数就显示成旧的,不会假装是刚读的。
  • 返回体不是 JSON、账户报 is_available: false、HTTP 非 200 —— 各说各的原因,不会读成 0。
  • 汇率符号只认 CNY/USD;其它币种原样带出代码(1200 JPY),不猜符号。
  • 接在别的端点的会话里它闭嘴,且一个请求都不发(看 ANTHROPIC_BASE_URL 里有没有 deepseek),不会在 Anthropic 的会话里刷一行没用的字。

和别的 meter 共存

它挂在 ui.render{component:'AbovePrompt'},和 Context View 是同一个位置。所以它 await next(e) 把下层画的树包住再画自己那一行——直接返回自己的树会把别人那行吞掉。以后往这个位置再加 mod,同样要包一层。

文件

.claude-plugin/plugin.json   名字、版本、类型契约的位置
hooks/hooks.json             模块清单
hooks/register.tsx           取数、定时、/balance、提示行那一行
hooks/balance.ts             解析与措辞,纯函数,没有引擎调用(测试盯的就是它)
types/index.d.ts             $.state 里那个 reading 的类型契约
tests/balance.test.tsx       13 条:怎么读答案、怎么措辞、挂上去真的画出来了、别的会话里是否沉默

(marketplace 清单在仓库根 .claude-plugin/marketplace.json,不在这个目录里。)

改

  • 刷新间隔:hooks/balance.ts 的 REFRESH_MS
  • 想换端点:BALANCE_URL
  • 改完 claude plugin validate <本目录> + claude plugin test <本目录>
Source 3 files
hooks/register.tsx 210 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import {
5  BALANCE_URL,
6  hourlyOf,
7  INITIAL,
8  KEY_VARS,
9  lineOf,
10  readingOf,
11  REFRESH_MS,
12  samplesOf,
13  withSample,
14} from './balance'
15import type { Reading } from '../types'
16
17// deepseek-balance: the account's balance as one line above the prompt, read
18// from DeepSeek's own endpoint every five minutes and by `/balance`, with the
19// spending of the last five whole hours beside it.
20//
21// DeepSeek publishes no usage endpoint — only the balance — so each reading is
22// kept in the plugin's store and the hours are the differences between them.
23// An hour the meter did not watch is shown as `?`, never as a zero it cannot
24// prove.
25//
26// Claude Code's `session.usage()` carries the plan windows of a subscription;
27// an API-key session pointed at DeepSeek reports none of them, which is why
28// this meter asks DeepSeek instead. The key is the session's own: it is the
29// Anthropic-compatible one Claude Code was configured with, read at call time
30// and never kept, logged or drawn.
31
32const reading = atom({ plugin: 'deepseek-balance', key: 'reading' } as const, INITIAL)
33
34const SAMPLES_KEY = 'samples'
35
36/** Runs `work`, and answers null when it throws. */
37const quietly = async <T,>(work: () => Promise<T>): Promise<T | null> => {
38  try {
39    return await work()
40  } catch {
41    return null
42  }
43}
44
45/**
46 * Whether this session is the one the meter speaks for.
47 *
48 * A session pointed at another endpoint has no DeepSeek balance to show, and
49 * saying "no key found" there would be noise about a thing that was never
50 * meant to work: it stays quiet instead, and makes no request.
51 */
52const speaksForSession = async ($: EngineInterface): Promise<boolean> => {
53  const baseUrl = await quietly(() => $.env.get('ANTHROPIC_BASE_URL'))
54
55  return /deepseek/i.test(baseUrl ?? '')
56}
57
58/**
59 * The key, or null when none is set.
60 *
61 * The two names are spelled out rather than looped over: `$.env.get` takes a
62 * literal at the call site, so that a module's reads can be listed. `KEY_VARS`
63 * is the same list, for the wording.
64 */
65const keyOf = async ($: EngineInterface): Promise<string | null> => {
66  const session = await quietly(() => $.env.get('ANTHROPIC_AUTH_TOKEN'))
67
68  if (session !== null && session !== undefined && session !== '') return session
69
70  const dedicated = await quietly(() => $.env.get('DEEPSEEK_API_KEY'))
71
72  return dedicated === null || dedicated === undefined || dedicated === '' ? null : dedicated
73}
74
75let isSampling = false
76
77/**
78 * Reads the balance, keeps the reading and the sample behind it. One sample at
79 * a time: the timer and a render can both ask, and the second ask rides the
80 * first.
81 */
82async function sample($: EngineInterface): Promise<void> {
83  if (isSampling) return
84  if (!(await speaksForSession($))) return
85
86  isSampling = true
87
88  try {
89    const nowMs = (await quietly(() => $.clock.now())) ?? 0
90    const previous = await quietly(() => read($, reading))
91
92    const key = await keyOf($)
93
94    if (key === null) {
95      await update($, reading, () => ({
96        ...INITIAL,
97        kind: 'no-key',
98        atMs: nowMs,
99        note: `no key in ${KEY_VARS.join(' or ')}`,
100      }))
101
102      return
103    }
104
105    const response = await quietly(() =>
106      $.http.fetch(BALANCE_URL, { headers: { Authorization: `Bearer ${key}` } }),
107    )
108
109    const next: Reading =
110      response === null
111        ? { ...INITIAL, kind: 'failed', atMs: nowMs, note: 'the request failed' }
112        : response.ok
113          ? readingOf(response.text, nowMs)
114          : { ...INITIAL, kind: 'failed', atMs: nowMs, note: `HTTP ${response.status}` }
115
116    if (next.kind === 'ok' && next.total !== null) {
117      const kept = samplesOf(await quietly(() => $.store.get(SAMPLES_KEY)))
118      const samples = withSample(kept, { atMs: next.atMs, total: next.total })
119
120      await quietly(() => $.store.set(SAMPLES_KEY, samples))
121      await update($, reading, () => ({ ...next, hours: hourlyOf(samples, next.atMs) }))
122
123      return
124    }
125
126    // A refresh that fails never erases a number that was good: it keeps the
127    // amount, the *time it was read* and the hours summed from the readings, so
128    // the age beside it says how old the figure is rather than passing it off as
129    // fresh.
130    await update($, reading, () =>
131      next.kind === 'failed' && previous?.kind === 'ok'
132        ? { ...next, amount: previous.amount, total: previous.total, atMs: previous.atMs, hours: previous.hours }
133        : next,
134    )
135  } finally {
136    isSampling = false
137  }
138}
139
140/** The line as it stands now. */
141const currentLineOf = async ($: EngineInterface, fallbackMs: number): Promise<string> =>
142  lineOf(await read($, reading), (await quietly(() => $.clock.now())) ?? fallbackMs)
143
144export const register: Register = on => {
145  let tick: Timer | null = null
146
147  on('session.start', async ($, e, next) => {
148    const result = await next(e)
149
150    if (!(await speaksForSession($))) return result
151
152    await quietly(() =>
153      $.command.register({
154        name: 'balance',
155        description: 'DeepSeek balance: read it again now and show what the last answer said (deepseek-balance)',
156      }),
157    )
158
159    await sample($)
160
161    if (tick === null) {
162      try {
163        tick = $.clock.every(REFRESH_MS, () => {
164          void sample($)
165        })
166      } catch {
167        // without the timer the line still refreshes on every mount and on /balance
168      }
169    }
170
171    return result
172  })
173
174  on('command.run', { command: 'balance' }, async ($, e, next) => {
175    await sample($)
176
177    return { text: await currentLineOf($, 0) }
178  })
179
180  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
181    // Nothing to say over a survey, and nothing in a session this meter does
182    // not speak for.
183    if (e.props.hasSurvey) return next(e)
184    if (!(await speaksForSession($))) return next(e)
185
186    const readingNow = await read($, reading)
187
188    if (readingNow.atMs === 0 && readingNow.kind === 'pending') {
189      void sample($)
190
191      return next(e)
192    }
193
194    const { Box, Text } = $.ui.resolve(e)
195
196    // Another meter draws at this same spot (the context window, a turn's
197    // counters). Returning a tree without `next(e)` would draw it instead of
198    // that row and swallow it, so the plugins beneath are asked for their tree
199    // and it is kept, with this line under it.
200    const inner = await next(e)
201
202    return (
203      <Box flexDirection="column">
204        {inner}
205        <Text dimColor>{lineOf(readingNow, (await quietly(() => $.clock.now())) ?? readingNow.atMs)}</Text>
206      </Box>
207    )
208  })
209}
210
hooks/balance.ts 185 lines
1// The numbers and the wording, with no engine calls in them, so a test can
2// hold them to account without a session.
3//
4// Nothing here invents a figure: a reading the meter could not take comes back
5// as the reason it could not, and an hour the meter did not watch is `?` —
6// never a zero it cannot prove.
7
8import type { Reading, Sample } from '../types'
9
10/** DeepSeek's own balance endpoint, the one its console reads. */
11export const BALANCE_URL = 'https://api.deepseek.com/user/balance'
12
13/**
14 * Where the key is looked for, in order. The session's own key is the one this
15 * bills, so it comes first: a session pointed at DeepSeek keeps it in
16 * `ANTHROPIC_AUTH_TOKEN` (Claude Code's variable, set in settings `env`), and
17 * `DEEPSEEK_API_KEY` is the fallback for when only that one is set.
18 */
19export const KEY_VARS = ['ANTHROPIC_AUTH_TOKEN', 'DEEPSEEK_API_KEY'] as const
20
21/** How long between refreshes: the balance moves slowly, and each one is a request. */
22export const REFRESH_MS = 5 * 60 * 1000
23
24/** How many hours the strip shows, and how long the readings behind it are kept. */
25export const HOURS_SHOWN = 5
26export const HOUR_MS = 60 * 60 * 1000
27const KEEP_MS = 26 * HOUR_MS
28const KEEP_COUNT = 400
29
30/**
31 * How far a reading may sit from an hour's edge and still stand for the balance
32 * there. Five-minute readings land inside this; a reading from the far side of
33 * an unwatched stretch does not, which leaves that hour unknown rather than
34 * charging it with someone else's spending.
35 */
36export const BOUNDARY_MS = 15 * 60 * 1000
37
38export const INITIAL: Reading = { kind: 'pending', amount: null, total: null, atMs: 0, note: null, hours: [] }
39
40/** What DeepSeek answers, to the extent this meter reads it. */
41type Payload = {
42  is_available?: boolean
43  balance_infos?: { currency?: string; total_balance?: string }[]
44}
45
46/** The amount with its currency, as DeepSeek spells it; an unknown code keeps the code. */
47export const amountOf = (currency: string | undefined, total: string): string => {
48  if (currency === 'CNY') return `¥${total}`
49  if (currency === 'USD') return `$${total}`
50
51  return currency === undefined || currency === '' ? total : `${total} ${currency}`
52}
53
54/** What one answer comes to, or why it is not a balance. */
55export const readingOf = (body: string, atMs: number): Reading => {
56  let payload: Payload
57
58  try {
59    payload = JSON.parse(body) as Payload
60  } catch {
61    return { ...INITIAL, kind: 'failed', atMs, note: 'the answer was not JSON' }
62  }
63
64  if (payload.is_available === false) {
65    return { ...INITIAL, kind: 'refused', atMs, note: 'the account reports no available balance' }
66  }
67
68  const info = payload.balance_infos?.[0]
69  const total = Number(info?.total_balance)
70
71  if (info?.total_balance === undefined || !Number.isFinite(total)) {
72    return { ...INITIAL, kind: 'failed', atMs, note: 'the answer named no balance' }
73  }
74
75  return {
76    kind: 'ok',
77    amount: amountOf(info.currency, info.total_balance),
78    total,
79    atMs,
80    note: null,
81    hours: [],
82  }
83}
84
85/** The readings a store holds, as far as they are readings. */
86export const samplesOf = (raw: unknown): Sample[] => {
87  if (!Array.isArray(raw)) return []
88
89  return raw
90    .filter(
91      (one): one is Sample =>
92        typeof one === 'object' &&
93        one !== null &&
94        Number.isFinite((one as Sample).atMs) &&
95        Number.isFinite((one as Sample).total),
96    )
97    .sort((a, b) => a.atMs - b.atMs)
98}
99
100/** The readings with this one added, oldest dropped once they are a day old. */
101export const withSample = (samples: Sample[], sample: Sample): Sample[] => {
102  const list = [...samples.filter(one => one.atMs !== sample.atMs), sample].sort((a, b) => a.atMs - b.atMs)
103  const since = sample.atMs - KEEP_MS
104
105  return list.filter(one => one.atMs >= since).slice(-KEEP_COUNT)
106}
107
108/**
109 * The spending of each of the last `count` whole hours, oldest first: the
110 * balance at an hour's start less the balance at its end. `null` where a
111 * reading was too far from an edge to speak for it, and a negative number when
112 * the balance rose (a top-up).
113 *
114 * The hour in progress is left out: a part of an hour would read as a cheap hour.
115 */
116export const hourlyOf = (samples: Sample[], nowMs: number, count = HOURS_SHOWN): (number | null)[] => {
117  const edge = Math.floor(nowMs / HOUR_MS) * HOUR_MS
118  const at = (ms: number): Sample | null => {
119    for (let i = samples.length - 1; i >= 0; i -= 1) {
120      const one = samples[i]
121
122      if (one.atMs <= ms) return ms - one.atMs <= BOUNDARY_MS ? one : null
123    }
124
125    return null
126  }
127
128  const hours: (number | null)[] = []
129
130  for (let back = count; back >= 1; back -= 1) {
131    const start = at(edge - back * HOUR_MS)
132    const end = at(edge - (back - 1) * HOUR_MS)
133
134    hours.push(start === null || end === null ? null : Math.round((start.total - end.total) * 100) / 100)
135  }
136
137  return hours
138}
139
140/** The strip: `0.34` spent, `?` unwatched, `+3.00` when the balance rose. */
141export const hourlyTextOf = (hours: (number | null)[]): string => {
142  if (hours.length === 0) return ''
143
144  const cells = hours.map(one => {
145    if (one === null) return '?'
146    if (one < 0) return `+${Math.abs(one).toFixed(2)}`
147
148    return one.toFixed(2)
149  })
150
151  return `· ${hours.length}h ${cells.join(' ')}`
152}
153
154/** How long ago a reading was taken, in the shortest form that still reads as English. */
155export const ageOf = (atMs: number, nowMs: number): string => {
156  const minutes = Math.round((nowMs - atMs) / 60_000)
157
158  if (minutes < 1) return 'just now'
159  if (minutes < 60) return `${minutes}m ago`
160
161  const hours = Math.round(minutes / 60)
162
163  return hours < 48 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`
164}
165
166/** The one line above the prompt. */
167export const lineOf = (reading: Reading, nowMs: number): string => {
168  const strip = hourlyTextOf(reading.hours)
169  const tail = strip === '' ? '' : ` ${strip}`
170
171  switch (reading.kind) {
172    case 'pending':
173      return 'DeepSeek balance: reading…'
174    case 'no-key':
175    case 'refused':
176      return `DeepSeek balance: ${reading.note ?? 'not available'}`
177    case 'failed':
178      return reading.amount === null
179        ? `DeepSeek balance: ${reading.note ?? 'the request failed'}`
180        : `DeepSeek ${reading.amount} · ${ageOf(reading.atMs, nowMs)} · refresh failed: ${reading.note ?? 'unknown'}${tail}`
181    case 'ok':
182      return `DeepSeek ${reading.amount} · ${ageOf(reading.atMs, nowMs)}${tail}`
183  }
184}
185
types/index.d.ts 41 lines
1/**
2 * What the meter last made of DeepSeek's answer: the number when there is one,
3 * and otherwise the reason there is not.
4 *
5 * `amount` and `atMs` always travel together, and `atMs` is the time the
6 * reading was taken, never the time of a later failure. A failed refresh keeps
7 * the last good amount and its own time, so the age beside it stays honest
8 * about how old the number is.
9 */
10export type Reading = {
11  /** `pending` until the first answer; `ok` when a number came back. */
12  kind: 'pending' | 'ok' | 'no-key' | 'refused' | 'failed'
13  /** The amount as DeepSeek spelled it, with its currency, or null. */
14  amount: string | null
15  /** The same balance as a number, for the hourly strip; null when there is none. */
16  total: number | null
17  /** When the reading was taken, in milliseconds since the epoch; 0 before the first one. */
18  atMs: number
19  /** One phrase saying why there is no number, or what went wrong on a refresh. */
20  note: string | null
21  /** Spending of each of the last whole hours, oldest first; null for an hour not watched. */
22  hours: (number | null)[]
23}
24
25/**
26 * One balance reading, kept in the plugin's store so the strip can be summed
27 * across sessions: the meter only knows what it watched.
28 */
29export type Sample = {
30  /** When the reading was taken, in milliseconds since the epoch. */
31  atMs: number
32  /** The balance then, as a number. */
33  total: number
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    'deepseek-balance': { reading: Reading }
39  }
40}
41