The DeepSeek account balance as one line above the prompt, refreshed every five minutes and by /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 自己的接口。
按顺序找两个环境变量,只用不存、不打印:
ANTHROPIC_AUTH_TOKEN —— 本会话实际计费的那把(settings.json 的 env 里配的)DEEPSEEK_API_KEY —— 兜底两个都没有时,那一行会写清楚它找过哪两个名字,而不是空着。
· 12m ago · refresh failed: HTTP 500。旧的数就显示成旧的,不会假装是刚读的。is_available: false、HTTP 非 200 —— 各说各的原因,不会读成 0。1200 JPY),不猜符号。ANTHROPIC_BASE_URL 里有没有 deepseek),不会在 Anthropic 的会话里刷一行没用的字。它挂在 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_MSBALANCE_URLclaude plugin validate <本目录> + claude plugin test <本目录>hooks/register.tsx 210 lines1import { 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}
210hooks/balance.ts 185 lines1// 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}
185types/index.d.ts 41 lines1/**
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