dsh-token-use 0.2.2 → 0.3.1
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.en.md +64 -11
- package/README.md +65 -11
- package/assets/tab-config.jpg +0 -0
- package/assets/tab-models.jpg +0 -0
- package/assets/tab-overview.jpg +0 -0
- package/assets/tab-projects.jpg +0 -0
- package/assets/tab-selfcheck.jpg +0 -0
- package/client/client.js +997 -64
- package/cordis.patch.yml +5 -0
- package/lib/health.js +277 -0
- package/lib/index.js +200 -44
- package/lib/versions.js +396 -0
- package/package.json +7 -3
package/lib/versions.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version discovery and process control for the settings panel's config tab.
|
|
3
|
+
*
|
|
4
|
+
* Installed versions are read from disk, never guessed: the running dsh bin
|
|
5
|
+
* (`process.argv[1]`) names the service package, and the plugin's own module
|
|
6
|
+
* chain reaches the harness packages. Latest versions come from the npm
|
|
7
|
+
* registry and are cached on disk for hours — the panel is a settings page,
|
|
8
|
+
* not a poller.
|
|
9
|
+
*
|
|
10
|
+
* The restart helper writes a detached shell script instead of killing the
|
|
11
|
+
* process directly: the HTTP response has to reach the browser first, and the
|
|
12
|
+
* relaunch must survive the death of the process that spawned it.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-token-use/versions
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
17
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { createRequire } from 'node:module'
|
|
19
|
+
import { arch, platform as platformName, release, type as osType } from 'node:os'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** Packages the config tab reports; `id` is what the client labels. */
|
|
23
|
+
export const TRACKED = [
|
|
24
|
+
{ id: 'dsh-service', label: 'dsh-service', name: '@deepseek-ai/dsh' },
|
|
25
|
+
{ id: 'dsh', label: 'dsh', name: '@deepseek-ai/dsh-web-app' },
|
|
26
|
+
{ id: 'dsh-base', label: 'dsh-base', name: '@deepseek-ai/dsh-base' },
|
|
27
|
+
{ id: 'dsh-token-use', label: 'dsh-token-use', name: 'dsh-token-use' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
|
|
31
|
+
const TIMEOUT_MS = 8000
|
|
32
|
+
/** How long a registry answer is trusted before the next check. */
|
|
33
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
|
34
|
+
const USER_AGENT = 'dsh-token-use (+https://github.com/huangyuheng/dsh-token-use)'
|
|
35
|
+
/** Steps walked up a directory chain while hunting for a package.json. */
|
|
36
|
+
const MAX_WALK = 12
|
|
37
|
+
|
|
38
|
+
const require = createRequire(import.meta.url)
|
|
39
|
+
|
|
40
|
+
function readJson(file) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** package.json paths to try, nearest first, walking up from `start`. */
|
|
49
|
+
function walkUp(start, name) {
|
|
50
|
+
const candidates = []
|
|
51
|
+
let dir = start
|
|
52
|
+
for (let i = 0; i < MAX_WALK; i++) {
|
|
53
|
+
candidates.push(join(dir, 'node_modules', name, 'package.json'))
|
|
54
|
+
const parent = dirname(dir)
|
|
55
|
+
if (parent === dir) break
|
|
56
|
+
dir = parent
|
|
57
|
+
}
|
|
58
|
+
return candidates
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Version of the harness package that owns the running bin. `process.argv[1]`
|
|
63
|
+
* is the entry script, so the first package.json named `@deepseek-ai/dsh` above
|
|
64
|
+
* it is exactly the code being served — the truth even when several copies are
|
|
65
|
+
* installed (pnpm dlx keeps one store link per version).
|
|
66
|
+
*/
|
|
67
|
+
function runningServiceVersion() {
|
|
68
|
+
const bin = process.argv[1]
|
|
69
|
+
if (typeof bin !== 'string' || bin.length === 0) return undefined
|
|
70
|
+
let dir = dirname(bin)
|
|
71
|
+
for (let i = 0; i < MAX_WALK; i++) {
|
|
72
|
+
const pkg = readJson(join(dir, 'package.json'))
|
|
73
|
+
if (pkg !== undefined && pkg.name === '@deepseek-ai/dsh' && typeof pkg.version === 'string') return pkg.version
|
|
74
|
+
const parent = dirname(dir)
|
|
75
|
+
if (parent === dir) break
|
|
76
|
+
dir = parent
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Installed version of one package, or undefined when it cannot be located. */
|
|
82
|
+
export function installedVersion(name) {
|
|
83
|
+
if (name === '@deepseek-ai/dsh') {
|
|
84
|
+
const fromBin = runningServiceVersion()
|
|
85
|
+
if (fromBin !== undefined) return fromBin
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const pkg = readJson(require.resolve(`${name}/package.json`))
|
|
89
|
+
if (pkg !== undefined && typeof pkg.version === 'string') return pkg.version
|
|
90
|
+
} catch {
|
|
91
|
+
// fall through to the directory walk
|
|
92
|
+
}
|
|
93
|
+
const starts = [dirname(new URL(import.meta.url).pathname), ...(typeof process.argv[1] === 'string' ? [dirname(process.argv[1])] : []), process.cwd()]
|
|
94
|
+
for (const start of starts) {
|
|
95
|
+
for (const candidate of walkUp(start, name)) {
|
|
96
|
+
const pkg = readJson(candidate)
|
|
97
|
+
if (pkg !== undefined && typeof pkg.version === 'string') return pkg.version
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Semver-lite precedence: `-1` older, `0` equal, `1` newer. A prerelease sorts
|
|
105
|
+
* below its release, and prerelease identifiers are compared the semver way
|
|
106
|
+
* (numeric below alphanumeric, numbers numerically) so `0.1.5-rc.2` beats
|
|
107
|
+
* `0.1.5-rc.1`.
|
|
108
|
+
*/
|
|
109
|
+
export function compareVersions(a, b) {
|
|
110
|
+
const parse = (value) => {
|
|
111
|
+
const [core, pre = ''] = String(value).split('-')
|
|
112
|
+
return {
|
|
113
|
+
core: core.split('.').map((part) => Number.parseInt(part, 10) || 0),
|
|
114
|
+
pre: pre.length === 0 ? [] : pre.split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : part)),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const left = parse(a)
|
|
118
|
+
const right = parse(b)
|
|
119
|
+
for (let i = 0; i < Math.max(left.core.length, right.core.length); i++) {
|
|
120
|
+
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0)
|
|
121
|
+
if (diff !== 0) return diff > 0 ? 1 : -1
|
|
122
|
+
}
|
|
123
|
+
if (left.pre.length === 0 || right.pre.length === 0) {
|
|
124
|
+
if (left.pre.length === right.pre.length) return 0
|
|
125
|
+
return left.pre.length === 0 ? 1 : -1
|
|
126
|
+
}
|
|
127
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
|
|
128
|
+
const one = left.pre[i]
|
|
129
|
+
const two = right.pre[i]
|
|
130
|
+
if (one === undefined) return -1
|
|
131
|
+
if (two === undefined) return 1
|
|
132
|
+
if (one === two) continue
|
|
133
|
+
const oneNumeric = typeof one === 'number'
|
|
134
|
+
if (oneNumeric !== (typeof two === 'number')) return oneNumeric ? -1 : 1
|
|
135
|
+
return one > two ? 1 : -1
|
|
136
|
+
}
|
|
137
|
+
return 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Release channel of a version: the `rc` of `0.1.5-rc.2`, or `stable`. The
|
|
142
|
+
* harness publishes every channel to the same package, and `latest` is not
|
|
143
|
+
* always the newest of them — `@deepseek-ai/dsh-web-app` still tags a stale
|
|
144
|
+
* `0.0.1-rc.1` as latest while `next` carries the real build.
|
|
145
|
+
*/
|
|
146
|
+
export function channelOf(version, fallback = 'stable') {
|
|
147
|
+
if (typeof version !== 'string' || version.length === 0) return fallback
|
|
148
|
+
const match = /-[a-zA-Z]+/.exec(version)
|
|
149
|
+
return match === null ? 'stable' : match[0].slice(1).toLowerCase()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Highest version inside one channel. */
|
|
153
|
+
function highestInChannel(versions, channel) {
|
|
154
|
+
let best
|
|
155
|
+
for (const version of versions) {
|
|
156
|
+
if (channelOf(version) !== channel) continue
|
|
157
|
+
if (best === undefined || compareVersions(version, best) > 0) best = version
|
|
158
|
+
}
|
|
159
|
+
return best
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** macOS prefers the marketing version (`sw_vers`); elsewhere the kernel tells enough. */
|
|
163
|
+
function osDisplayName() {
|
|
164
|
+
if (platformName() === 'darwin') {
|
|
165
|
+
try {
|
|
166
|
+
const product = execFileSync('sw_vers', ['-productVersion'], { encoding: 'utf8', timeout: 2000 }).trim()
|
|
167
|
+
if (product.length > 0) return `macOS ${product}`
|
|
168
|
+
} catch {
|
|
169
|
+
return `macOS (kernel ${release()})`
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const family = { linux: 'Linux', win32: 'Windows', freebsd: 'FreeBSD' }[platformName()]
|
|
173
|
+
return family === undefined ? `${osType()} ${release()}` : `${family} ${release()}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Runtime facts for the config tab; `port` comes from the request that asked. */
|
|
177
|
+
export function describePlatform(port, dshHome) {
|
|
178
|
+
return {
|
|
179
|
+
os: platformName(),
|
|
180
|
+
osName: osDisplayName(),
|
|
181
|
+
arch: arch(),
|
|
182
|
+
node: process.version,
|
|
183
|
+
pid: process.pid,
|
|
184
|
+
port: typeof port === 'string' && port.length > 0 ? port : null,
|
|
185
|
+
uptimeSec: Math.round(process.uptime()),
|
|
186
|
+
cwd: process.cwd(),
|
|
187
|
+
execPath: process.execPath,
|
|
188
|
+
dshHome,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `'current' | 'outdated' | 'ahead' | 'unknown'`. */
|
|
193
|
+
function statusOf(installed, latest) {
|
|
194
|
+
if (typeof installed !== 'string' || installed.length === 0) return 'unknown'
|
|
195
|
+
if (typeof latest !== 'string' || latest.length === 0) return 'unknown'
|
|
196
|
+
const diff = compareVersions(installed, latest)
|
|
197
|
+
if (diff === 0) return 'current'
|
|
198
|
+
return diff < 0 ? 'outdated' : 'ahead'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Latest-version lookups with an on-disk cache. A failed lookup keeps the last
|
|
203
|
+
* known answer (marked stale) so the tab still renders offline.
|
|
204
|
+
*/
|
|
205
|
+
export class VersionStore {
|
|
206
|
+
/**
|
|
207
|
+
* @param dshHome - `$DSH_HOME`, where the cache lives.
|
|
208
|
+
* @param options - `registry` (defaults to npmjs), `timeoutMs`, `ttlMs`.
|
|
209
|
+
*/
|
|
210
|
+
constructor(dshHome, options = {}) {
|
|
211
|
+
this.file = join(dshHome, 'dsh-token-use', 'versions.json')
|
|
212
|
+
this.registry = typeof options.registry === 'string' && options.registry.length > 0 ? options.registry : DEFAULT_REGISTRY
|
|
213
|
+
this.timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : TIMEOUT_MS
|
|
214
|
+
this.ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : CACHE_TTL_MS
|
|
215
|
+
this.cache = readJson(this.file) ?? {}
|
|
216
|
+
this.pending = null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Cached record for one package, or undefined. */
|
|
220
|
+
cached(name) {
|
|
221
|
+
return this.cache.packages?.[name]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The registry's install metadata (versions + dist-tags) — the abbreviated
|
|
226
|
+
* packument, which is a fraction of the full document's size.
|
|
227
|
+
*/
|
|
228
|
+
async fetchPackument(name) {
|
|
229
|
+
const url = `${this.registry}/${name.replace('/', '%2f')}`
|
|
230
|
+
const response = await fetch(url, {
|
|
231
|
+
headers: { accept: 'application/vnd.npm.install-v1+json', 'user-agent': USER_AGENT },
|
|
232
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
233
|
+
})
|
|
234
|
+
if (!response.ok) throw new Error(`http ${response.status}`)
|
|
235
|
+
const body = await response.json()
|
|
236
|
+
const versions = Object.keys(body?.versions ?? {})
|
|
237
|
+
if (versions.length === 0) throw new Error('no versions in registry response')
|
|
238
|
+
return { versions, tags: body['dist-tags'] ?? {} }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Refresh every tracked package at most once per TTL, deduplicated.
|
|
243
|
+
* @param names - packages to look up.
|
|
244
|
+
* @param options - `force: true` skips the TTL, for the panel's re-check button.
|
|
245
|
+
*/
|
|
246
|
+
async ensure(names, options = {}) {
|
|
247
|
+
const checkedAt = typeof this.cache.checkedAt === 'number' ? this.cache.checkedAt : 0
|
|
248
|
+
if (options.force !== true && Date.now() - checkedAt < this.ttlMs) return
|
|
249
|
+
if (this.pending !== null) {
|
|
250
|
+
await this.pending
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
this.pending = (async () => {
|
|
254
|
+
const packages = { ...this.cache.packages }
|
|
255
|
+
const errors = {}
|
|
256
|
+
await Promise.all(names.map(async (name) => {
|
|
257
|
+
try {
|
|
258
|
+
const { versions, tags } = await this.fetchPackument(name)
|
|
259
|
+
const next = typeof tags.next === 'string' ? tags.next : undefined
|
|
260
|
+
packages[name] = {
|
|
261
|
+
versions: versions.slice(-40),
|
|
262
|
+
tags,
|
|
263
|
+
next: next ?? null,
|
|
264
|
+
latest: highestInChannel(versions, channelOf(next ?? '')) ?? highestInChannel(versions, 'stable') ?? versions[versions.length - 1],
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
errors[name] = String(error?.message ?? error)
|
|
268
|
+
}
|
|
269
|
+
}))
|
|
270
|
+
this.cache = { checkedAt: Date.now(), packages, errors }
|
|
271
|
+
this.persist()
|
|
272
|
+
})()
|
|
273
|
+
try {
|
|
274
|
+
await this.pending
|
|
275
|
+
} finally {
|
|
276
|
+
this.pending = null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
persist() {
|
|
281
|
+
try {
|
|
282
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
283
|
+
const tmp = `${this.file}.tmp`
|
|
284
|
+
writeFileSync(tmp, `${JSON.stringify(this.cache, null, 2)}\n`)
|
|
285
|
+
renameSync(tmp, this.file)
|
|
286
|
+
} catch {
|
|
287
|
+
// a cache that cannot be written is still a usable in-memory cache
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The config tab's payload: installed vs latest for every tracked package,
|
|
293
|
+
* the runtime facts, and whether the cached registry answer is fresh.
|
|
294
|
+
* @param options - `port`, `dshHome`, and `force` to re-query the registry now.
|
|
295
|
+
*/
|
|
296
|
+
async describe({ port, dshHome, force }) {
|
|
297
|
+
try {
|
|
298
|
+
await this.ensure(TRACKED.map((entry) => entry.name), { force: force === true })
|
|
299
|
+
} catch {
|
|
300
|
+
// ensure() swallows per-package errors already; a throw here means the cache
|
|
301
|
+
// file itself is unwritable, which the report below still handles
|
|
302
|
+
}
|
|
303
|
+
const errors = this.cache.errors ?? {}
|
|
304
|
+
const packages = TRACKED.map((entry) => {
|
|
305
|
+
const installed = entry.id === 'dsh-token-use'
|
|
306
|
+
? readJson(new URL('../package.json', import.meta.url))?.version
|
|
307
|
+
: installedVersion(entry.name)
|
|
308
|
+
const record = this.cached(entry.name)
|
|
309
|
+
// Compare inside the channel the user actually runs: an rc install is not
|
|
310
|
+
// out of date just because an alpha exists.
|
|
311
|
+
const channel = channelOf(installed ?? record?.next ?? '')
|
|
312
|
+
const latest = record === undefined
|
|
313
|
+
? undefined
|
|
314
|
+
: highestInChannel(record.versions, channel) ?? record.latest
|
|
315
|
+
return {
|
|
316
|
+
id: entry.id,
|
|
317
|
+
label: entry.label,
|
|
318
|
+
name: entry.name,
|
|
319
|
+
installed: installed ?? null,
|
|
320
|
+
latest: latest ?? null,
|
|
321
|
+
channel,
|
|
322
|
+
tags: record?.tags ?? {},
|
|
323
|
+
status: statusOf(installed, latest),
|
|
324
|
+
error: errors[entry.name] ?? null,
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
return {
|
|
328
|
+
packages,
|
|
329
|
+
registry: this.registry,
|
|
330
|
+
checkedAt: typeof this.cache.checkedAt === 'number' ? this.cache.checkedAt : 0,
|
|
331
|
+
platform: describePlatform(port, dshHome),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Single-quote a shell word so paths with spaces survive. */
|
|
337
|
+
function shellQuote(value) {
|
|
338
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The detached restart script: wait for the response to land, stop the process
|
|
343
|
+
* that serves this request, then start the very same command again from the
|
|
344
|
+
* same directory. Relaunching by `argv` (not by a guessed CLI invocation)
|
|
345
|
+
* keeps custom flags, ports and working directories intact.
|
|
346
|
+
*
|
|
347
|
+
* @returns the script text, or undefined when the running command is unusable.
|
|
348
|
+
*/
|
|
349
|
+
export function restartScript({ pid, argv, cwd: workingDir, logFile }) {
|
|
350
|
+
const entry = argv[1]
|
|
351
|
+
const rest = argv.slice(2)
|
|
352
|
+
if (typeof entry !== 'string' || entry.length === 0 || typeof pid !== 'number') return undefined
|
|
353
|
+
const command = [process.execPath, entry, ...rest].map(shellQuote).join(' ')
|
|
354
|
+
return [
|
|
355
|
+
'#!/bin/sh',
|
|
356
|
+
'# Written by dsh-token-use: restart the dsh web service on demand.',
|
|
357
|
+
'sleep 2',
|
|
358
|
+
`kill -TERM ${pid} 2>/dev/null`,
|
|
359
|
+
'i=0',
|
|
360
|
+
`while kill -0 ${pid} 2>/dev/null && [ "$i" -lt 30 ]; do sleep 1; i=$((i+1)); done`,
|
|
361
|
+
`kill -9 ${pid} 2>/dev/null`,
|
|
362
|
+
'sleep 1',
|
|
363
|
+
`cd ${shellQuote(workingDir)} || exit 1`,
|
|
364
|
+
`nohup ${command} >>${shellQuote(logFile)} 2>&1 </dev/null &`,
|
|
365
|
+
'',
|
|
366
|
+
].join('\n')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Write and fire the restart script, fully detached so it outlives the server
|
|
371
|
+
* it is about to stop.
|
|
372
|
+
*
|
|
373
|
+
* @returns `{ script, logFile }` for the response, or undefined when the
|
|
374
|
+
* current command line cannot be relaunched safely.
|
|
375
|
+
*/
|
|
376
|
+
export function scheduleRestart(dshHome) {
|
|
377
|
+
const dir = join(dshHome, 'dsh-token-use')
|
|
378
|
+
const logFile = join(dir, 'restart.log')
|
|
379
|
+
const scriptFile = join(dir, 'restart.sh')
|
|
380
|
+
const script = restartScript({
|
|
381
|
+
pid: process.pid,
|
|
382
|
+
argv: process.argv,
|
|
383
|
+
cwd: process.cwd(),
|
|
384
|
+
logFile,
|
|
385
|
+
})
|
|
386
|
+
if (script === undefined) return undefined
|
|
387
|
+
try {
|
|
388
|
+
mkdirSync(dir, { recursive: true })
|
|
389
|
+
writeFileSync(scriptFile, script, { mode: 0o700 })
|
|
390
|
+
} catch (error) {
|
|
391
|
+
return { error: String(error?.message ?? error) }
|
|
392
|
+
}
|
|
393
|
+
const child = spawn('sh', [scriptFile], { detached: true, stdio: 'ignore' })
|
|
394
|
+
child.unref()
|
|
395
|
+
return { script: scriptFile, logFile }
|
|
396
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-token-use",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Real-time token usage and
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Real-time token usage and estimated spend for DeepSeek Harness, in a four-tab settings panel: an overview with cost/totals cards, a trend chart with a daily-average line and a 12-month usage heatmap; by-model and by-project tables with day/month/year/date-range queries; and versions, runtime facts, a harness self-check and a one-click restart. Prices come from DeepSeek's official page, refreshed daily, peak/off-peak aware. · 实时 Token 用量与估算金额面板,四个 tab:概览(金额/总量卡片、带日均线的趋势图、近 12 个月使用热力图)、模型统计与项目统计(按天/月/年/时间段/全部查询)、配置信息(版本对比、运行环境、Harness 自检、一键重启)。定价取 DeepSeek 官网、每日自动更新,区分高峰/空闲。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"dsh-plugin",
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"token",
|
|
10
10
|
"token-usage",
|
|
11
11
|
"usage-dashboard",
|
|
12
|
+
"cost",
|
|
13
|
+
"cost-estimation",
|
|
14
|
+
"heatmap",
|
|
12
15
|
"echarts"
|
|
13
16
|
],
|
|
14
17
|
"license": "MIT",
|
|
@@ -32,7 +35,8 @@
|
|
|
32
35
|
"files": [
|
|
33
36
|
"lib",
|
|
34
37
|
"client/client.js",
|
|
35
|
-
"cordis.patch.yml"
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"assets"
|
|
36
40
|
],
|
|
37
41
|
"engines": {
|
|
38
42
|
"node": ">=22.19"
|