deepseek-harness-wallet 0.1.2 → 0.1.3

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.
Files changed (4) hide show
  1. package/README.md +104 -96
  2. package/index.js +226 -122
  3. package/lib/client.js +550 -371
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,96 +1,104 @@
1
- # deepseek-harness-wallet
2
-
3
- ### DeepSeek Harness Balance Monitor & Recharge Plugin · 余额监控和充值插件
4
-
5
- <p align="center">
6
- <a href="./README.md">English</a> ·
7
- <a href="./docs/i18n/README.zh-CN.md">简体中文</a>
8
- </p>
9
-
10
- <p align="center">
11
- <img alt="Version 0.1.2" src="https://img.shields.io/badge/version-0.1.2-5965d8">
12
- <img alt="DeepSeek Harness rc.6" src="https://img.shields.io/badge/dsh-0.1.0--rc.6-4aa3ff">
13
- <img alt="MIT License" src="https://img.shields.io/badge/license-MIT-3b7a57">
14
- </p>
15
-
16
- **The multi-provider wallet chip for the DeepSeek Harness Web GUI.**
17
-
18
- A resident one-line chip beside the composer: official DeepSeek (balance, session cost, tokens, one-click recharge, low-balance alert) plus the third-party token total. Accounting is bucketed per provider — a GLM session never shows a DeepSeek balance, and DeepSeek costs are never computed from GLM tokens.
19
-
20
- <p align="center">
21
- If deepseek-harness-wallet helps you, please consider leaving a ⭐ Star. Thank you!
22
- </p>
23
-
24
- ## What it shows
25
-
26
- ```
27
- 余额 5.89 · 本场 0.72 · 官 18.8M | 三方 800K · ↗充
28
- ```
29
-
30
- - **Official DeepSeek** — live balance (60s global refresh with fast boot retries), current-session cost (official pricing timeline, including the 2026-08-17 peak/off-peak rollout), and token breakdown.
31
- - **Third-party total** — current-session tokens (input / cache read / output). No balance guessing, no cost math, zero configuration.
32
- - **Click the chip** to open the detail panel: per-currency balance breakdown, cost and token splits, a freely editable low-balance threshold in CNY (two decimals, persisted globally; the chip compares the CNY balance, never a mixed-currency sum), manual refresh, and a jump to the official recharge page (first click shows the domain for confirmation — anti-phishing).
33
- - **Floating window mode** — from the detail panel, detach the wallet into a floating window you can drag anywhere (position remembered across reloads), or minimize it to a small dot; the dot turns red below the threshold.
34
- - **Low-balance alert** — below the threshold the chip turns red with a breathing animation and fires one desktop notification; it resets automatically once the balance recovers.
35
- - **Theme-native UI** — built entirely on `--dsw-alias-*` theme variables, so light and dark themes both render correctly; the panel closes when you click outside and flips open-direction near screen edges.
36
- - **Clear current session** — one button clears only the open conversation's token/cost records; every other conversation is untouched.
37
-
38
- ### Screenshots
39
-
40
- | Floating window (draggable) | Minimized dot | Below threshold (alert on) | Above threshold (normal) |
41
- | --- | --- | --- | --- |
42
- | <img alt="Floating wallet window" src="docs/assets/floating-window.png" width="340"> | <img alt="Minimized floating dot" src="docs/assets/floating-dot.png" width="340"> | <img alt="Below threshold" src="docs/assets/below-threshold.png" width="340"> | <img alt="Above threshold" src="docs/assets/above-threshold.png" width="340"> |
43
-
44
- ## Install
45
-
46
- ```sh
47
- dsh plugin --profile web add github:feibi-mochi/deepseek-harness-wallet
48
- ```
49
-
50
- Restart `dsh web`, then hard-refresh the page.
51
-
52
- ### Update
53
-
54
- ```sh
55
- dsh plugin --profile web update deepseek-harness-wallet
56
- ```
57
-
58
- ### Remove
59
-
60
- ```sh
61
- dsh plugin --profile web remove deepseek-harness-wallet
62
- ```
63
-
64
- > The package was renamed from `dsh-wallet` to `deepseek-harness-wallet` in 0.1.1. If you installed the old name, remove it with `dsh plugin --profile web remove dsh-wallet` first.
65
-
66
- ## Data & trust
67
-
68
- | Item | Behavior |
69
- | --- | --- |
70
- | Token accounting | Listens to the `llm/stream` event and buckets per provider (`deepseek-official` vs. everything else) and per session; multiple sessions never mix accounts. |
71
- | Balance | The `DEEPSEEK_API_KEY` from the credentials seam never leaves this machine except as the `Authorization` header of the official `/user/balance` request. |
72
- | Session log | The plugin writes no events; its data lives in `$DSH_HOME/storages/wallet.json`. |
73
- | Model surface | No tools registered, no prompt injection, zero token cost. |
74
- | Recharge | The URL is hardcoded to the official `https://platform.deepseek.com/top_up` and is not user-configurable (anti-phishing). |
75
-
76
- ## Pricing timeline
77
-
78
- CNY per 1M tokens, curated from official announcements (cache writes are not billed):
79
-
80
- - Since 2025-02-09 — deepseek-chat 2/8 (cache read 0.5), deepseek-reasoner 4/16 (cache read 1)
81
- - Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025)
82
- - Since 2026-08-17 00:00 Beijing — peak/off-peak pricing for the v4 models (peak windows Beijing 09:00–12:00 / 14:00–18:00; off-peak is half the peak rate):
83
- - v4-flash (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
84
- - v4-pro (off-peak / peak): cache read 0.15 / 0.30, input 4.5 / 9, output 13.5 / 27
85
-
86
- deepseek-chat and deepseek-reasoner keep their flat rates. Costs are estimates; the API-returned balance is authoritative.
87
-
88
- ## Roadmap
89
-
90
- - [ ] Third-party price tables (cost per token)
91
- - [ ] Balance history chart
92
- - [ ] Balance-API adapters for other providers (e.g. Zhipu)
93
-
94
- ## License
95
-
96
- [MIT](LICENSE)
1
+ # deepseek-harness-wallet
2
+
3
+ ### DeepSeek Harness Balance Monitor & Recharge Plugin · 余额监控和充值插件
4
+
5
+ <p align="center">
6
+ <a href="./README.md">English</a> ·
7
+ <a href="./docs/i18n/README.zh-CN.md">简体中文</a>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <img alt="Version 0.1.3" src="https://img.shields.io/badge/version-0.1.3-5965d8">
12
+ <img alt="DeepSeek Harness rc.6" src="https://img.shields.io/badge/dsh-0.1.0--rc.6-4aa3ff">
13
+ <img alt="MIT License" src="https://img.shields.io/badge/license-MIT-3b7a57">
14
+ </p>
15
+
16
+ **The multi-provider wallet chip for the DeepSeek Harness Web GUI.**
17
+
18
+ A resident one-line chip beside the composer: official DeepSeek (balance, session cost, tokens, one-click recharge, low-balance alert) plus the third-party token total. Accounting is bucketed per provider — a GLM session never shows a DeepSeek balance, and DeepSeek costs are never computed from GLM tokens.
19
+
20
+ <p align="center">
21
+ If deepseek-harness-wallet helps you, please consider leaving a ⭐ Star. Thank you!
22
+ </p>
23
+
24
+ ## What it shows
25
+
26
+ ```
27
+ 余额 ¥5.89 · 本场 ¥0.72 · 官 18.8M | 三方 800K · ↗充
28
+ ```
29
+
30
+ - **Official DeepSeek** — live balance (60s global refresh with fast boot retries), current-session cost locked to the price active for each usage event (including the 2026-08-17 peak/off-peak rollout), and token breakdown.
31
+ - **Third-party total** — current-session tokens (input / cache read / output). No balance guessing, no cost math, zero configuration.
32
+ - **Click the chip** to open the detail panel: correctly formatted per-currency balances, cost and token splits, a freely editable low-balance threshold in CNY (two decimals, persisted globally; alerts only compare a CNY balance and never mix currencies), manual refresh, and a jump to the official recharge page (first click shows the domain for confirmation — anti-phishing).
33
+ - **Floating window mode** — from the detail panel, detach the wallet into a floating window you can drag anywhere (position remembered across reloads), or minimize it to a small dot; the dot turns red below the threshold.
34
+ - **Low-balance alert** — below the threshold the chip turns red with a breathing animation and fires one desktop notification; it resets automatically once the balance recovers.
35
+ - **Theme-native UI** — built entirely on `--dsw-alias-*` theme variables, so light and dark themes both render correctly; the panel closes when you click outside and flips open-direction near screen edges.
36
+ - **Clear current session** — one button clears only the open conversation's token/cost records; every other conversation is untouched.
37
+
38
+ ### Screenshots
39
+
40
+ | Floating window (draggable) | Minimized dot | Below threshold (alert on) | Above threshold (normal) |
41
+ | --- | --- | --- | --- |
42
+ | <img alt="Floating wallet window" src="docs/assets/floating-window.png" width="340"> | <img alt="Minimized floating dot" src="docs/assets/floating-dot.png" width="340"> | <img alt="Below threshold" src="docs/assets/below-threshold.png" width="340"> | <img alt="Above threshold" src="docs/assets/above-threshold.png" width="340"> |
43
+
44
+ ## Install
45
+
46
+ From npm:
47
+
48
+ ```sh
49
+ dsh plugin --profile web add deepseek-harness-wallet
50
+ ```
51
+
52
+ or from GitHub directly:
53
+
54
+ ```sh
55
+ dsh plugin --profile web add github:feibi-mochi/deepseek-harness-wallet
56
+ ```
57
+
58
+ Restart `dsh web`, then hard-refresh the page.
59
+
60
+ ### Update
61
+
62
+ ```sh
63
+ dsh plugin --profile web update deepseek-harness-wallet
64
+ ```
65
+
66
+ ### Remove
67
+
68
+ ```sh
69
+ dsh plugin --profile web remove deepseek-harness-wallet
70
+ ```
71
+
72
+ > The package was renamed from `dsh-wallet` to `deepseek-harness-wallet` in 0.1.1. If you installed the old name, remove it with `dsh plugin --profile web remove dsh-wallet` first.
73
+
74
+ ## Data & trust
75
+
76
+ | Item | Behavior |
77
+ | --- | --- |
78
+ | Token accounting | Listens to the `llm/stream` event and buckets per provider (`deepseek-official` vs. everything else) and per session; each usage event also locks its contemporaneous official price, so multiple sessions and pricing windows never mix. |
79
+ | Balance | The `DEEPSEEK_API_KEY` from the credentials seam never leaves this machine except as the `Authorization` header of the official `/user/balance` request. |
80
+ | Session log | The plugin writes no events; its data lives in `$DSH_HOME/storages/wallet.json`. |
81
+ | Model surface | No tools registered, no prompt injection, zero token cost. |
82
+ | Recharge | The URL is hardcoded to the official `https://platform.deepseek.com/top_up` and is not user-configurable (anti-phishing). |
83
+
84
+ ## Pricing timeline
85
+
86
+ CNY per 1M tokens, curated from official announcements (cache writes are not billed):
87
+
88
+ - Since 2025-02-09 — deepseek-chat 2/8 (cache read 0.5), deepseek-reasoner 4/16 (cache read 1)
89
+ - Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025)
90
+ - Since 2026-08-17 00:00 Beijing — peak/off-peak pricing for the v4 models (peak windows Beijing 09:00–12:00 / 14:00–18:00; off-peak is half the peak rate):
91
+ - v4-flash (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
92
+ - v4-pro (off-peak / peak): cache read 0.15 / 0.30, input 4.5 / 9, output 13.5 / 27
93
+
94
+ deepseek-chat and deepseek-reasoner keep their flat rates. Each usage event is priced when it arrives; upgrading from 0.1.2 migrates legacy counters once using the then-current rate. Costs are estimates; the API-returned balance is authoritative.
95
+
96
+ ## Roadmap
97
+
98
+ - [ ] Third-party price tables (cost per token)
99
+ - [ ] Balance history chart
100
+ - [ ] Balance-API adapters for other providers (e.g. Zhipu)
101
+
102
+ ## License
103
+
104
+ [MIT](LICENSE)
package/index.js CHANGED
@@ -19,8 +19,9 @@ export const inject = ['webServer']
19
19
  const OFFICIAL_PROVIDER = 'deepseek-official'
20
20
  const RECHARGE_URL = 'https://platform.deepseek.com/top_up'
21
21
  const STORE_PATH = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages', 'wallet.json')
22
- const DEFAULT_THRESHOLD = 5
23
- const BALANCE_REFRESH_MS = 60_000
22
+ const DEFAULT_THRESHOLD = 5
23
+ const BALANCE_REFRESH_MS = 60_000
24
+ const STORE_VERSION = 2
24
25
 
25
26
  // Beijing (UTC+8, no DST) peak windows: 09:00-12:00 and 14:00-18:00.
26
27
  const BEIJING_OFFSET_MS = 8 * 3600_000
@@ -72,22 +73,24 @@ export function ratesFor(model, atMs) {
72
73
  return rates
73
74
  }
74
75
 
75
- export function costOf(model, usage, atMs) {
76
- const rates = ratesFor(model, atMs)
77
- if (rates === null) return null
78
- const input = (usage.inputTokens ?? usage.input ?? 0) * rates.input
79
- const cacheRead = (usage.cacheReadTokens ?? usage.cacheRead ?? 0) * rates.cacheHit
80
- const output = (usage.outputTokens ?? usage.output ?? 0) * rates.output
76
+ export function costOf(model, usage, atMs) {
77
+ const rates = ratesFor(model, atMs)
78
+ if (rates === null) return null
79
+ usage = usage !== null && typeof usage === 'object' ? usage : {}
80
+ const input = finiteCounter(usage.inputTokens ?? usage.input) * rates.input
81
+ const cacheRead = finiteCounter(usage.cacheReadTokens ?? usage.cacheRead) * rates.cacheHit
82
+ const output = finiteCounter(usage.outputTokens ?? usage.output) * rates.output
81
83
  return (input + cacheRead + output) / 1e6
82
84
  }
83
85
 
84
- function emptyCounters() {
85
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
86
- }
87
-
88
- function emptyBucket() {
89
- return { models: {} }
90
- }
86
+ function emptyCounters() {
87
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
88
+ }
89
+
90
+ function emptyBucket(priced) {
91
+ if (priced === true) return { models: {}, cost: 0, priced: true }
92
+ return { models: {} }
93
+ }
91
94
 
92
95
  export function normalizeThreshold(value) {
93
96
  const parsed = Number.parseFloat(value)
@@ -95,47 +98,109 @@ export function normalizeThreshold(value) {
95
98
  return Math.min(100000, Math.max(0, Math.round(parsed * 100) / 100))
96
99
  }
97
100
 
98
- function loadStore() {
99
- try {
100
- const parsed = JSON.parse(readFileSync(STORE_PATH, 'utf8'))
101
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
102
- return { threshold: DEFAULT_THRESHOLD, sessions: {} }
103
- }
104
- const sessions = parsed.sessions !== null && typeof parsed.sessions === 'object' && !Array.isArray(parsed.sessions)
105
- ? parsed.sessions
106
- : {}
107
- return { threshold: normalizeThreshold(parsed.threshold), sessions }
108
- } catch {
109
- return { threshold: DEFAULT_THRESHOLD, sessions: {} }
110
- }
111
- }
112
-
113
- let saveTimer = null
114
- let store = loadStore()
115
-
116
- function scheduleSave(logger) {
117
- if (saveTimer !== null) return
118
- saveTimer = setTimeout(() => {
119
- saveTimer = null
120
- try {
121
- mkdirSync(dirname(STORE_PATH), { recursive: true })
122
- const tmp = STORE_PATH + '.tmp'
123
- writeFileSync(tmp, JSON.stringify(store))
124
- renameSync(tmp, STORE_PATH)
125
- } catch (error) {
126
- if (logger && typeof logger.warn === 'function') {
127
- logger.warn('dsh-wallet: failed to persist store: ' + String(error))
128
- }
129
- }
130
- }, 500)
131
- }
101
+ function finiteCounter(value) {
102
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
103
+ }
104
+
105
+ function normalizeCounters(value) {
106
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
107
+ return {
108
+ input: finiteCounter(source.input),
109
+ output: finiteCounter(source.output),
110
+ cacheRead: finiteCounter(source.cacheRead),
111
+ cacheWrite: finiteCounter(source.cacheWrite),
112
+ reasoning: finiteCounter(source.reasoning),
113
+ }
114
+ }
115
+
116
+ function normalizeModels(value) {
117
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
118
+ const models = {}
119
+ for (const [model, counters] of Object.entries(source)) {
120
+ if (model === '__proto__' || model === 'prototype' || model === 'constructor') continue
121
+ models[model] = normalizeCounters(counters)
122
+ }
123
+ return models
124
+ }
125
+
126
+ function migratedOfficialBucket(value, atMs) {
127
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
128
+ const models = normalizeModels(source.models)
129
+ if (typeof source.cost === 'number' && Number.isFinite(source.cost) && source.cost >= 0 && typeof source.priced === 'boolean') {
130
+ return { models, cost: source.cost, priced: source.priced }
131
+ }
132
+ let cost = 0
133
+ let priced = true
134
+ for (const [model, counters] of Object.entries(models)) {
135
+ const valueAtMigration = costOf(model, counters, atMs)
136
+ if (valueAtMigration === null) priced = false
137
+ else cost += valueAtMigration
138
+ }
139
+ return { models, cost, priced }
140
+ }
141
+
142
+ export function normalizeStoreData(value, atMs = Date.now()) {
143
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
144
+ const rawSessions = source.sessions !== null && typeof source.sessions === 'object' && !Array.isArray(source.sessions)
145
+ ? source.sessions
146
+ : {}
147
+ const sessions = {}
148
+ for (const [sessionId, value] of Object.entries(rawSessions)) {
149
+ if (sessionId === '__proto__' || sessionId === 'prototype' || sessionId === 'constructor') continue
150
+ const rawSession = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
151
+ sessions[sessionId] = {
152
+ official: migratedOfficialBucket(rawSession.official, atMs),
153
+ third: { models: normalizeModels(rawSession.third && rawSession.third.models) },
154
+ }
155
+ }
156
+ const normalized = { version: STORE_VERSION, threshold: normalizeThreshold(source.threshold), sessions }
157
+ return { store: normalized, migrated: JSON.stringify(source) !== JSON.stringify(normalized) }
158
+ }
159
+
160
+ function loadStore() {
161
+ try {
162
+ return normalizeStoreData(JSON.parse(readFileSync(STORE_PATH, 'utf8')))
163
+ } catch {
164
+ return {
165
+ store: { version: STORE_VERSION, threshold: DEFAULT_THRESHOLD, sessions: {} },
166
+ migrated: false,
167
+ }
168
+ }
169
+ }
170
+
171
+ let saveTimer = null
172
+ const loadedStore = loadStore()
173
+ let store = loadedStore.store
174
+ let storeNeedsSave = loadedStore.migrated
175
+
176
+ function persistStore(logger) {
177
+ if (saveTimer !== null) {
178
+ clearTimeout(saveTimer)
179
+ saveTimer = null
180
+ }
181
+ try {
182
+ mkdirSync(dirname(STORE_PATH), { recursive: true })
183
+ const tmp = STORE_PATH + '.tmp'
184
+ writeFileSync(tmp, JSON.stringify(store))
185
+ renameSync(tmp, STORE_PATH)
186
+ } catch (error) {
187
+ if (logger && typeof logger.warn === 'function') {
188
+ logger.warn('dsh-wallet: failed to persist store: ' + String(error))
189
+ }
190
+ }
191
+ }
192
+
193
+ function scheduleSave(logger) {
194
+ if (saveTimer !== null) return
195
+ saveTimer = setTimeout(() => persistStore(logger), 500)
196
+ }
132
197
 
133
- function addUsage(counters, usage) {
134
- counters.input += usage.inputTokens ?? 0
135
- counters.output += usage.outputTokens ?? 0
136
- counters.cacheRead += usage.cacheReadTokens ?? 0
137
- counters.cacheWrite += usage.cacheWriteTokens ?? 0
138
- counters.reasoning += usage.reasoningTokens ?? 0
198
+ function addUsage(counters, usage) {
199
+ counters.input += finiteCounter(usage.inputTokens)
200
+ counters.output += finiteCounter(usage.outputTokens)
201
+ counters.cacheRead += finiteCounter(usage.cacheReadTokens)
202
+ counters.cacheWrite += finiteCounter(usage.cacheWriteTokens)
203
+ counters.reasoning += finiteCounter(usage.reasoningTokens)
139
204
  }
140
205
 
141
206
  function bucketTotals(bucket) {
@@ -150,25 +215,37 @@ function bucketTotals(bucket) {
150
215
  return totals
151
216
  }
152
217
 
153
- function recordUsage(options, usage) {
218
+ export function addOfficialUsage(bucket, model, usage, atMs) {
219
+ if (!Object.hasOwn(bucket.models, model)) bucket.models[model] = emptyCounters()
220
+ addUsage(bucket.models[model], usage)
221
+ const cost = costOf(model, usage, atMs)
222
+ if (cost === null) bucket.priced = false
223
+ else bucket.cost += cost
224
+ }
225
+
226
+ function recordUsage(options, usage, atMs = Date.now()) {
154
227
  const sessionId = options.sessionId
155
- const provider = options.provider
156
- const model = options.model ?? options.modelName ?? ''
157
- if (sessionId === undefined || provider === undefined || provider === '') return
158
- if (store.sessions[sessionId] === undefined) {
159
- store.sessions[sessionId] = { official: emptyBucket(), third: emptyBucket() }
160
- }
161
- const session = store.sessions[sessionId]
162
- const bucket = provider === OFFICIAL_PROVIDER ? session.official : session.third
163
- if (bucket.models[model] === undefined) {
164
- bucket.models[model] = emptyCounters()
165
- }
166
- addUsage(bucket.models[model], usage)
167
- }
168
-
169
- let balance = { fetchedAt: 0, available: false, balances: [], error: null }
228
+ const provider = options.provider
229
+ const model = options.model ?? options.modelName ?? ''
230
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof provider !== 'string' || provider === '') return
231
+ if (typeof model !== 'string' || model === '__proto__' || model === 'prototype' || model === 'constructor') return
232
+ if (sessionId === '__proto__' || sessionId === 'prototype' || sessionId === 'constructor') return
233
+ if (!Object.hasOwn(store.sessions, sessionId)) {
234
+ store.sessions[sessionId] = { official: emptyBucket(true), third: emptyBucket(false) }
235
+ }
236
+ const session = store.sessions[sessionId]
237
+ const bucket = provider === OFFICIAL_PROVIDER ? session.official : session.third
238
+ if (provider === OFFICIAL_PROVIDER) addOfficialUsage(bucket, model, usage, atMs)
239
+ else {
240
+ if (!Object.hasOwn(bucket.models, model)) bucket.models[model] = emptyCounters()
241
+ addUsage(bucket.models[model], usage)
242
+ }
243
+ }
170
244
 
171
- async function refreshBalance(ctx) {
245
+ let balance = { fetchedAt: 0, available: false, balances: [], error: null }
246
+ let balanceRefresh = null
247
+
248
+ async function performBalanceRefresh(ctx) {
172
249
  const credentials = ctx.get('credentials')
173
250
  if (credentials === undefined) {
174
251
  balance = { fetchedAt: Date.now(), available: false, balances: [], error: 'no credentials service' }
@@ -207,10 +284,18 @@ async function refreshBalance(ctx) {
207
284
  balances: [],
208
285
  error: error instanceof Error ? error.message : String(error),
209
286
  }
210
- }
211
- }
287
+ }
288
+ }
289
+
290
+ function refreshBalance(ctx) {
291
+ if (balanceRefresh !== null) return balanceRefresh
292
+ balanceRefresh = performBalanceRefresh(ctx).finally(() => {
293
+ balanceRefresh = null
294
+ })
295
+ return balanceRefresh
296
+ }
212
297
 
213
- export function sumBalances(infos) {
298
+ export function sumBalances(infos) {
214
299
  if (!Array.isArray(infos) || infos.length === 0) return 0
215
300
  // The chip and the threshold are denominated in CNY: prefer the CNY record
216
301
  // instead of mixing it with e.g. a USD record for international accounts.
@@ -232,8 +317,16 @@ export function sumBalances(infos) {
232
317
  const value = Number.parseFloat(info.total_balance)
233
318
  if (Number.isFinite(value)) total += value
234
319
  }
235
- return total
236
- }
320
+ return total
321
+ }
322
+
323
+ export function balanceCurrency(infos) {
324
+ if (!Array.isArray(infos)) return null
325
+ const cny = infos.find((info) => info.currency === 'CNY' && Number.isFinite(Number.parseFloat(info.total_balance)))
326
+ if (cny !== undefined) return 'CNY'
327
+ const firstFinite = infos.find((info) => Number.isFinite(Number.parseFloat(info.total_balance)))
328
+ return firstFinite && typeof firstFinite.currency === 'string' ? firstFinite.currency.toUpperCase() : null
329
+ }
237
330
 
238
331
  function balanceTotal() {
239
332
  return sumBalances(balance.balances)
@@ -243,55 +336,61 @@ function sessionView(sessionId) {
243
336
  if (sessionId === undefined || sessionId === '') return { official: null, third: null }
244
337
  const session = store.sessions[sessionId]
245
338
  if (session === undefined) return { official: null, third: null }
246
- const official = bucketTotals(session.official)
247
- const third = bucketTotals(session.third)
248
- const now = Date.now()
249
- let cost = 0
250
- let priced = true
251
- for (const model of Object.keys(session.official.models)) {
252
- const value = costOf(model, session.official.models[model], now)
253
- if (value === null) priced = false
254
- else cost += value
255
- }
256
- return {
257
- official: { tokens: official, cost: priced ? cost : null, models: session.official.models },
339
+ const official = bucketTotals(session.official)
340
+ const third = bucketTotals(session.third)
341
+ return {
342
+ official: {
343
+ tokens: official,
344
+ cost: session.official.priced === true ? session.official.cost : null,
345
+ models: session.official.models,
346
+ },
258
347
  third: { tokens: third, models: session.third.models },
259
348
  }
260
349
  }
261
350
 
262
- function snapshotView(sessionId, threshold) {
263
- return {
351
+ function snapshotView(sessionId, threshold) {
352
+ const currency = balanceCurrency(balance.balances)
353
+ return {
264
354
  ok: true,
265
355
  balance: {
266
356
  available: balance.available,
267
357
  fetchedAt: balance.fetchedAt,
268
- balances: balance.balances,
269
- total: balance.available ? balanceTotal() : null,
270
- error: balance.available ? null : balance.error,
358
+ balances: balance.balances,
359
+ total: balance.available ? balanceTotal() : null,
360
+ currency,
361
+ error: balance.available ? null : balance.error,
271
362
  },
272
363
  session: sessionView(sessionId),
273
364
  threshold,
274
- lowBalance: balance.available && threshold > 0 && balanceTotal() < threshold,
365
+ // The configured threshold is CNY-denominated; do not compare unlike
366
+ // currencies for international accounts that do not expose a CNY row.
367
+ lowBalance: balance.available && currency === 'CNY' && threshold > 0 && balanceTotal() < threshold,
275
368
  rechargeUrl: RECHARGE_URL,
276
369
  }
277
370
  }
278
371
 
279
- function json(res, status, body) {
280
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
372
+ function json(res, status, body) {
373
+ res.writeHead(status, {
374
+ 'content-type': 'application/json; charset=utf-8',
375
+ 'cache-control': 'no-store',
376
+ })
281
377
  res.end(JSON.stringify(body))
282
378
  }
283
379
 
284
380
  function readBody(req, cap) {
285
381
  cap = cap || 4096
286
- return new Promise((resolve) => {
287
- let size = 0
288
- const parts = []
289
- req.on('data', (chunk) => {
290
- size += chunk.length
291
- if (size <= cap) parts.push(chunk)
292
- })
293
- req.on('end', () => {
294
- try {
382
+ return new Promise((resolve) => {
383
+ let size = 0
384
+ let tooLarge = false
385
+ const parts = []
386
+ req.on('data', (chunk) => {
387
+ size += chunk.length
388
+ if (size <= cap) parts.push(chunk)
389
+ else tooLarge = true
390
+ })
391
+ req.on('end', () => {
392
+ if (tooLarge) return resolve(null)
393
+ try {
295
394
  resolve(JSON.parse(Buffer.concat(parts).toString('utf8') || '{}'))
296
395
  } catch {
297
396
  resolve(null)
@@ -309,8 +408,12 @@ function sessionParam(req) {
309
408
  return value === null || value === '' ? undefined : value
310
409
  }
311
410
 
312
- export function apply(ctx, config) {
313
- config = config || {}
411
+ export function apply(ctx, config) {
412
+ config = config || {}
413
+ if (storeNeedsSave) {
414
+ storeNeedsSave = false
415
+ scheduleSave(ctx.logger)
416
+ }
314
417
  // Threshold lives ONLY in the persisted store; an explicit row config may
315
418
  // still override it for power users, but the bundle patch sets none.
316
419
  let threshold = store.threshold
@@ -321,19 +424,21 @@ export function apply(ctx, config) {
321
424
  }
322
425
 
323
426
  const usageTap = (options, next) => {
324
- const downstream = next()
325
- return (async function* () {
326
- let usage = null
327
- try {
427
+ const downstream = next()
428
+ return (async function* () {
429
+ let usage = null
430
+ let usageAt = 0
431
+ try {
328
432
  for await (const chunk of downstream) {
329
- if (chunk !== null && chunk !== undefined && chunk.type === 'usage' && chunk.usage !== undefined) {
330
- usage = chunk.usage
433
+ if (chunk !== null && chunk !== undefined && chunk.type === 'usage' && chunk.usage !== undefined) {
434
+ usage = chunk.usage
435
+ usageAt = Date.now()
331
436
  }
332
437
  yield chunk
333
438
  }
334
439
  } finally {
335
- if (usage !== null) {
336
- recordUsage(options, usage)
440
+ if (usage !== null) {
441
+ recordUsage(options, usage, usageAt)
337
442
  scheduleSave(ctx.logger)
338
443
  }
339
444
  }
@@ -409,11 +514,10 @@ export function apply(ctx, config) {
409
514
  return () => {
410
515
  disposeSnapshot()
411
516
  disposeThreshold()
412
- disposeRefresh()
413
- disposeClear()
414
- clearInterval(balanceTimer)
415
- clearTimeout(saveTimer)
416
- saveTimer = null
417
- }
517
+ disposeRefresh()
518
+ disposeClear()
519
+ clearInterval(balanceTimer)
520
+ if (saveTimer !== null) persistStore(ctx.logger)
521
+ }
418
522
  }, 'dsh-wallet: routes')
419
523
  }