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.
- package/README.md +104 -96
- package/index.js +226 -122
- package/lib/client.js +550 -371
- 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.
|
|
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
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
```sh
|
|
55
|
-
dsh plugin --profile web
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
-
|
|
91
|
-
-
|
|
92
|
-
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
135
|
-
counters.output += usage.outputTokens
|
|
136
|
-
counters.cacheRead += usage.cacheReadTokens
|
|
137
|
-
counters.cacheWrite += usage.cacheWriteTokens
|
|
138
|
-
counters.reasoning += usage.reasoningTokens
|
|
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
|
|
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 ===
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
517
|
+
disposeRefresh()
|
|
518
|
+
disposeClear()
|
|
519
|
+
clearInterval(balanceTimer)
|
|
520
|
+
if (saveTimer !== null) persistStore(ctx.logger)
|
|
521
|
+
}
|
|
418
522
|
}, 'dsh-wallet: routes')
|
|
419
523
|
}
|