@ruoyang/dsh-plugin-deepseek-balance 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ruoyang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # @ruoyang/dsh-plugin-deepseek-balance
2
+
3
+ A floating badge in the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI
4
+ showing the remaining balance of the `DEEPSEEK_API_KEY` credential the harness itself uses for model
5
+ calls.
6
+
7
+ > 中文说明见下方 [中文](#中文)。
8
+
9
+ ## What it does
10
+
11
+ | Part | Behaviour |
12
+ | --- | --- |
13
+ | Status dot | green = balance available, amber = account cannot call the API, grey = querying, red = query failed |
14
+ | Badge text | total balance in the primary currency, e.g. `¥4.94` |
15
+ | Hover panel | per-currency totals, granted vs topped-up split, whether the account is callable, and the query time |
16
+ | Interaction | click to refresh, drag to reposition, automatic refresh every 60 seconds |
17
+
18
+ The badge itself is a pill pinned to the lower-right corner; drag it anywhere and it stays there for
19
+ the session.
20
+
21
+ ## Requirements
22
+
23
+ - A DeepSeek Harness installation with the **Web** surface (`dsh web`).
24
+ - `DEEPSEEK_API_KEY` configured — via the launching environment, a `.env` file, or
25
+ **Settings → Models**. The plugin resolves it per query through the credentials seam, so a key you
26
+ rotate takes effect on the next refresh with no restart.
27
+ - Node.js **>= 18.17** on the host (the host half uses global `fetch` and `AbortSignal.timeout`).
28
+
29
+ The package deliberately declares **no `peerDependencies`** on `@deepseek-ai/dsh-*`, so it installs
30
+ across harness versions instead of being pinned to one. It also has **no runtime dependencies** and
31
+ no install scripts.
32
+
33
+ ## Install
34
+
35
+ Using the `dsh` CLI:
36
+
37
+ ```sh
38
+ dsh plugin --profile web add @ruoyang/dsh-plugin-deepseek-balance
39
+ ```
40
+
41
+ In the Web UI: **Settings → Plugins**, then install by the package name above.
42
+
43
+ Through an agent session, the equivalent is the `plugin_manager` tool with
44
+ `action: install_bundle` and that package name as `target`.
45
+
46
+ A new bundle activates immediately; if the badge does not appear, reload the page once so the
47
+ browser picks up the new client module.
48
+
49
+ ## Uninstall
50
+
51
+ ```sh
52
+ dsh plugin --profile web remove @ruoyang/dsh-plugin-deepseek-balance
53
+ ```
54
+
55
+ ## How it works
56
+
57
+ One bundle, two halves, mounted by a single Loader row:
58
+
59
+ | File | Role |
60
+ | --- | --- |
61
+ | `cordis.patch.yml` | inserts the row `deepseek-balance` |
62
+ | `index.js` | Host half: serves `GET /deepseek-balance/api` |
63
+ | `client.js` | Client half: a lazy module-system factory that registers into the `shell.overlay` slot |
64
+
65
+ **Your key never reaches the browser.** The host half reads it through `ctx.credentials` and calls
66
+ `https://api.deepseek.com/user/balance` itself; the browser half only fetches the same-origin JSON
67
+ snapshot the host produces. Failures come back as `{ "ok": false, "code": …, "message": … }` with the
68
+ error's `cause` chain flattened into `message`, so a transport problem is diagnosable without logs.
69
+
70
+ The plugin declares no settings and no `Config` schema — there is nothing to configure.
71
+
72
+ ## Troubleshooting
73
+
74
+ ```sh
75
+ curl http://127.0.0.1:3080/deepseek-balance/api # the JSON snapshot the badge renders
76
+ curl "http://127.0.0.1:3080/deepseek-balance/api?force=1" # bypass the host half's 20s cache
77
+ ```
78
+
79
+ | Response | Meaning |
80
+ | --- | --- |
81
+ | `{"ok":true,…}` | working |
82
+ | `{"ok":false,"code":"NO_KEY"}` | no `DEEPSEEK_API_KEY` is configured |
83
+ | `{"ok":false,"code":"HTTP_401"}` | the key is rejected by DeepSeek |
84
+ | `{"ok":false,"code":"TRANSPORT"}` | the host could not reach `api.deepseek.com` |
85
+
86
+ A 404 on that path means the row did not mount — check that the bundle is enabled in
87
+ **Settings → Plugins**.
88
+
89
+ ## Known limitations
90
+
91
+ - Visible badge text is currently Chinese only; it does not yet go through the Client locale service,
92
+ so an English UI still shows Chinese labels in the hover panel.
93
+ - The badge position resets on page reload; it is not persisted.
94
+
95
+ ## 中文
96
+
97
+ 在 DeepSeek Harness 的 Web 界面右下角悬浮显示**当前 `DEEPSEEK_API_KEY` 的剩余余额**。
98
+
99
+ **它做什么**:状态点(绿=有余额 / 黄=账号不可调用 / 灰=查询中 / 红=查询失败)、胶囊显示主币种
100
+ 总余额、悬停展开各币种总余额与赠金/充值拆分、点击刷新、按住拖动、每 60 秒自动刷新。
101
+
102
+ **要求**:带 Web 界面的 Harness(`dsh web`);已配置 `DEEPSEEK_API_KEY`(环境变量、`.env` 或
103
+ 设置里的 Models 页均可,插件每次查询都重新解析,换密钥不用重启);宿主机 Node ≥ 18.17。
104
+
105
+ **安装**:
106
+
107
+ ```sh
108
+ dsh plugin --profile web add @ruoyang/dsh-plugin-deepseek-balance
109
+ ```
110
+
111
+ 或在 Web 界面 **设置 → 插件** 里按包名安装。装好后如果徽标没出现,刷新一次页面。
112
+
113
+ **卸载**:`dsh plugin --profile web remove @ruoyang/dsh-plugin-deepseek-balance`
114
+
115
+ **密钥安全**:密钥全程留在宿主机侧,由宿主半经 `ctx.credentials` 读出后直接请求
116
+ `api.deepseek.com`;浏览器半只读同源的 JSON 快照,**密钥不会进入浏览器**。
117
+
118
+ **排查**:
119
+
120
+ ```sh
121
+ curl http://127.0.0.1:3080/deepseek-balance/api # 徽标渲染用的 JSON
122
+ curl "http://127.0.0.1:3080/deepseek-balance/api?force=1" # 绕过宿主半 20 秒缓存
123
+ ```
124
+
125
+ 返回 `NO_KEY` = 没配密钥,`HTTP_401` = 密钥被拒,`TRANSPORT` = 宿主机连不上
126
+ `api.deepseek.com`;404 = 行没挂上,去 **设置 → 插件** 看是否启用。
127
+
128
+ **已知限制**:徽标内的可见文字目前是硬编码中文,还没接 Client locale 服务,所以英文界面下悬停
129
+ 面板仍是中文;徽标位置刷新页面后重置。
130
+
131
+ ## Releasing (maintainer)
132
+
133
+ The default registry on a machine set up for fast Chinese installs is
134
+ `registry.npmmirror.com` — a **read-only mirror**. It serves installs but accepts no publishes, so a
135
+ bare `npm publish` fails with `ENEEDAUTH` against it. Pass the official registry explicitly:
136
+
137
+ ```sh
138
+ cd dsh-plugins/deepseek-balance
139
+ npm login --registry=https://registry.npmjs.org/
140
+ npm whoami --registry=https://registry.npmjs.org/ # must print the owner of the @ruoyang scope
141
+ npm publish --registry=https://registry.npmjs.org/
142
+ ```
143
+
144
+ Why the flag and not just `.npmrc`: npm resolves config as
145
+ `cli > env > project > user > global`, and a shell that inherited `npm_config_registry` (npx exports
146
+ it, so any shell started by an npx-launched `dsh` carries it) overrides every file. `npm config ls`
147
+ shows it as `registry = "…" ; overridden by env`. The project `.npmrc` here is a convenience for
148
+ clean shells; the flag is what always works.
149
+
150
+ A scoped package can only be published by the account that owns the scope, so `npm whoami` must
151
+ match `@ruoyang` — either that is your npm username, or `ruoyang` is an org you belong to.
152
+
153
+ For a new release, bump `version` in `package.json` first: npm refuses to republish an existing
154
+ version, and a re-`npm pack` at the same version silently overwrites the local tarball while the
155
+ already-installed copy keeps the old bytes.
156
+
157
+ ## License
158
+
159
+ [MIT](./LICENSE)
package/client.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Browser half of the DeepSeek balance badge.
3
+ *
4
+ * One occupant of the frame-wide `shell.overlay` slot (`@deepseek-ai/dsh-client-ui-layout`
5
+ * declares it): a draggable pill showing the remaining balance of the credential
6
+ * the harness itself uses for model calls. The host half owns the credential and
7
+ * the upstream request; this half only reads the JSON snapshot it serves.
8
+ *
9
+ * Hand-written in the client module system's lazy-CJS factory format, so no
10
+ * build step is involved. `id` must equal the package name. React comes from the
11
+ * browser module table via `require`.
12
+ *
13
+ * The factory itself stays side-effect free: the stylesheet, the interval and the
14
+ * slot registration are all created inside `apply` and owned by that fiber.
15
+ */
16
+ window.__ModuleLoader__.load({
17
+ id: '@ruoyang/dsh-plugin-deepseek-balance',
18
+ factory(require) {
19
+ const React = require('react')
20
+
21
+ /** Snapshot route served by this package's host half. */
22
+ const API_PATH = '/deepseek-balance/api'
23
+ /** Auto-refresh cadence, matching the host half's own cache window. */
24
+ const REFRESH_MS = 60000
25
+ /** Display symbol per reported currency; anything else is shown with its code. */
26
+ const SYMBOLS = { CNY: '¥', USD: '$' }
27
+ /** Id of the one style node this fiber owns. */
28
+ const STYLE_ID = 'dsh-balance-styles'
29
+
30
+ const CSS = [
31
+ '.dsh-balance-root{position:fixed;z-index:60;pointer-events:auto;user-select:none;',
32
+ 'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;',
33
+ 'font-size:12px;line-height:1.45;}',
34
+ '.dsh-balance-pill{display:flex;align-items:center;gap:6px;padding:6px 11px;border-radius:999px;',
35
+ 'background:var(--dsw-alias-bg-overlay,rgba(24,24,27,.94));',
36
+ 'color:var(--dsw-alias-label-primary,#fafafa);',
37
+ 'border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));',
38
+ 'box-shadow:0 6px 20px rgba(0,0,0,.28);cursor:grab;touch-action:none;backdrop-filter:blur(8px);}',
39
+ '.dsh-balance-pill:active{cursor:grabbing;}',
40
+ '.dsh-balance-dot{width:7px;height:7px;border-radius:50%;flex:none;',
41
+ 'background:var(--dsw-alias-label-secondary,#a1a1aa);}',
42
+ '.dsh-balance-tone-ok .dsh-balance-dot{background:var(--dsw-alias-state-success-primary,#22c55e);}',
43
+ '.dsh-balance-tone-warn .dsh-balance-dot{background:var(--dsw-alias-state-warn-primary,#f59e0b);}',
44
+ '.dsh-balance-tone-error .dsh-balance-dot{background:var(--dsw-alias-state-error-primary,#ef4444);}',
45
+ '.dsh-balance-label{font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:.01em;white-space:nowrap;}',
46
+ '.dsh-balance-spin{opacity:.65;animation:dsh-balance-spin .9s linear infinite;}',
47
+ '@keyframes dsh-balance-spin{to{transform:rotate(360deg)}}',
48
+ '.dsh-balance-panel{display:none;margin-bottom:8px;padding:10px 12px;min-width:190px;border-radius:12px;',
49
+ 'background:var(--dsw-alias-bg-overlay,rgba(24,24,27,.96));',
50
+ 'color:var(--dsw-alias-label-primary,#fafafa);',
51
+ 'border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.12));',
52
+ 'box-shadow:0 10px 30px rgba(0,0,0,.32);backdrop-filter:blur(10px);}',
53
+ '.dsh-balance-root:hover .dsh-balance-panel{display:block;}',
54
+ '.dsh-balance-row{display:flex;justify-content:space-between;gap:16px;padding:2px 0;}',
55
+ '.dsh-balance-key{color:var(--dsw-alias-label-secondary,#a1a1aa);white-space:pre;}',
56
+ '.dsh-balance-val{font-variant-numeric:tabular-nums;white-space:nowrap;}',
57
+ '.dsh-balance-err{color:var(--dsw-alias-state-error-primary,#ef4444);max-width:230px;',
58
+ 'white-space:normal;word-break:break-word;text-align:right;}',
59
+ '.dsh-balance-hint{margin-top:7px;padding-top:6px;font-size:11px;',
60
+ 'border-top:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.1));',
61
+ 'color:var(--dsw-alias-label-secondary,#a1a1aa);}',
62
+ ].join('')
63
+
64
+ /**
65
+ * Read one snapshot from this package's host half.
66
+ *
67
+ * The host half answers structured failures with HTTP 200, so a rejected
68
+ * promise here means the route itself was unreachable.
69
+ *
70
+ * @param force - bypass the host half's cache.
71
+ * @returns the snapshot payload.
72
+ */
73
+ function requestSnapshot(force) {
74
+ return fetch(API_PATH + (force === true ? '?force=1' : ''), { headers: { accept: 'application/json' } })
75
+ .then((response) => response.json())
76
+ }
77
+
78
+ /** Render a thrown value as one short line. */
79
+ function describe(error) {
80
+ return String(error !== null && typeof error === 'object' && 'message' in error ? error.message : error)
81
+ }
82
+
83
+ /** Display symbol for a reported currency. */
84
+ function symbolOf(currency) {
85
+ return SYMBOLS[currency] !== undefined ? SYMBOLS[currency] : currency + ' '
86
+ }
87
+
88
+ /** `HH:MM:SS` for a snapshot timestamp. */
89
+ function formatTime(ms) {
90
+ const date = new Date(ms)
91
+ const pad = (value) => (value < 10 ? '0' + String(value) : String(value))
92
+ return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
93
+ }
94
+
95
+ /** One key/value line of the hover panel. */
96
+ function row(key, value, extraClass) {
97
+ return React.createElement(
98
+ 'div',
99
+ { className: 'dsh-balance-row', key },
100
+ React.createElement('span', { className: 'dsh-balance-key' }, key),
101
+ React.createElement(
102
+ 'span',
103
+ { className: 'dsh-balance-val' + (extraClass === undefined ? '' : ' ' + extraClass) },
104
+ value,
105
+ ),
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Build the hover panel: one block per reported currency, then availability
111
+ * and freshness. The failure branch names the credential so a missing key is
112
+ * obvious without opening logs.
113
+ */
114
+ function panelRows(data, failure, phase) {
115
+ const rows = []
116
+ if (data !== null) {
117
+ if (data.infos.length === 0) {
118
+ rows.push(row('余额', '无数据'))
119
+ } else {
120
+ for (const info of data.infos) {
121
+ const symbol = symbolOf(info.currency)
122
+ rows.push(row(info.currency + ' 总余额', symbol + info.total))
123
+ rows.push(row(' 赠金 / 充值', symbol + info.granted + ' / ' + symbol + info.toppedUp))
124
+ }
125
+ }
126
+ rows.push(row('可调用', data.isAvailable === true ? '是' : '否'))
127
+ rows.push(row('更新于', formatTime(data.at)))
128
+ } else if (phase === 'loading') {
129
+ rows.push(row('状态', '查询中 …'))
130
+ rows.push(row('凭据', 'DEEPSEEK_API_KEY'))
131
+ } else {
132
+ rows.push(row('错误', failure === null ? '余额暂不可用' : failure, 'dsh-balance-err'))
133
+ rows.push(row('凭据', 'DEEPSEEK_API_KEY'))
134
+ }
135
+ rows.push(React.createElement('div', { className: 'dsh-balance-hint', key: 'hint' },
136
+ '点击刷新 · 按住拖动可移动位置'))
137
+ return rows
138
+ }
139
+
140
+ return {
141
+ inject: ['slots', 'timer'],
142
+ apply(ctx) {
143
+ ctx.effect(() => {
144
+ if (document.getElementById(STYLE_ID) === null) {
145
+ const node = document.createElement('style')
146
+ node.id = STYLE_ID
147
+ node.textContent = CSS
148
+ document.head.append(node)
149
+ }
150
+ return () => { document.getElementById(STYLE_ID)?.remove() }
151
+ }, 'deepseek-balance: styles')
152
+
153
+ // Defined inside apply so the component closes over this fiber's context
154
+ // and can own its own interval. One mount per client run keeps the
155
+ // component identity stable across re-renders.
156
+ function BalanceBadge() {
157
+ const [snapshot, setSnapshot] = React.useState({ phase: 'loading' })
158
+ const [busy, setBusy] = React.useState(false)
159
+ const [position, setPosition] = React.useState({ right: 22, bottom: 22 })
160
+ const drag = React.useRef(null)
161
+
162
+ React.useEffect(() => {
163
+ let alive = true
164
+ const pull = (force) => {
165
+ requestSnapshot(force).then(
166
+ (data) => { if (alive) setSnapshot({ phase: 'ready', data }) },
167
+ (error) => { if (alive) setSnapshot({ phase: 'error', message: describe(error) }) },
168
+ )
169
+ }
170
+ pull(false)
171
+ const stop = ctx.interval(() => { pull(true) }, REFRESH_MS)
172
+ return () => { alive = false; stop() }
173
+ }, [])
174
+
175
+ const refresh = () => {
176
+ setBusy(true)
177
+ requestSnapshot(true)
178
+ .then(
179
+ (data) => setSnapshot({ phase: 'ready', data }),
180
+ (error) => setSnapshot({ phase: 'error', message: describe(error) }),
181
+ )
182
+ .then(() => setBusy(false))
183
+ }
184
+
185
+ const onPointerDown = (event) => {
186
+ if (event.button !== 0) return
187
+ try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { /* still draggable */ }
188
+ drag.current = {
189
+ x: event.clientX,
190
+ y: event.clientY,
191
+ right: position.right,
192
+ bottom: position.bottom,
193
+ }
194
+ }
195
+
196
+ const onPointerMove = (event) => {
197
+ const started = drag.current
198
+ if (started === null) return
199
+ let right = started.right - (event.clientX - started.x)
200
+ let bottom = started.bottom - (event.clientY - started.y)
201
+ right = Math.max(4, Math.min(right, window.innerWidth - 60))
202
+ bottom = Math.max(4, Math.min(bottom, window.innerHeight - 32))
203
+ setPosition({ right, bottom })
204
+ }
205
+
206
+ const onPointerUp = (event) => {
207
+ const started = drag.current
208
+ drag.current = null
209
+ try { event.currentTarget.releasePointerCapture(event.pointerId) } catch (error) { /* nothing captured */ }
210
+ if (started === null) return
211
+ const moved = Math.abs(event.clientX - started.x) + Math.abs(event.clientY - started.y)
212
+ if (moved < 5) refresh()
213
+ }
214
+
215
+ const data = snapshot.phase === 'ready' && snapshot.data !== null && snapshot.data.ok === true
216
+ ? snapshot.data
217
+ : null
218
+ const failure = snapshot.phase === 'error'
219
+ ? snapshot.message
220
+ : (snapshot.phase === 'ready' && snapshot.data !== null && snapshot.data.ok !== true
221
+ ? snapshot.data.message
222
+ : null)
223
+
224
+ let tone = 'idle'
225
+ let label = '余额 …'
226
+ if (snapshot.phase === 'error' || failure !== null) {
227
+ tone = 'error'
228
+ label = '余额不可用'
229
+ } else if (data !== null) {
230
+ tone = data.isAvailable === true ? 'ok' : 'warn'
231
+ label = symbolOf(data.currency) + data.total
232
+ }
233
+
234
+ return React.createElement(
235
+ 'div',
236
+ {
237
+ className: 'dsh-balance-root dsh-balance-tone-' + tone,
238
+ style: { right: position.right + 'px', bottom: position.bottom + 'px' },
239
+ },
240
+ React.createElement('div', { className: 'dsh-balance-panel' }, panelRows(data, failure, snapshot.phase)),
241
+ React.createElement(
242
+ 'div',
243
+ {
244
+ className: 'dsh-balance-pill',
245
+ title: 'DeepSeek 余额 · 点击刷新 · 拖动移动',
246
+ onPointerDown,
247
+ onPointerMove,
248
+ onPointerUp,
249
+ onPointerCancel: onPointerUp,
250
+ },
251
+ React.createElement('span', { className: 'dsh-balance-dot' }),
252
+ React.createElement('span', { className: 'dsh-balance-label' }, label),
253
+ busy ? React.createElement('span', { className: 'dsh-balance-spin' }, '↻') : null,
254
+ ),
255
+ )
256
+ }
257
+
258
+ ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register(
259
+ { name: 'shell.overlay', id: 'deepseek-balance', order: 200 },
260
+ BalanceBadge,
261
+ )), 'deepseek-balance: overlay badge')
262
+ },
263
+ }
264
+ },
265
+ })
@@ -0,0 +1,9 @@
1
+ # deepseek-balance: a floating badge showing the remaining balance of the
2
+ # DEEPSEEK_API_KEY credential the harness itself uses for model calls.
3
+ #
4
+ # The Host half (index.js) serves GET /deepseek-balance/api; the Client half
5
+ # (client.js, declared through dsh.client) registers the badge into the
6
+ # frame-wide shell.overlay slot. Both halves belong to this one row.
7
+ - insert:
8
+ - id: deepseek-balance
9
+ name: '@ruoyang/dsh-plugin-deepseek-balance'
package/icon.svg ADDED
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="DeepSeek balance">
2
+ <circle cx="32" cy="32" r="27" fill="#4D6BFE"/>
3
+ <g fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
4
+ <path d="M22 19 L32 30 L42 19"/>
5
+ <path d="M32 30 L32 47"/>
6
+ <path d="M22 34 L42 34"/>
7
+ <path d="M22 41 L42 41"/>
8
+ </g>
9
+ </svg>
package/index.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * DeepSeek balance badge — host half.
3
+ *
4
+ * Serves one JSON route the package's browser half polls: the remaining balance
5
+ * of the credential the harness already uses for its own model calls
6
+ * (`DEEPSEEK_API_KEY`, resolved through the credentials seam on every query, so
7
+ * a rotated key reaches the next refresh with no restart).
8
+ *
9
+ * The browser half is a `dsh.client` bundle this package declares, so the client
10
+ * module system loads and serves it; this half owns only the data.
11
+ *
12
+ * @module @local/deepseek-balance
13
+ */
14
+
15
+ /** Credential reference resolved per query; the same key the model route uses. */
16
+ const KEY_REF = 'DEEPSEEK_API_KEY'
17
+ /** Official balance endpoint: GET /user/balance. */
18
+ const BALANCE_URL = 'https://api.deepseek.com/user/balance'
19
+ /** Route the badge polls. */
20
+ const API_PATH = '/deepseek-balance/api'
21
+ /** Serve one cached snapshot for this long before paying for another request. */
22
+ const CACHE_MS = 20000
23
+ /** Per-attempt upstream bound. */
24
+ const TIMEOUT_MS = 20000
25
+ /** Attempts per query: one retry covers the transient transport blips seen here. */
26
+ const ATTEMPTS = 2
27
+ /** Backoff between attempts, owned by the cordis timer so it dies with the fiber. */
28
+ const RETRY_DELAY_MS = 400
29
+
30
+ /** The host capabilities this plugin needs before it can serve anything. */
31
+ export const inject = ['webServer', 'timer']
32
+
33
+ /**
34
+ * Register the balance route.
35
+ *
36
+ * `credentials` is read optionally and degrades to a structured error the badge
37
+ * renders, never a stuck row.
38
+ *
39
+ * @param ctx - the plugin's Cordis context.
40
+ */
41
+ export function apply(ctx) {
42
+ let cached = null
43
+ let inflight = null
44
+
45
+ /**
46
+ * Resolve the API key through the credentials seam.
47
+ *
48
+ * Resolution is per call by design: it layers the launching environment, the
49
+ * provider-managed store and `.env` files, so a key saved or rotated at
50
+ * runtime is picked up by the next query.
51
+ *
52
+ * @returns the key, or undefined when nothing is configured.
53
+ */
54
+ async function resolveKey() {
55
+ const credentials = ctx.get('credentials')
56
+ if (credentials === undefined) return undefined
57
+ try {
58
+ const resolved = await credentials.resolve(KEY_REF)
59
+ if (resolved === undefined || resolved === null) return undefined
60
+ const value = resolved.value
61
+ return typeof value === 'string' && value.length > 0 ? value : undefined
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Flatten a thrown value and its `cause` chain into one line.
69
+ *
70
+ * Node's fetch reports a bare "fetch failed"; the actionable part (ECONNRESET,
71
+ * ENOTFOUND, a TLS error) only ever appears on `cause`, so a message that
72
+ * stops at the top level is undiagnosable.
73
+ *
74
+ * @param error - the thrown value.
75
+ * @returns a short single-line description.
76
+ */
77
+ function messageOf(error) {
78
+ const parts = []
79
+ let cursor = error
80
+ while (cursor !== null && cursor !== undefined && parts.length < 4) {
81
+ if (typeof cursor === 'object' && typeof cursor.message === 'string') {
82
+ parts.push(cursor.message + (typeof cursor.code === 'string' ? ' [' + cursor.code + ']' : ''))
83
+ cursor = cursor.cause
84
+ } else {
85
+ parts.push(String(cursor))
86
+ break
87
+ }
88
+ }
89
+ return (parts.length === 0 ? String(error) : parts.join(' <- ')).slice(0, 400)
90
+ }
91
+
92
+ /** Project one `balance_infos[]` entry onto the four scalars the badge shows. */
93
+ function readInfo(item) {
94
+ const text = (value) => (typeof value === 'string' ? value : String(value))
95
+ return {
96
+ currency: typeof item.currency === 'string' ? item.currency : '?',
97
+ total: text(item.total_balance),
98
+ granted: text(item.granted_balance),
99
+ toppedUp: text(item.topped_up_balance),
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Build the owned JSON snapshot from the upstream body.
105
+ *
106
+ * CNY is the primary display currency when present, which is what a DeepSeek
107
+ * Platform account is normally denominated in; USD accounts fall back to the
108
+ * first reported entry.
109
+ *
110
+ * @param data - parsed `/user/balance` body.
111
+ * @returns the badge payload.
112
+ */
113
+ function shape(data) {
114
+ const infos = []
115
+ if (Array.isArray(data.balance_infos)) {
116
+ for (const item of data.balance_infos) {
117
+ if (item === null || typeof item !== 'object') continue
118
+ infos.push(readInfo(item))
119
+ }
120
+ }
121
+ let primary = null
122
+ for (const info of infos) if (info.currency === 'CNY') primary = info
123
+ if (primary === null && infos.length > 0) primary = infos[0]
124
+ const base = primary === null
125
+ ? { currency: 'CNY', total: '0.00', granted: '0.00', toppedUp: '0.00' }
126
+ : primary
127
+ return {
128
+ ok: true,
129
+ isAvailable: data.is_available === true,
130
+ currency: base.currency,
131
+ total: base.total,
132
+ granted: base.granted,
133
+ toppedUp: base.toppedUp,
134
+ infos,
135
+ at: Date.now(),
136
+ }
137
+ }
138
+
139
+ /**
140
+ * One upstream attempt.
141
+ *
142
+ * Never throws: every outcome is a payload plus whether another attempt could
143
+ * plausibly help (a transport failure, or a 5xx).
144
+ *
145
+ * @param key - the resolved API key.
146
+ * @returns the payload and its retryability.
147
+ */
148
+ async function attempt(key) {
149
+ let response
150
+ try {
151
+ response = await fetch(BALANCE_URL, {
152
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
153
+ signal: AbortSignal.timeout(TIMEOUT_MS),
154
+ })
155
+ } catch (error) {
156
+ return { retryable: true, payload: { ok: false, code: 'TRANSPORT', message: messageOf(error), at: Date.now() } }
157
+ }
158
+
159
+ const body = await response.text()
160
+ if (response.status !== 200) {
161
+ return {
162
+ retryable: response.status >= 500,
163
+ payload: { ok: false, code: 'HTTP_' + String(response.status), message: body.slice(0, 300), at: Date.now() },
164
+ }
165
+ }
166
+ let data
167
+ try {
168
+ data = JSON.parse(body)
169
+ } catch {
170
+ return { retryable: false, payload: { ok: false, code: 'BAD_JSON', message: '余额响应不是合法 JSON', at: Date.now() } }
171
+ }
172
+ return { retryable: false, payload: shape(data) }
173
+ }
174
+
175
+ /**
176
+ * Query the official endpoint, retrying a retryable failure once.
177
+ *
178
+ * @returns the badge payload.
179
+ */
180
+ async function runQuery() {
181
+ const key = await resolveKey()
182
+ if (key === undefined) {
183
+ return { ok: false, code: 'NO_KEY', message: '未找到 ' + KEY_REF + ' 凭据,请先在 Models 页配置密钥', at: Date.now() }
184
+ }
185
+ let outcome = await attempt(key)
186
+ for (let done = 1; done < ATTEMPTS && outcome.retryable; done += 1) {
187
+ await ctx.timeout(RETRY_DELAY_MS)
188
+ outcome = await attempt(key)
189
+ }
190
+ return outcome.payload
191
+ }
192
+
193
+ /**
194
+ * Query with a short cache and single-flight collapse.
195
+ *
196
+ * Several open tabs polling at once, or one tab polling while a manual
197
+ * refresh is in flight, share one upstream request.
198
+ *
199
+ * @param force - bypass the cache (the badge's manual refresh).
200
+ * @returns the badge payload.
201
+ */
202
+ async function query(force) {
203
+ const now = Date.now()
204
+ if (force !== true && cached !== null && now - cached.at < CACHE_MS) return cached.value
205
+ if (inflight !== null) return inflight
206
+ const pending = (async () => {
207
+ try {
208
+ return await runQuery()
209
+ } catch (error) {
210
+ return { ok: false, code: 'UNEXPECTED', message: messageOf(error), at: Date.now() }
211
+ }
212
+ })()
213
+ inflight = pending
214
+ let value
215
+ try {
216
+ value = await pending
217
+ } finally {
218
+ if (inflight === pending) inflight = null
219
+ }
220
+ cached = { at: Date.now(), value }
221
+ return value
222
+ }
223
+
224
+ ctx.effect(() => ctx.webServer.register({
225
+ kind: 'exact',
226
+ path: API_PATH,
227
+ handler: async (req, res) => {
228
+ let force = false
229
+ try {
230
+ force = new URL(req.url ?? API_PATH, 'http://localhost').searchParams.get('force') === '1'
231
+ } catch {
232
+ // a malformed query string simply means "not forced"
233
+ }
234
+ let value
235
+ try {
236
+ value = await query(force)
237
+ } catch (error) {
238
+ value = { ok: false, code: 'UNEXPECTED', message: messageOf(error), at: Date.now() }
239
+ }
240
+ const body = JSON.stringify(value)
241
+ res.writeHead(200, {
242
+ 'content-type': 'application/json; charset=utf-8',
243
+ 'cache-control': 'no-store',
244
+ 'content-length': Buffer.byteLength(body),
245
+ })
246
+ res.end(body)
247
+ },
248
+ }), 'deepseek-balance: balance api')
249
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DeepSeek Balance Badge",
4
+ "description": "A floating badge showing the remaining balance of your DEEPSEEK_API_KEY, refreshed every minute."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DeepSeek 余额徽标",
4
+ "description": "悬浮显示当前 DEEPSEEK_API_KEY 的剩余余额,每分钟自动刷新。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@ruoyang/dsh-plugin-deepseek-balance",
3
+ "version": "1.0.0",
4
+ "description": "Floating DeepSeek API balance badge for the DeepSeek Harness Web UI, backed by the harness's own DEEPSEEK_API_KEY credential",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "keywords": [
11
+ "deepseek",
12
+ "dsh",
13
+ "dsh-plugin",
14
+ "harness",
15
+ "balance",
16
+ "cordis"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18.17.0"
20
+ },
21
+ "icon": "./icon.svg",
22
+ "exports": {
23
+ ".": "./index.js",
24
+ "./client": "./client.js",
25
+ "./package.json": "./package.json",
26
+ "./locale/*.json": "./locale/*.json"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "client.js",
31
+ "cordis.patch.yml",
32
+ "icon.svg",
33
+ "locale/*.json",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web",
43
+ "immediately": true,
44
+ "inject": [
45
+ "@deepseek-ai/dsh-client-ui-layout"
46
+ ]
47
+ }
48
+ }
49
+ }