dsh-token-use 0.2.2 → 0.3.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/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 +3 -2
package/cordis.patch.yml
CHANGED
|
@@ -12,3 +12,8 @@
|
|
|
12
12
|
enabled: true
|
|
13
13
|
currency: CNY
|
|
14
14
|
refreshHour: 12
|
|
15
|
+
# Version checks for the configuration tab read npm's packument every six
|
|
16
|
+
# hours; point `registry` at a mirror on a slow or blocked network.
|
|
17
|
+
versions:
|
|
18
|
+
registry: https://registry.npmjs.org
|
|
19
|
+
ttlMs: 21600000
|
package/lib/health.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness self-check: the failure modes a plugin install can leave behind.
|
|
3
|
+
*
|
|
4
|
+
* A broken profile rarely announces itself — the service starts, the panel
|
|
5
|
+
* loads, and one plugin is silently missing (its entry file never shipped, its
|
|
6
|
+
* bundle patch is gone, or two copies of cordis fight over the same hooks).
|
|
7
|
+
* This module walks the running profile on disk, so the panel can say which
|
|
8
|
+
* plugin is incomplete and why. What the *browser* can actually load is checked
|
|
9
|
+
* on the client side, where the boot manifest lives.
|
|
10
|
+
*
|
|
11
|
+
* The report is data, not prose: rows carry a `code`/`hint` the client
|
|
12
|
+
* translates, and `detail` holds only language-neutral facts (paths, versions).
|
|
13
|
+
*
|
|
14
|
+
* Everything here is read-only, and every probe is wrapped: a check that cannot
|
|
15
|
+
* run reports `unknown` rather than breaking the report.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-token-use/health
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import { compareVersions, installedVersion } from './versions.js'
|
|
22
|
+
|
|
23
|
+
/** A plugin's `dsh` block, tolerant of a missing or oddly shaped one. */
|
|
24
|
+
function dshBlock(pkg) {
|
|
25
|
+
const block = pkg?.dsh
|
|
26
|
+
return block !== null && typeof block === 'object' ? block : {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Worst-first status across rows. */
|
|
30
|
+
function worst(items, fallback = 'ok') {
|
|
31
|
+
if (items.length === 0) return fallback
|
|
32
|
+
if (items.some((item) => item.status === 'error')) return 'error'
|
|
33
|
+
if (items.some((item) => item.status === 'warn')) return 'warn'
|
|
34
|
+
if (items.every((item) => item.status === 'unknown')) return 'unknown'
|
|
35
|
+
return 'ok'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJson(file) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The profile whose dependency list names this plugin. */
|
|
47
|
+
export function findProfile(dshHome, name) {
|
|
48
|
+
const root = join(dshHome, 'profiles')
|
|
49
|
+
let entries
|
|
50
|
+
try {
|
|
51
|
+
entries = readdirSync(root)
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const dir = join(root, entry)
|
|
57
|
+
const pkg = readJson(join(dir, 'package.json'))
|
|
58
|
+
if (pkg === undefined) continue
|
|
59
|
+
const deps = Object.keys(pkg.dependencies ?? {})
|
|
60
|
+
const bundles = dshBlock(pkg).profile?.bundles ?? []
|
|
61
|
+
if (deps.includes(name) || bundles.includes(name)) return { name: entry, dir, pkg }
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One installed package inside the profile, symlinks resolved. */
|
|
67
|
+
function packageIn(profile, name) {
|
|
68
|
+
const link = join(profile.dir, 'node_modules', name)
|
|
69
|
+
const pkg = readJson(join(link, 'package.json'))
|
|
70
|
+
if (pkg === undefined) return undefined
|
|
71
|
+
let dir = link
|
|
72
|
+
try {
|
|
73
|
+
dir = realpathSync(link)
|
|
74
|
+
} catch {
|
|
75
|
+
// keep the un-resolved path: it exists, we just cannot canonicalise it
|
|
76
|
+
}
|
|
77
|
+
return { dir, pkg, linked: dir !== link }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The file a loader resolves for a subpath such as `.` or `./client`. */
|
|
81
|
+
function subpathEntry(dir, pkg, subpath) {
|
|
82
|
+
const exports = pkg.exports
|
|
83
|
+
const direct = typeof exports === 'string' && subpath === '.' ? exports : undefined
|
|
84
|
+
const field = typeof exports === 'object' && exports !== null ? exports[subpath] : undefined
|
|
85
|
+
const rel = direct
|
|
86
|
+
?? (typeof field === 'string' ? field : field?.default)
|
|
87
|
+
?? (subpath === '.' ? pkg.main : undefined)
|
|
88
|
+
if (typeof rel !== 'string' || rel.length === 0) return undefined
|
|
89
|
+
return join(dir, rel)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every `@deepseek-ai/cordis` copy reachable from `fromDir`, with its version. */
|
|
93
|
+
function cordisFrom(fromDir) {
|
|
94
|
+
let dir = fromDir
|
|
95
|
+
for (let i = 0; i < 12; i++) {
|
|
96
|
+
const file = join(dir, 'node_modules', '@deepseek-ai', 'cordis', 'package.json')
|
|
97
|
+
if (existsSync(file)) {
|
|
98
|
+
const pkg = readJson(file)
|
|
99
|
+
let real = file
|
|
100
|
+
try {
|
|
101
|
+
real = realpathSync(file)
|
|
102
|
+
} catch {
|
|
103
|
+
// keep the unresolved path
|
|
104
|
+
}
|
|
105
|
+
return { real, version: typeof pkg?.version === 'string' ? pkg.version : 'unknown' }
|
|
106
|
+
}
|
|
107
|
+
const parent = dirname(dir)
|
|
108
|
+
if (parent === dir) break
|
|
109
|
+
dir = parent
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Walk the profile that runs this plugin and report every plugin-level problem
|
|
116
|
+
* that could keep it out of the running service.
|
|
117
|
+
*
|
|
118
|
+
* @param options - `dshHome`, the plugin `name` and the running `commandVersion`.
|
|
119
|
+
* @returns `{ ok, profile, checks }` — checks group per-plugin rows.
|
|
120
|
+
*/
|
|
121
|
+
export function checkHarness({ dshHome, name, commandVersion }) {
|
|
122
|
+
const checks = []
|
|
123
|
+
const profile = findProfile(dshHome, name)
|
|
124
|
+
if (profile === undefined) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
profile: null,
|
|
128
|
+
// The summary already names the searched root, so no hint is needed here.
|
|
129
|
+
checks: [{ id: 'profile', status: 'error', hint: null, data: { root: join(dshHome, 'profiles') }, items: [] }],
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const integrity = []
|
|
134
|
+
const nodeModules = join(profile.dir, 'node_modules')
|
|
135
|
+
const hasModules = existsSync(nodeModules)
|
|
136
|
+
integrity.push({
|
|
137
|
+
name: 'node_modules',
|
|
138
|
+
status: hasModules ? 'ok' : 'error',
|
|
139
|
+
code: hasModules ? null : 'modules-missing',
|
|
140
|
+
detail: nodeModules,
|
|
141
|
+
hint: hasModules ? null : 'install',
|
|
142
|
+
})
|
|
143
|
+
for (const lock of ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) {
|
|
144
|
+
if (existsSync(join(profile.dir, lock))) integrity.push({ name: lock, status: 'ok', code: null, detail: lock })
|
|
145
|
+
}
|
|
146
|
+
const bundles = dshBlock(profile.pkg).profile?.bundles ?? []
|
|
147
|
+
integrity.push({
|
|
148
|
+
name: 'bundles',
|
|
149
|
+
status: bundles.length > 0 ? 'ok' : 'warn',
|
|
150
|
+
code: bundles.length > 0 ? null : 'bundles-empty',
|
|
151
|
+
detail: String(bundles.length),
|
|
152
|
+
hint: bundles.length > 0 ? null : 'bundles-empty',
|
|
153
|
+
})
|
|
154
|
+
checks.push({
|
|
155
|
+
id: 'profile',
|
|
156
|
+
status: worst(integrity),
|
|
157
|
+
data: { name: profile.name, dir: profile.dir },
|
|
158
|
+
items: integrity,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Per-plugin files on disk. Harness bundles (@deepseek-ai/…) are supplied by
|
|
162
|
+
// the service package rather than the profile, so they are not files to check.
|
|
163
|
+
const items = []
|
|
164
|
+
const cordisCopies = new Map()
|
|
165
|
+
const rememberCordis = (label, fromDir) => {
|
|
166
|
+
const found = cordisFrom(fromDir)
|
|
167
|
+
if (found === undefined) return
|
|
168
|
+
if (!cordisCopies.has(found.real)) cordisCopies.set(found.real, { version: found.version, owners: [] })
|
|
169
|
+
cordisCopies.get(found.real).owners.push(label)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const bundle of bundles) {
|
|
173
|
+
const found = packageIn(profile, bundle)
|
|
174
|
+
if (found === undefined) {
|
|
175
|
+
items.push(bundle.startsWith('@deepseek-ai/')
|
|
176
|
+
? { name: bundle, client: false, status: 'ok', code: 'provided', detail: '', hint: null }
|
|
177
|
+
: { name: bundle, client: false, status: 'error', code: 'not-installed', detail: '', hint: 'add' })
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
const problems = []
|
|
181
|
+
const entry = subpathEntry(found.dir, found.pkg, '.')
|
|
182
|
+
if (entry === undefined) problems.push({ code: 'entry-undeclared', detail: '', status: 'warn' })
|
|
183
|
+
else if (!existsSync(entry)) problems.push({ code: 'entry-missing', detail: entry, status: 'error' })
|
|
184
|
+
|
|
185
|
+
const patch = dshBlock(found.pkg).bundle?.patch
|
|
186
|
+
if (typeof patch === 'string' && !existsSync(join(found.dir, patch))) {
|
|
187
|
+
problems.push({ code: 'patch-missing', detail: patch, status: 'error' })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const declaresClient = dshBlock(found.pkg).client !== undefined
|
|
191
|
+
if (declaresClient) {
|
|
192
|
+
const clientFile = subpathEntry(found.dir, found.pkg, './client')
|
|
193
|
+
if (clientFile === undefined) problems.push({ code: 'client-undeclared', detail: '', status: 'warn' })
|
|
194
|
+
else if (!existsSync(clientFile)) problems.push({ code: 'client-missing', detail: clientFile, status: 'error' })
|
|
195
|
+
}
|
|
196
|
+
rememberCordis(bundle, found.dir)
|
|
197
|
+
items.push({
|
|
198
|
+
name: bundle,
|
|
199
|
+
client: declaresClient,
|
|
200
|
+
version: found.pkg.version,
|
|
201
|
+
status: worst(problems.map((problem) => ({ status: problem.status }))),
|
|
202
|
+
code: problems.length === 0 ? null : problems.map((problem) => problem.code).join('+'),
|
|
203
|
+
detail: problems.length === 0
|
|
204
|
+
? `v${found.pkg.version}${found.linked ? ' (linked)' : ''}`
|
|
205
|
+
: problems.map((problem) => problem.detail).filter((text) => text.length > 0).join('; '),
|
|
206
|
+
hint: problems.length === 0 ? null : 'reinstall',
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
checks.push({
|
|
210
|
+
id: 'plugins',
|
|
211
|
+
status: worst(items),
|
|
212
|
+
data: { total: items.length, ok: items.filter((item) => item.status === 'ok').length },
|
|
213
|
+
items,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const deps = Object.entries(profile.pkg.dependencies ?? {}).map(([dep, range]) => {
|
|
217
|
+
const found = packageIn(profile, dep)
|
|
218
|
+
if (found === undefined) {
|
|
219
|
+
return { name: dep, status: 'error', code: 'not-installed', detail: String(range), hint: 'install' }
|
|
220
|
+
}
|
|
221
|
+
return { name: dep, status: 'ok', code: null, detail: `v${found.pkg.version}${found.linked ? ' (linked)' : ''}` }
|
|
222
|
+
})
|
|
223
|
+
checks.push({ id: 'deps', status: worst(deps), data: { count: deps.length }, items: deps })
|
|
224
|
+
|
|
225
|
+
rememberCordis(profile.name, profile.dir)
|
|
226
|
+
const cordisItems = [...cordisCopies.entries()].map(([file, record]) => ({
|
|
227
|
+
name: record.owners.length === 2 && record.owners[0] === profile.name ? record.owners[1] : record.owners.join(', '),
|
|
228
|
+
status: 'ok',
|
|
229
|
+
code: null,
|
|
230
|
+
detail: `v${record.version} · ${dirname(dirname(file))}`,
|
|
231
|
+
}))
|
|
232
|
+
const cordisVersions = [...new Set([...cordisCopies.values()].map((record) => record.version))]
|
|
233
|
+
const cordisStatus = cordisVersions.length > 1 ? 'error' : cordisCopies.size > 1 ? 'warn' : 'ok'
|
|
234
|
+
checks.push({
|
|
235
|
+
id: 'cordis',
|
|
236
|
+
status: cordisStatus,
|
|
237
|
+
data: { copies: cordisCopies.size, versions: cordisVersions.length },
|
|
238
|
+
items: cordisItems,
|
|
239
|
+
hint: cordisStatus === 'ok' ? null : (cordisVersions.length > 1 ? 'cordis-mixed' : 'cordis-duplicate'),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const harness = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
|
|
243
|
+
const versionItems = harness.map((dep) => {
|
|
244
|
+
const installed = installedVersion(dep)
|
|
245
|
+
if (installed === undefined) return { name: dep, status: 'warn', code: 'unresolved', detail: '', hint: null }
|
|
246
|
+
const same = typeof commandVersion !== 'string' || commandVersion.length === 0 || compareVersions(installed, commandVersion) === 0
|
|
247
|
+
return {
|
|
248
|
+
name: dep,
|
|
249
|
+
status: same ? 'ok' : 'warn',
|
|
250
|
+
code: same ? null : 'service-mismatch',
|
|
251
|
+
detail: `v${installed}`,
|
|
252
|
+
hint: same ? null : 'service-version',
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
const own = installedVersion(name)
|
|
256
|
+
versionItems.push({
|
|
257
|
+
name,
|
|
258
|
+
status: own === undefined ? 'warn' : 'ok',
|
|
259
|
+
code: own === undefined ? 'unresolved' : null,
|
|
260
|
+
detail: own === undefined ? '' : `v${own}`,
|
|
261
|
+
})
|
|
262
|
+
const versionHint = worst(versionItems) === 'ok' ? null : 'service-version'
|
|
263
|
+
checks.push({
|
|
264
|
+
id: 'versions',
|
|
265
|
+
status: worst(versionItems),
|
|
266
|
+
data: { commandVersion: typeof commandVersion === 'string' ? commandVersion : null },
|
|
267
|
+
items: versionItems,
|
|
268
|
+
hint: versionHint,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
// `ok` means "nothing broken": a warning is information, not a failure.
|
|
273
|
+
ok: checks.every((check) => check.status !== 'error'),
|
|
274
|
+
profile: { name: profile.name, dir: profile.dir },
|
|
275
|
+
checks,
|
|
276
|
+
}
|
|
277
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* `pricing.js`), so each bucket carries an estimated amount alongside tokens.
|
|
12
12
|
*
|
|
13
13
|
* Queries: `GET /dsh-token-use` returns all-time buckets;
|
|
14
|
-
* `?day=YYYY-MM-DD
|
|
15
|
-
*
|
|
14
|
+
* `?day=YYYY-MM-DD`, `?month=YYYY-MM`, `?year=YYYY` and
|
|
15
|
+
* `?from=YYYY-MM-DD&to=YYYY-MM-DD` return the matching window, aggregated in
|
|
16
|
+
* memory from per-day buckets (no rescan, no disk).
|
|
16
17
|
*
|
|
17
18
|
* @module dsh-token-use
|
|
18
19
|
*/
|
|
@@ -23,6 +24,8 @@ import { join } from 'node:path'
|
|
|
23
24
|
import { zstdDecompressSync } from 'node:zlib'
|
|
24
25
|
import { scheduler } from 'node:timers/promises'
|
|
25
26
|
import { PricingStore } from './pricing.js'
|
|
27
|
+
import { VersionStore, installedVersion, restartScript, scheduleRestart } from './versions.js'
|
|
28
|
+
import { checkHarness } from './health.js'
|
|
26
29
|
|
|
27
30
|
export const name = 'dsh-token-use'
|
|
28
31
|
|
|
@@ -40,11 +43,11 @@ const USAGE_FIELDS = [
|
|
|
40
43
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
|
-
* Days of per-day buckets the snapshot carries
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* Days of per-day buckets the snapshot carries. The client slices this down to
|
|
47
|
+
* its 7/30/90-day trend switcher and draws the 12-month heatmap from the same
|
|
48
|
+
* series, so neither needs a second round trip.
|
|
46
49
|
*/
|
|
47
|
-
const TREND_DAYS =
|
|
50
|
+
const TREND_DAYS = 366
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* Auxiliary LLM calls that DeepSeek bills but that persist no `usage` record:
|
|
@@ -83,6 +86,7 @@ function pickSessionLog(entries) {
|
|
|
83
86
|
const ZSTD_MAGIC = 4247762216
|
|
84
87
|
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
85
88
|
const MONTH_RE = /^\d{4}-\d{2}$/
|
|
89
|
+
const YEAR_RE = /^\d{4}$/
|
|
86
90
|
|
|
87
91
|
function resolveDshHome(env = process.env) {
|
|
88
92
|
const explicit = env.DSH_HOME
|
|
@@ -251,6 +255,7 @@ export class UsageTracker {
|
|
|
251
255
|
constructor(dshHome = resolveDshHome(), options = {}) {
|
|
252
256
|
this.dshHome = dshHome
|
|
253
257
|
this.allowRemote = options.allowRemote === true
|
|
258
|
+
this.endpoint = typeof options.endpoint === 'string' && options.endpoint.length > 0 ? options.endpoint : '/dsh-token-use'
|
|
254
259
|
this.pricing = options.pricing ?? new PricingStore(dshHome, options.pricingOptions ?? {})
|
|
255
260
|
this.startedAt = Date.now()
|
|
256
261
|
this.totals = emptyBucket()
|
|
@@ -619,6 +624,16 @@ export class UsageTracker {
|
|
|
619
624
|
return [...this.byModel.keys()].sort()
|
|
620
625
|
}
|
|
621
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Years that own at least one day bucket, oldest first: the year picker lists
|
|
629
|
+
* these instead of asking for a free-text year that may hold no data.
|
|
630
|
+
*/
|
|
631
|
+
years() {
|
|
632
|
+
const years = new Set()
|
|
633
|
+
for (const day of this.byDay.keys()) years.add(day.slice(0, 4))
|
|
634
|
+
return [...years].sort()
|
|
635
|
+
}
|
|
636
|
+
|
|
622
637
|
/**
|
|
623
638
|
* Materialize totals/breakdowns for the optional window + model filter.
|
|
624
639
|
* Filtering only re-aggregates existing per-day buckets — no rescan, no disk.
|
|
@@ -646,7 +661,10 @@ export class UsageTracker {
|
|
|
646
661
|
const allDays = [...new Set([...this.byDay.keys(), ...this.dayAuxiliary.keys()])].sort()
|
|
647
662
|
const days = filter.kind === 'all'
|
|
648
663
|
? allDays
|
|
649
|
-
:
|
|
664
|
+
: filter.kind === 'range'
|
|
665
|
+
? allDays.filter((day) => day >= filter.from && day <= filter.to)
|
|
666
|
+
// day is an exact match; month (YYYY-MM) and year (YYYY) are prefixes.
|
|
667
|
+
: allDays.filter((day) => (filter.kind === 'day' ? day === filter.value : day.startsWith(filter.value)))
|
|
650
668
|
const totals = emptyBucket()
|
|
651
669
|
const models = new Map()
|
|
652
670
|
const projects = new Map()
|
|
@@ -668,7 +686,7 @@ export class UsageTracker {
|
|
|
668
686
|
const lastDataDay = days.length > 0 ? days[days.length - 1] : undefined
|
|
669
687
|
const trendEnd = filter.kind === 'day'
|
|
670
688
|
? filter.value
|
|
671
|
-
: (lastDataDay ?? localDay(Date.now()))
|
|
689
|
+
: (lastDataDay ?? (filter.kind === 'range' ? filter.to : localDay(Date.now())))
|
|
672
690
|
const trendStart = shiftDay(trendEnd, -(TREND_DAYS - 1))
|
|
673
691
|
for (let day = trendStart; day <= trendEnd; day = shiftDay(day, 1)) {
|
|
674
692
|
const bucket = model === undefined ? this.byDay.get(day) : this.dayModel.get(day)?.get(model)
|
|
@@ -704,8 +722,15 @@ export class UsageTracker {
|
|
|
704
722
|
startedAt: this.startedAt,
|
|
705
723
|
updatedAt: now,
|
|
706
724
|
dshHome: this.dshHome,
|
|
707
|
-
filter: filter === null ? null : {
|
|
725
|
+
filter: filter === null ? null : {
|
|
726
|
+
kind: filter.kind,
|
|
727
|
+
value: filter.value ?? null,
|
|
728
|
+
from: filter.from ?? null,
|
|
729
|
+
to: filter.to ?? null,
|
|
730
|
+
model: filter.model ?? null,
|
|
731
|
+
},
|
|
708
732
|
models: this.models(),
|
|
733
|
+
years: this.years(),
|
|
709
734
|
modelPricing,
|
|
710
735
|
pricing: this.pricing.describe(now),
|
|
711
736
|
auxiliary: view.auxiliary,
|
|
@@ -722,6 +747,122 @@ export class UsageTracker {
|
|
|
722
747
|
}
|
|
723
748
|
}
|
|
724
749
|
|
|
750
|
+
/** Loopback, or a same-origin request when `allowRemote` was opted into. */
|
|
751
|
+
trusted(req) {
|
|
752
|
+
if (LOOPBACK.has(req.socket?.remoteAddress)) return true
|
|
753
|
+
const origin = req.headers.origin
|
|
754
|
+
const sameOrigin = req.headers['sec-fetch-site'] === 'same-origin'
|
|
755
|
+
&& typeof origin === 'string'
|
|
756
|
+
&& typeof req.headers.host === 'string'
|
|
757
|
+
let trusted = false
|
|
758
|
+
if (sameOrigin) {
|
|
759
|
+
try {
|
|
760
|
+
trusted = new URL(origin).host === req.headers.host
|
|
761
|
+
} catch {
|
|
762
|
+
trusted = false
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return this.allowRemote && trusted
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
deny(res) {
|
|
769
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
770
|
+
res.end(JSON.stringify({ ok: false, error: 'loopback only; set allowRemote for trusted same-origin access' }))
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Config tab payload: installed vs latest versions for the harness packages,
|
|
775
|
+
* the runtime facts, and the restart capability of this launch command.
|
|
776
|
+
*/
|
|
777
|
+
async handleConfig(req, res, versions) {
|
|
778
|
+
res.setHeader('cache-control', 'no-store')
|
|
779
|
+
if (req.method !== 'GET') {
|
|
780
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
781
|
+
res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
if (!this.trusted(req)) {
|
|
785
|
+
this.deny(res)
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
const host = req.headers.host
|
|
789
|
+
const port = typeof host === 'string' && host.includes(':') ? host.slice(host.lastIndexOf(':') + 1) : null
|
|
790
|
+
// `?refresh=1` is the panel's re-check button: it skips the six-hour cache.
|
|
791
|
+
const force = new URL(req.url, 'http://localhost').searchParams.get('refresh') === '1'
|
|
792
|
+
const report = await versions.describe({ port, dshHome: this.dshHome, force })
|
|
793
|
+
const planned = restartScript({ pid: process.pid, argv: process.argv, cwd: process.cwd(), logFile: 'probe' })
|
|
794
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
795
|
+
res.end(JSON.stringify({
|
|
796
|
+
ok: true,
|
|
797
|
+
name,
|
|
798
|
+
version: VERSION,
|
|
799
|
+
endpoint: this.endpoint,
|
|
800
|
+
restartable: planned !== undefined,
|
|
801
|
+
...report,
|
|
802
|
+
}))
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Harness self-check: what the browser would load, plugin by plugin, plus the
|
|
807
|
+
* profile's own integrity — the failures that leave a plugin installed but
|
|
808
|
+
* never running.
|
|
809
|
+
*/
|
|
810
|
+
async handleHealth(req, res) {
|
|
811
|
+
res.setHeader('cache-control', 'no-store')
|
|
812
|
+
if (req.method !== 'GET') {
|
|
813
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
814
|
+
res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
if (!this.trusted(req)) {
|
|
818
|
+
this.deny(res)
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
const host = req.headers.host
|
|
822
|
+
const port = typeof host === 'string' && host.includes(':') ? host.slice(host.lastIndexOf(':') + 1) : null
|
|
823
|
+
let report
|
|
824
|
+
try {
|
|
825
|
+
report = checkHarness({
|
|
826
|
+
dshHome: this.dshHome,
|
|
827
|
+
name,
|
|
828
|
+
commandVersion: installedVersion('@deepseek-ai/dsh'),
|
|
829
|
+
})
|
|
830
|
+
} catch (error) {
|
|
831
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
832
|
+
res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }))
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
836
|
+
res.end(JSON.stringify({ ok: true, ...report }))
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** One-click restart: schedule the detached script and answer immediately. */
|
|
840
|
+
handleRestart(req, res) {
|
|
841
|
+
res.setHeader('cache-control', 'no-store')
|
|
842
|
+
if (req.method !== 'POST') {
|
|
843
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
844
|
+
res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
if (!this.trusted(req)) {
|
|
848
|
+
this.deny(res)
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
const scheduled = scheduleRestart(this.dshHome)
|
|
852
|
+
if (scheduled === undefined) {
|
|
853
|
+
res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
|
|
854
|
+
res.end(JSON.stringify({ ok: false, error: 'this launch command cannot be restarted automatically' }))
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
if (scheduled.error !== undefined) {
|
|
858
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
859
|
+
res.end(JSON.stringify({ ok: false, error: scheduled.error }))
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
863
|
+
res.end(JSON.stringify({ ok: true, delaySec: 2, logFile: scheduled.logFile, script: scheduled.script }))
|
|
864
|
+
}
|
|
865
|
+
|
|
725
866
|
handle(req, res) {
|
|
726
867
|
res.setHeader('cache-control', 'no-store')
|
|
727
868
|
if (req.method !== 'GET') {
|
|
@@ -729,50 +870,59 @@ export class UsageTracker {
|
|
|
729
870
|
res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
|
|
730
871
|
return
|
|
731
872
|
}
|
|
732
|
-
if (!
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
&& typeof origin === 'string'
|
|
736
|
-
&& typeof req.headers.host === 'string'
|
|
737
|
-
let trusted = false
|
|
738
|
-
if (sameOrigin) {
|
|
739
|
-
try {
|
|
740
|
-
trusted = new URL(origin).host === req.headers.host
|
|
741
|
-
} catch {
|
|
742
|
-
trusted = false
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
if (!(this.allowRemote && trusted)) {
|
|
746
|
-
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
747
|
-
res.end(JSON.stringify({ ok: false, error: 'loopback only; set allowRemote for trusted same-origin access' }))
|
|
748
|
-
return
|
|
749
|
-
}
|
|
873
|
+
if (!this.trusted(req)) {
|
|
874
|
+
this.deny(res)
|
|
875
|
+
return
|
|
750
876
|
}
|
|
751
877
|
const params = new URL(req.url, 'http://localhost').searchParams
|
|
752
878
|
const day = params.get('day')
|
|
753
879
|
const month = params.get('month')
|
|
880
|
+
const year = params.get('year')
|
|
881
|
+
const from = params.get('from')
|
|
882
|
+
const to = params.get('to')
|
|
754
883
|
const rawModel = params.get('model')
|
|
755
884
|
const model = rawModel !== null && rawModel.length > 0 && rawModel.length <= 200 ? rawModel : undefined
|
|
756
|
-
|
|
757
|
-
if (day !== null && month !== null) {
|
|
885
|
+
const reject = (error) => {
|
|
758
886
|
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
759
|
-
res.end(JSON.stringify({ ok: false, error
|
|
887
|
+
res.end(JSON.stringify({ ok: false, error }))
|
|
888
|
+
}
|
|
889
|
+
let filter = null
|
|
890
|
+
if ((from === null) !== (to === null)) {
|
|
891
|
+
reject('from and to must be used together')
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
if ([day !== null, month !== null, year !== null, from !== null].filter(Boolean).length > 1) {
|
|
895
|
+
reject('use exactly one of day, month, year or from+to')
|
|
760
896
|
return
|
|
761
897
|
}
|
|
762
898
|
if (day !== null) {
|
|
763
899
|
if (!DAY_RE.test(day)) {
|
|
764
|
-
|
|
765
|
-
res.end(JSON.stringify({ ok: false, error: 'day must be YYYY-MM-DD' }))
|
|
900
|
+
reject('day must be YYYY-MM-DD')
|
|
766
901
|
return
|
|
767
902
|
}
|
|
768
903
|
filter = { kind: 'day', value: day, model }
|
|
769
904
|
} else if (month !== null) {
|
|
770
905
|
if (!MONTH_RE.test(month)) {
|
|
771
|
-
|
|
772
|
-
res.end(JSON.stringify({ ok: false, error: 'month must be YYYY-MM' }))
|
|
906
|
+
reject('month must be YYYY-MM')
|
|
773
907
|
return
|
|
774
908
|
}
|
|
775
909
|
filter = { kind: 'month', value: month, model }
|
|
910
|
+
} else if (year !== null) {
|
|
911
|
+
if (!YEAR_RE.test(year)) {
|
|
912
|
+
reject('year must be YYYY')
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
filter = { kind: 'year', value: year, model }
|
|
916
|
+
} else if (from !== null) {
|
|
917
|
+
if (!DAY_RE.test(from) || !DAY_RE.test(to)) {
|
|
918
|
+
reject('from and to must be YYYY-MM-DD')
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
if (from > to) {
|
|
922
|
+
reject('from must not be after to')
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
filter = { kind: 'range', from, to, model }
|
|
776
926
|
} else if (model !== undefined) {
|
|
777
927
|
filter = { kind: 'all', value: undefined, model }
|
|
778
928
|
}
|
|
@@ -784,8 +934,9 @@ export class UsageTracker {
|
|
|
784
934
|
/**
|
|
785
935
|
* Register the tracker once the profile's webServer service exists.
|
|
786
936
|
* @param ctx - host context from the cordis loader.
|
|
787
|
-
* @param config - loader config: `endpoint`, `scanAtBoot`, `allowRemote
|
|
788
|
-
* `pricing` (`enabled`, `currency`, `refreshHour`, `url`, `timeoutMs`)
|
|
937
|
+
* @param config - loader config: `endpoint`, `scanAtBoot`, `allowRemote`,
|
|
938
|
+
* `pricing` (`enabled`, `currency`, `refreshHour`, `url`, `timeoutMs`) and
|
|
939
|
+
* `versions` (`registry`, `timeoutMs`, `ttlMs`).
|
|
789
940
|
*/
|
|
790
941
|
export function apply(ctx, config = {}) {
|
|
791
942
|
const endpoint = typeof config.endpoint === 'string' && config.endpoint.length > 0 ? config.endpoint : '/dsh-token-use'
|
|
@@ -793,7 +944,8 @@ export function apply(ctx, config = {}) {
|
|
|
793
944
|
ctx.inject(['webServer'], (host) => {
|
|
794
945
|
const dshHome = resolveDshHome()
|
|
795
946
|
const pricing = new PricingStore(dshHome, config.pricing ?? {})
|
|
796
|
-
const
|
|
947
|
+
const versions = new VersionStore(dshHome, config.versions ?? {})
|
|
948
|
+
const tracker = new UsageTracker(dshHome, { allowRemote: config.allowRemote === true, pricing, endpoint })
|
|
797
949
|
host.effect(() => {
|
|
798
950
|
pricing.start()
|
|
799
951
|
return () => pricing.stop()
|
|
@@ -802,13 +954,17 @@ export function apply(ctx, config = {}) {
|
|
|
802
954
|
tracker.fold(session, event)
|
|
803
955
|
}, { global: true })
|
|
804
956
|
host.effect(() => {
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
path: endpoint,
|
|
808
|
-
handler: (req, res) => tracker.
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
957
|
+
const routes = [
|
|
958
|
+
{ path: endpoint, handler: (req, res) => tracker.handle(req, res) },
|
|
959
|
+
{ path: `${endpoint}/config`, handler: (req, res) => tracker.handleConfig(req, res, versions) },
|
|
960
|
+
{ path: `${endpoint}/health`, handler: (req, res) => tracker.handleHealth(req, res) },
|
|
961
|
+
{ path: `${endpoint}/restart`, handler: (req, res) => tracker.handleRestart(req, res) },
|
|
962
|
+
]
|
|
963
|
+
const disposers = routes.map((route) => host.webServer.register({ kind: 'exact', ...route }))
|
|
964
|
+
return () => {
|
|
965
|
+
for (const dispose of disposers) dispose()
|
|
966
|
+
}
|
|
967
|
+
}, 'dsh-token-use: http routes')
|
|
812
968
|
if (scanAtBoot) {
|
|
813
969
|
tracker.scanSessions().catch((error) => {
|
|
814
970
|
tracker.scan.error = String(error?.stack ?? error)
|