@goodandready/dsh-cost-meter 0.8.1 → 0.8.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 +18 -2
- package/{docs/README.ru.md → README.ru.md} +11 -1
- package/{docs/README.zh.md → README.zh.md} +11 -1
- package/lib/client.js +427 -176
- package/lib/index.js +43 -1
- package/lib/updater.js +270 -0
- package/package.json +7 -4
- package/docs/deployment/public-release.md +0 -9
- package/docs/design/DESIGN.md +0 -111
- package/docs/plans/findings.md +0 -17
- package/docs/plans/progress.md +0 -12
- package/docs/plans/task_plan.md +0 -31
- package/docs/testing/projection-lifecycle.md +0 -28
- package/docs/testing/public-route.md +0 -12
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerPluginUpdater } from './updater.js'
|
|
1
2
|
// dsh-cost-meter — host half.
|
|
2
3
|
//
|
|
3
4
|
// The harness prices nothing on purpose: pi-ai carries a `cost` field but
|
|
@@ -20,12 +21,38 @@ import { dirname, join } from 'node:path'
|
|
|
20
21
|
import z from '@deepseek-ai/schemastery'
|
|
21
22
|
import { z as zod } from 'zod'
|
|
22
23
|
|
|
23
|
-
export const name = 'dsh-cost-meter'
|
|
24
|
+
export const name = '@goodandready/dsh-cost-meter'
|
|
24
25
|
export const inject = ['webServer']
|
|
25
26
|
|
|
26
27
|
const NS = 'dsh-cost-meter'
|
|
27
28
|
const STATE_PATH = '/dsh-cost-meter/state'
|
|
28
29
|
const REFRESH_PATH = '/dsh-cost-meter/refresh'
|
|
30
|
+
export function isLoopback(value) {
|
|
31
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
32
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
33
|
+
|| address?.startsWith('127.') === true
|
|
34
|
+
|| address?.startsWith('::ffff:127.') === true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isTrustedCaller(request) {
|
|
38
|
+
const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress
|
|
39
|
+
if (isLoopback(remote)) return true
|
|
40
|
+
const site = request?.headers?.['sec-fetch-site']
|
|
41
|
+
if (site === 'same-origin' || site === 'same-site') return true
|
|
42
|
+
if (site === 'cross-site') return false
|
|
43
|
+
const origin = request?.headers?.['origin']
|
|
44
|
+
const host = request?.headers?.['host']
|
|
45
|
+
if (origin && host) {
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(origin)
|
|
48
|
+
if (url.host === host) return true
|
|
49
|
+
} catch (e) {
|
|
50
|
+
/* invalid url */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
29
56
|
const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
|
|
30
57
|
const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
|
|
31
58
|
|
|
@@ -532,6 +559,11 @@ export function apply(ctx, config) {
|
|
|
532
559
|
res.end(JSON.stringify({ error: 'method not allowed' }))
|
|
533
560
|
return
|
|
534
561
|
}
|
|
562
|
+
if (!isTrustedCaller(req)) {
|
|
563
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
564
|
+
res.end(JSON.stringify({ error: 'forbidden source origin' }))
|
|
565
|
+
return
|
|
566
|
+
}
|
|
535
567
|
const now = Date.now()
|
|
536
568
|
const COOLDOWN_MS = 30000
|
|
537
569
|
if (now - lastManualRefreshAt < COOLDOWN_MS) {
|
|
@@ -627,4 +659,14 @@ export function apply(ctx, config) {
|
|
|
627
659
|
}),
|
|
628
660
|
'dsh-cost-meter: state route',
|
|
629
661
|
)
|
|
662
|
+
|
|
663
|
+
// Route 3: /dsh-cost-meter/update (in-app one-click updater)
|
|
664
|
+
if (ctx.webServer?.register) {
|
|
665
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
666
|
+
endpoint: '/dsh-cost-meter/update',
|
|
667
|
+
packageName: name,
|
|
668
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
669
|
+
}), 'dsh-cost-meter: updater')
|
|
670
|
+
}
|
|
671
|
+
|
|
630
672
|
}
|
package/lib/updater.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update'
|
|
9
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000
|
|
10
|
+
const VERSION_CACHE_MS = 5 * 60_000
|
|
11
|
+
let latestCache = undefined
|
|
12
|
+
|
|
13
|
+
function header(request, name) {
|
|
14
|
+
const value = request?.headers?.[name]
|
|
15
|
+
return Array.isArray(value) ? value[0] : value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isLoopback(value) {
|
|
19
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
20
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
21
|
+
|| address?.startsWith('127.') === true
|
|
22
|
+
|| address?.startsWith('::ffff:127.') === true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isTrustedUpdateRequest(request) {
|
|
26
|
+
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
27
|
+
if (!isLoopback(request?.socket?.remoteAddress)) return false
|
|
28
|
+
const site = header(request, 'sec-fetch-site')
|
|
29
|
+
if (site !== undefined && site !== 'same-origin') return false
|
|
30
|
+
const origin = header(request, 'origin')
|
|
31
|
+
const host = header(request, 'host')
|
|
32
|
+
if (origin === undefined || host === undefined) return false
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(origin)
|
|
35
|
+
return (url.protocol === 'http:' || url.protocol === 'https:')
|
|
36
|
+
&& isLoopback(url.hostname) && url.host === host
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validProfileName(value) {
|
|
43
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
|
|
44
|
+
&& !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function profileNameFromArgv(argv) {
|
|
48
|
+
if (!Array.isArray(argv)) return undefined
|
|
49
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
50
|
+
if (argv[index] === '--profile') return argv[index + 1]
|
|
51
|
+
if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
|
|
52
|
+
}
|
|
53
|
+
return argv[2] === 'web' ? 'web' : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findDshCliEntry() {
|
|
57
|
+
const value = process.argv[1]
|
|
58
|
+
if (typeof value !== 'string' || value.length === 0) return undefined
|
|
59
|
+
const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
|
|
60
|
+
if (!existsSync(entry)) return undefined
|
|
61
|
+
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
62
|
+
const manifestPath = resolve(directory, 'package.json')
|
|
63
|
+
if (existsSync(manifestPath)) {
|
|
64
|
+
try {
|
|
65
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
66
|
+
const bin = typeof manifest.bin === 'string'
|
|
67
|
+
? manifest.bin
|
|
68
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
69
|
+
? manifest.bin.dsh
|
|
70
|
+
: undefined
|
|
71
|
+
if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
|
|
72
|
+
&& !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
|
|
73
|
+
} catch (e) {
|
|
74
|
+
/* continue */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const parent = dirname(directory)
|
|
78
|
+
if (parent === directory) return undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function detectRuntime() {
|
|
83
|
+
const profileDir = resolve(process.env.DSH_PROFILE_DIR
|
|
84
|
+
?? resolve(homedir(), '.dsh', 'profiles', 'web'))
|
|
85
|
+
const selected = profileNameFromArgv(process.argv)
|
|
86
|
+
const profileName = validProfileName(selected)
|
|
87
|
+
? selected
|
|
88
|
+
: validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
|
|
89
|
+
const cliEntry = findDshCliEntry()
|
|
90
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseSemver(value) {
|
|
94
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || ''))
|
|
95
|
+
if (match === null) return undefined
|
|
96
|
+
return {
|
|
97
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
98
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function comparePrerelease(candidateParts, currentParts) {
|
|
103
|
+
if (candidateParts.length === 0 && currentParts.length > 0) return 1
|
|
104
|
+
if (candidateParts.length > 0 && currentParts.length === 0) return -1
|
|
105
|
+
if (candidateParts.length === 0 && currentParts.length === 0) return 0
|
|
106
|
+
|
|
107
|
+
const len = Math.max(candidateParts.length, currentParts.length)
|
|
108
|
+
for (let i = 0; i < len; i += 1) {
|
|
109
|
+
const a = candidateParts[i]
|
|
110
|
+
const b = currentParts[i]
|
|
111
|
+
if (a === undefined) return -1
|
|
112
|
+
if (b === undefined) return 1
|
|
113
|
+
if (a === b) continue
|
|
114
|
+
|
|
115
|
+
const aNum = /^\d+$/.test(a) ? Number(a) : undefined
|
|
116
|
+
const bNum = /^\d+$/.test(b) ? Number(b) : undefined
|
|
117
|
+
|
|
118
|
+
if (aNum !== undefined && bNum !== undefined) {
|
|
119
|
+
return aNum > bNum ? 1 : -1
|
|
120
|
+
}
|
|
121
|
+
if (aNum !== undefined && bNum === undefined) {
|
|
122
|
+
return -1
|
|
123
|
+
}
|
|
124
|
+
if (aNum === undefined && bNum !== undefined) {
|
|
125
|
+
return 1
|
|
126
|
+
}
|
|
127
|
+
return a.localeCompare(b) > 0 ? 1 : -1
|
|
128
|
+
}
|
|
129
|
+
return 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
133
|
+
const current = parseSemver(currentValue)
|
|
134
|
+
const candidate = parseSemver(candidateValue)
|
|
135
|
+
if (current === undefined || candidate === undefined) return false
|
|
136
|
+
for (let index = 0; index < 3; index += 1) {
|
|
137
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
|
|
138
|
+
}
|
|
139
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function fetchLatestVersion(packageName, registry) {
|
|
143
|
+
if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
|
|
144
|
+
return latestCache.version
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const reg = registry.replace(/\/+$/, '')
|
|
148
|
+
const response = await fetch(`${reg}/${encodeURIComponent(packageName)}/latest`, {
|
|
149
|
+
signal: AbortSignal.timeout(6000),
|
|
150
|
+
})
|
|
151
|
+
if (!response.ok) return undefined
|
|
152
|
+
const value = await response.json()
|
|
153
|
+
if (typeof value?.version !== 'string' || !value.version) return undefined
|
|
154
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
|
|
155
|
+
return value.version
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readCurrentVersion(manifestUrl) {
|
|
162
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
|
|
163
|
+
if (typeof value?.version !== 'string' || !value.version) throw new Error('Cannot read current plugin version.')
|
|
164
|
+
return value.version
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function checkUpdateStatus(options, target = detectRuntime()) {
|
|
168
|
+
const current = await readCurrentVersion(options.manifestUrl)
|
|
169
|
+
const latest = await fetchLatestVersion(options.packageName, options.registry || 'https://registry.npmjs.org')
|
|
170
|
+
return {
|
|
171
|
+
packageName: options.packageName,
|
|
172
|
+
currentVersion: current,
|
|
173
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
174
|
+
latestCheckFailed: latest === undefined,
|
|
175
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
176
|
+
profileName: target.profileName,
|
|
177
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function installExact(target, packageSpec, options) {
|
|
182
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
|
|
183
|
+
await new Promise((resolvePromise, reject) => {
|
|
184
|
+
const child = spawn(process.execPath, [
|
|
185
|
+
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
186
|
+
packageSpec,
|
|
187
|
+
`--registry=${options.registry || 'https://registry.npmjs.org/'}`,
|
|
188
|
+
], {
|
|
189
|
+
cwd: target.profileDir,
|
|
190
|
+
windowsHide: true,
|
|
191
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
192
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
193
|
+
})
|
|
194
|
+
let detail = ''
|
|
195
|
+
child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
|
|
196
|
+
child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
child.kill()
|
|
199
|
+
reject(new Error('Update timed out.'))
|
|
200
|
+
}, UPDATE_TIMEOUT_MS)
|
|
201
|
+
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
|
202
|
+
child.once('exit', (code) => {
|
|
203
|
+
clearTimeout(timer)
|
|
204
|
+
if (code === 0) resolvePromise()
|
|
205
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function writeResponseJson(response, statusCode, value) {
|
|
211
|
+
response.writeHead(statusCode, {
|
|
212
|
+
'content-type': 'application/json; charset=utf-8',
|
|
213
|
+
'cache-control': 'no-store',
|
|
214
|
+
})
|
|
215
|
+
response.end(JSON.stringify(value))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function registerPluginUpdater(ctx, options) {
|
|
219
|
+
if (!ctx?.webServer?.register) return () => {}
|
|
220
|
+
let installing = false
|
|
221
|
+
return ctx.webServer.register({
|
|
222
|
+
kind: 'exact',
|
|
223
|
+
path: options.endpoint,
|
|
224
|
+
handler: async (request, response) => {
|
|
225
|
+
try {
|
|
226
|
+
const target = detectRuntime()
|
|
227
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
228
|
+
const payload = await checkUpdateStatus(options, target)
|
|
229
|
+
writeResponseJson(response, 200, payload)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (request.method !== 'POST') {
|
|
233
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' })
|
|
234
|
+
response.end()
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
238
|
+
writeResponseJson(response, 403, { ok: false, error: 'Rejected non-local or cross-origin update request.' })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (installing) {
|
|
242
|
+
writeResponseJson(response, 409, { ok: false, error: 'This plugin is already updating.' })
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
installing = true
|
|
246
|
+
try {
|
|
247
|
+
const before = await checkUpdateStatus(options, target)
|
|
248
|
+
if (before.latestVersion === undefined) {
|
|
249
|
+
writeResponseJson(response, 503, { ok: false, error: 'The latest version is temporarily unavailable.' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (!before.updateAvailable) {
|
|
253
|
+
writeResponseJson(response, 200, before)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
|
|
257
|
+
writeResponseJson(response, 200, {
|
|
258
|
+
...before,
|
|
259
|
+
updatedVersion: before.latestVersion,
|
|
260
|
+
restartRequired: true,
|
|
261
|
+
})
|
|
262
|
+
} finally {
|
|
263
|
+
installing = false
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
writeResponseJson(response, 500, { ok: false, error: String(error?.message || error) })
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cost-meter",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "DeepSeek Harness plugin: a session cost chip in the conversation header - live token usage by bucket, peak/off-peak tariff, and a countdown to the next tariff switch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
"./package.json": "./package.json"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"lib",
|
|
14
|
+
"lib/",
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"README.md",
|
|
17
|
-
"
|
|
17
|
+
"README.ru.md",
|
|
18
|
+
"README.zh.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
20
21
|
"publishConfig": {
|
|
@@ -26,7 +27,9 @@
|
|
|
26
27
|
},
|
|
27
28
|
"client": {
|
|
28
29
|
"platform": "web",
|
|
29
|
-
"inject": [
|
|
30
|
+
"inject": [
|
|
31
|
+
"@deepseek-ai/dsh-client-locale"
|
|
32
|
+
]
|
|
30
33
|
}
|
|
31
34
|
},
|
|
32
35
|
"keywords": [
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# Публичный релиз и deployment
|
|
2
|
-
|
|
3
|
-
Релизная версия повышается patch-изменением (v0.7.6). После merge в `main`
|
|
4
|
-
создаются annotated tag, пакет в npm (`@goodandready/dsh-cost-meter`) и
|
|
5
|
-
публичный GitHub release в `GooDAnDReaDY/dsh-cost-meter`.
|
|
6
|
-
|
|
7
|
-
Production должен содержать exact package version из npm и не должен иметь
|
|
8
|
-
локальной зависимости на исходный checkout. После restart проверяются
|
|
9
|
-
service health, browser loader и `GET /dsh-cost-meter/state`.
|
package/docs/design/DESIGN.md
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# DESIGN.md — dsh-cost-meter
|
|
2
|
-
|
|
3
|
-
## Product / Purpose
|
|
4
|
-
- Назначение: Отображение стоимости активной сессии диалога в реальном времени, тарифов (пик / не-пик / единый) за 1M токенов, обратного отсчёта до смены окна, прозрачной детализации расходов по каждой задействованной модели и нативного управления конфигурацией плагина.
|
|
5
|
-
- Аудитория: Пользователи и инженеры DeepSeek Harness, контролирующие потребление токенов и бюджет на LLM-провайдеров.
|
|
6
|
-
- Статус: Активная публичная поставка (@goodandready/dsh-cost-meter).
|
|
7
|
-
|
|
8
|
-
## User Surfaces
|
|
9
|
-
- Web/UI:
|
|
10
|
-
- Компактный чип в слоте `conversation.session.header.utilities` (order: 10).
|
|
11
|
-
- Выпадающая панель детализации (width: 320px, absolute positioning, click-outside dismissal).
|
|
12
|
-
- Карточка настроек плагина в `settings.plugin.item` (слот `settings.plugin.item` с fallback на `settings.section`).
|
|
13
|
-
- DSH UI / settings / slots:
|
|
14
|
-
- Слот чипа: `conversation.session.header.utilities`.
|
|
15
|
-
- Слот карточки настроек: `settings.plugin.item`.
|
|
16
|
-
- Настройки: реестр DSH `settings.register("dsh-cost-meter", Config)` (валюта `currency`, множитель курса `usdRate`, таймзона `displayTimeZone`, опрос OpenRouter `useOpenRouter`, ручные ставки `prices`, сопоставление `modelMap`).
|
|
17
|
-
- Локализация: регистрация словарей через `ctx.locale.register("dsh-cost-meter", { en, ru })` с поддержкой переключения языков через `useActiveLocale(ctx)`.
|
|
18
|
-
- API:
|
|
19
|
-
- HTTP `GET /dsh-cost-meter/state` (параметр `?models=...` для получения тарифов по задействованным моделям).
|
|
20
|
-
- CLI:
|
|
21
|
-
- Управление через CLI харнесса: `dsh plugin --profile web add @goodandready/dsh-cost-meter`.
|
|
22
|
-
- Документация:
|
|
23
|
-
- README.md, index.md, docs/testing/public-route.md, docs/testing/projection-lifecycle.md, docs/deployment/public-release.md.
|
|
24
|
-
|
|
25
|
-
## Visual Direction (Unified with dsh-clinebot)
|
|
26
|
-
- Атмосфера: Строгий утилитарный нативный интерфейс DSH, высокая информационная плотность без визуального шума.
|
|
27
|
-
- Утверждённые референсы: Эталонный плагин `@goodandready/dsh-clinebot`, дизайн-система DeepSeek Harness (`--dsw-alias-*`), карточки секций `.dcm-section-card`, бейджи статуса `.dcm-badge`, адаптивная сетка параметров `.dcm-grid-2`, чипы в шапке сессии.
|
|
28
|
-
- Отказоустойчивость: Обязательный `ErrorBoundary` вокруг карточки настроек и поповера шапки (предотвращает исчезновение слотов ядра при исключениях рендеринга).
|
|
29
|
-
- Синхронизация зеркал: `refreshMirrorUntilVisible(ctx)` для надёжного доступа к зеркалу `settingsScope`.
|
|
30
|
-
- Атмосфера: Строгий утилитарный нативный интерфейс DSH, высокая информационная плотность без визуального шума.
|
|
31
|
-
- Утверждённые референсы: Дизайн-система DeepSeek Harness (`--dsw-alias-*`), чипы в шапке сессии (`dsh-key-rotation`, `dsh-context-lens`), стандарт карточек настроек DSH.
|
|
32
|
-
- Не копировать: Избыточные градиенты, внешние модальные окна, тяжелые тени, кастомные несогласованные шрифты.
|
|
33
|
-
|
|
34
|
-
## Foundations
|
|
35
|
-
- Цвета и роли:
|
|
36
|
-
- `--dsw-alias-label-primary`: основной текст, заголовки, суммы.
|
|
37
|
-
- `--dsw-alias-label-tertiary`: второстепенные пояснения, метки столбцов, чип без тарифа.
|
|
38
|
-
- `--dsw-alias-border-l2`: разделители секций и обводка панели.
|
|
39
|
-
- `--dsw-alias-bg-layer-2` / `--dsw-alias-bg-base`: подложка всплывающей панели и карточки настроек.
|
|
40
|
-
- `--dsw-alias-state-success-primary` / `--dsw-alias-state-success-tertiary`: подсветка льготного окна (не-пик).
|
|
41
|
-
- `--dsw-alias-state-warn-primary` / `--dsw-alias-state-warn-tertiary`: подсветка полного тарифа (пик).
|
|
42
|
-
- Типографика:
|
|
43
|
-
- Системный стек шрифтов DSH, размер 12px, интерлиньяж 1.5.
|
|
44
|
-
- Обязательно `fontVariantNumeric: "tabular-nums"` для всех числовых колонок, токенов и таймеров.
|
|
45
|
-
- Сетка и отступы:
|
|
46
|
-
- Чип: высота 22px, padding: 0 8px, gap: 6px, border-radius: 6px.
|
|
47
|
-
- Панель: padding: 12px 14px, border-radius: 10px, ширина 320px.
|
|
48
|
-
- Карточка настроек: нативный складной контейнер (`.dcm-card`) с шапкой, статусами-бейджами (`dcm-badge-ok`, `dcm-badge-warn`, `dcm-badge-bad`), 3 структурированными секциями (`Основные параметры`, `Каталог OpenRouter`, `Диагностика и статус`), защитой ввода и кнопкой сохранения (`dcm-btn-primary`).
|
|
49
|
-
|
|
50
|
-
## Components & States
|
|
51
|
-
- Чип:
|
|
52
|
-
- `Unknown Tariff`: отображает «тариф ?» в приглушённом цвете `label-tertiary`.
|
|
53
|
-
- `Flat Tariff`: фон `--dsw-alias-state-warn-tertiary`, текст `--dsw-alias-state-warn-primary`, заголовок «Единый тариф».
|
|
54
|
-
- `Off-Peak`: зелёная плашка успеха (льготный тариф).
|
|
55
|
-
- `Peak`: оранжевая плашка предупреждения (полный тариф).
|
|
56
|
-
- Таймер: время до смены тарифа с пониженной непрозрачностью (`opacity: 0.75`).
|
|
57
|
-
- Панель:
|
|
58
|
-
- Заголовок с текущей моделью и статусом окна.
|
|
59
|
-
- Сетка тарифов за 1M токенов (не-пик / пик / единая ставка).
|
|
60
|
-
- Блок «Из чего сложилась сумма»:
|
|
61
|
-
- Список всех моделей сессии с токенами и суммарной стоимостью.
|
|
62
|
-
- Для модели без тарифа отображается статус `не указана стоимость` (`rate_unspecified`).
|
|
63
|
-
- Итог сессии: `Сессия: ≈ <валюта><сумма>`.
|
|
64
|
-
- Отсутствие мусорных служебных плашек (строка источника OpenRouter исключена).
|
|
65
|
-
- Карточка настроек (`CostMeterCard`):
|
|
66
|
-
- Сворачиваемый заголовок с шевроном и иконкой счётчика.
|
|
67
|
-
- Статус снимка: `ready` (зелёный бейдж), `loading` (нейтральный), `unavailable` (серый).
|
|
68
|
-
- Поля: выбор символа валюты (`currency`), курс к USD (`usdRate`), таймзона отображения (`displayTimeZone`).
|
|
69
|
-
- Локализованные лейблы и плейсхолдеры на русском и английском языках.
|
|
70
|
-
|
|
71
|
-
## User Flows
|
|
72
|
-
1. Пользователь ведёт диалог с ассистентом: чип в шапке инкрементально обновляет потраченную сумму прямо во время стриминга ответа ассистента.
|
|
73
|
-
2. Пользователь нажимает на чип: открывается всплывающая панель с тарифной сеткой и расходами по каждой модели сессии.
|
|
74
|
-
3. Если модель не тарифицирована: в списке моделей выводится статус `не указана стоимость`.
|
|
75
|
-
4. Пользователь открывает настройки DSH (`Настройки` -> `Плагины`): карточка `dsh-cost-meter` позволяет изменить валюту, курс и таймзону с мгновенным сохранением в `settingsScope`.
|
|
76
|
-
|
|
77
|
-
## Do / Don't
|
|
78
|
-
- Do:
|
|
79
|
-
- Использовать CSS-переменные DSH для согласованности с тёмной и светлой темами.
|
|
80
|
-
- Всегда разделять расходы по задействованным моделям.
|
|
81
|
-
- Использовать табличные цифры (`tabular-nums`) для предотвращения дёргания вёрстки при стриме.
|
|
82
|
-
- Использовать `ctx.locale.register` и `useActiveLocale` для всех строк интерфейса.
|
|
83
|
-
- Don't:
|
|
84
|
-
- Не выводить техническую служебную информацию о каталоге OpenRouter в интерфейс пользователя.
|
|
85
|
-
- Не заменять отсутствие тарифа обманчивым нулём или невнятным прочерком — писать прямо `не указана стоимость`.
|
|
86
|
-
- Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
|
|
87
|
-
|
|
88
|
-
## History & Decisions
|
|
89
|
-
- 2026-09-12 (v0.8.1):
|
|
90
|
-
- Оптимизация производительности расчёта тарифов: замена 1440-минутного цикла на алгоритм O(K) по точкам перехода и мемоизация экстремумов через WeakMap.
|
|
91
|
-
- Серверный кэш разрешения тарифов resolveTariffCache (Map) с автоматической очисткой при обновлении каталога.
|
|
92
|
-
- Поддержка ETag и ответа 304 Not Modified на эндпоинте /dsh-cost-meter/state.
|
|
93
|
-
- Кнопка ручной синхронизации каталога цен в секции OpenRouter с защитой от флуда (cooldown 30 сек).
|
|
94
|
-
- Интеллектуальные подсказки экономии: плашка скорого наступления льготного тарифа (скидка 50%) и отсчёт времени.
|
|
95
|
-
- Поддержка настраиваемого порога бюджета сессии (budgetThreshold) с предупреждающей подсветкой чипа.
|
|
96
|
-
- Кнопка быстрого копирования текстового отчёта о расходах в буфер обмена.
|
|
97
|
-
- Таблица нормализации алиасов вендоров (z-ai -> zai, deepseek-official -> deepseek, gemini -> google).
|
|
98
|
-
- 2026-09-05 (v0.7.9):
|
|
99
|
-
- Hotfix: исправлен ReferenceError (t is not defined) в объекте словаря ru (lib/client.js) (#15).
|
|
100
|
-
- Добавлен автоматический тест на чистое исполнение фабрики client loader (`test/public-route.test.mjs`).
|
|
101
|
-
- 2026-09-05 (v0.7.4):
|
|
102
|
-
- Добавлена нативная карточка настроек в слот `settings.plugin.item` (`#12`).
|
|
103
|
-
- Реализована полная локализация клиентской части через `ctx.locale.register` (`#2`).
|
|
104
|
-
- Реализовано provider-aware разрешение тарифов OpenRouter vs DeepSeek (`#3`).
|
|
105
|
-
- Добавлены всеобъемлющие регрессионные тесты на проекцию и сопоставление (`test/projection-lifecycle.test.mjs`, `#1`).
|
|
106
|
-
- Закрыт issue о согласовании публичного scope пакета (`#13`).
|
|
107
|
-
- 2026-09-05 (v0.7.3):
|
|
108
|
-
- Удалена строка источника цен OpenRouter.
|
|
109
|
-
- Список моделей в блоке «Из чего сложилась сумма» унифицирован и отображается для всех задействованных моделей сессии.
|
|
110
|
-
- Для нетарифицированных моделей введён явный статус «не указана стоимость».
|
|
111
|
-
- Пакет переведён на публичный scope `@goodandready/dsh-cost-meter`.
|
package/docs/plans/findings.md
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# Findings: Issue #25 (v0.8.1)
|
|
2
|
-
|
|
3
|
-
## 1. Клиентские вычисления расписания
|
|
4
|
-
- В `lib/client.js`: `extremes()` и `minutesUntilChange()` вызывались на каждом тике таймера (15 сек) и перерисовывали 1440-минутный цикл:
|
|
5
|
-
`for (let minute = 0; minute < 1440; minute++)` с поиском в `windows`.
|
|
6
|
-
- В сутках конечное число окон $K$ (обычно 2–4). Смена тарифа может происходить только на границах интервалов: `window.start` и `window.end`.
|
|
7
|
-
- Замена на $O(K)$ даёт мгновенный расчёт без аллокации сотен объектов в секунду при активных сессиях.
|
|
8
|
-
|
|
9
|
-
## 2. Разрешение тарифов на сервере
|
|
10
|
-
- `resolveTariff(cfg, catalog.index, route)` перебирает регулярные выражения и суффиксы.
|
|
11
|
-
- Кэширование в `Map<string, ResolvedTariff>` с очисткой при обновлении каталога полностью снимает нагрузку на CPU при частом пуллинге `/state` клиентами DSH.
|
|
12
|
-
|
|
13
|
-
## 3. Поддержка ETag
|
|
14
|
-
- Для `/dsh-cost-meter/state` генерация слабого ETag (`W/"<hash>"`) на основе `updatedAt`, `fetchedAt` и счетчиков токенов позволяет клиентам передавать `If-None-Match` и получать `304 Not Modified`, экономя полосу пропускания и парсинг JSON.
|
|
15
|
-
|
|
16
|
-
## 4. Алиасы вендоров
|
|
17
|
-
- Разные провайдеры в DSH могут передавать вендоров как `z-ai`, `zai`, `google`, `gemini`, `deepseek-ai`, `deepseek`. Единая нормализация исключит ситуации fallback на generic тариф.
|
package/docs/plans/progress.md
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Progress: Issue #25 (v0.8.1)
|
|
2
|
-
|
|
3
|
-
- **2026-09-12 19:10**:
|
|
4
|
-
- Создана Gitea Issue #25.
|
|
5
|
-
- Создан worktree `.worktrees/issue-25` от `origin/main` (ветка `feat/issue-25-perf-and-smart-features`).
|
|
6
|
-
- Инициализированы планы в `docs/plans/`.
|
|
7
|
-
|
|
8
|
-
- **2026-09-12 19:16**:
|
|
9
|
-
- Реализованы оптимизации в `lib/index.js` (resolveTariffCache, ETag / 304, POST /refresh, budgetThreshold, VENDOR_ALIASES).
|
|
10
|
-
- Реализованы улучшения в `lib/client.js` (O(K) minutesUntilChange, memoized extremes, кнопка синхронизации с cooldown, порог бюджета, плашка экономии, копирование сводки).
|
|
11
|
-
- Добавлены тесты в `test/v081-perf-smart.test.mjs` (все 20 тестов успешно пройдены).
|
|
12
|
-
- Версия поднята до `0.8.1`.
|
package/docs/plans/task_plan.md
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# Task Plan: Issue #25 (v0.8.1) — Оптимизация производительности, ручная синхронизация каталога и подсказки экономии
|
|
2
|
-
|
|
3
|
-
## Цели
|
|
4
|
-
1. **Скорость**:
|
|
5
|
-
- Оптимизация алгоритма расчета времени смены тарифа (`minutesUntilChange`) и экстремумов (`extremes`) с $O(1440)$ до $O(K)$ без полного минутного цикла.
|
|
6
|
-
- Серверный кэш `resolveTariffCache` для быстрого разрешения моделей.
|
|
7
|
-
- Поддержка ETag / 304 Not Modified на эндпоинте `/dsh-cost-meter/state`.
|
|
8
|
-
2. **Качество и управление**:
|
|
9
|
-
- Маршрут `POST /dsh-cost-meter/refresh` с rate limit (cooldown 30 сек) и кнопка «Синхронизировать сейчас» в настройках.
|
|
10
|
-
- Таблица нормализации алиасов вендоров (Smart Vendor Aliases).
|
|
11
|
-
- Индикация stale-кэша каталога в UI.
|
|
12
|
-
3. **Экономия**:
|
|
13
|
-
- Подсказка в поповере о приближении льготного тарифа (50% скидка).
|
|
14
|
-
- Конфигурируемый порог сессии `budgetThreshold` с предупреждающей подсветкой чипа.
|
|
15
|
-
- Кнопка «Скопировать сводку» в поповере.
|
|
16
|
-
4. **Стандарты и релиз**:
|
|
17
|
-
- 100% прохождение тестов `npm test`.
|
|
18
|
-
- Проверка размера файлов (< 256 KiB).
|
|
19
|
-
- Тестирование на test server (MiniPC :3082), затем на MiniAI candidate (:3080).
|
|
20
|
-
- Запрос подтверждения пользователя -> публикация v0.8.1 в npm/GitHub -> rollout на прод.
|
|
21
|
-
|
|
22
|
-
## Статус выполнения
|
|
23
|
-
- [x] Issue #25 создана
|
|
24
|
-
- [x] Worktree `.worktrees/issue-25` развёрнут
|
|
25
|
-
- [x] Реализация серверной части (`lib/index.js`)
|
|
26
|
-
- [x] Реализация клиентской части (`lib/client.js`)
|
|
27
|
-
- [x] Добавление unit-тестов в `test/`
|
|
28
|
-
- [x] Обновление документации (`DESIGN.md`, `README.md`, `README.ru.md`)
|
|
29
|
-
- [ ] Тестирование на стендах (:3082, :3080)
|
|
30
|
-
- [ ] Публикация релиза v0.8.1
|
|
31
|
-
- [ ] Закрытие Issue #25 и очистка worktree
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Регрессионное тестирование проекции тарифов и жизненного цикла
|
|
2
|
-
|
|
3
|
-
Тесты проверяют логику тарификации, парсинг временных окон UTC, сопоставление моделей и provider-aware разрешение ставок.
|
|
4
|
-
|
|
5
|
-
## Запуск тестов
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm test
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Проверяемые сценарии (`test/projection-lifecycle.test.mjs`)
|
|
12
|
-
|
|
13
|
-
1. **`hhmmToMinutes` и `parseWindow`**:
|
|
14
|
-
- Корректный разбор диапазонов `HH:MM-HH:MM` в минуты от начала суток UTC.
|
|
15
|
-
- Граничные условия: переход через полночь (`23:00-02:00`), пустые или невалидные строки.
|
|
16
|
-
|
|
17
|
-
2. **`slotOf`**:
|
|
18
|
-
- Вычисление 30-минутного слота суток (`0..47`) на основе переданного timestamp.
|
|
19
|
-
- Независимость накопления расхода от текущего момента времени (сохранение исторической ставки слота).
|
|
20
|
-
|
|
21
|
-
3. **`indexCatalog` и `matchModel`**:
|
|
22
|
-
- Индексация моделей OpenRouter с поддержкой поиска без учета регистра.
|
|
23
|
-
- Нормализация суффиксов вендоров и версий (например, `vendor/model:variant`).
|
|
24
|
-
|
|
25
|
-
4. **`resolveTariff` (Provider-aware)**:
|
|
26
|
-
- Если указан `provider: "openrouter"`, плагин в первую очередь обращается к каталогу OpenRouter, даже если имя модели содержит `deepseek`.
|
|
27
|
-
- Если провайдер не `openrouter` (или `deepseek`), DeepSeek модели тарифицируются по официальной зашитой сетке (пик / не-пик).
|
|
28
|
-
- Ручные ставки `prices` из конфигурации имеют высший приоритет над внешними каталогами.
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Проверка публичного маршрута
|
|
2
|
-
|
|
3
|
-
1. Выполнить `npm test`.
|
|
4
|
-
2. Проверить `npm pack --dry-run` и отсутствие локальных установочных ссылок,
|
|
5
|
-
абсолютных infra-путей, IP-адресов и секретов в поставляемых файлах.
|
|
6
|
-
3. Собрать tarball из проверенного Git worktree.
|
|
7
|
-
4. Установить tarball на изолированный тестовый DSH web server (MiniPC).
|
|
8
|
-
5. Для browser half проверить loader id `@goodandready/dsh-cost-meter`, HTTP route
|
|
9
|
-
состояния `GET /dsh-cost-meter/state` и smoke-сценарий тарификации (список моделей,
|
|
10
|
-
расчёт стоимости, статус «не указана стоимость»); для host half — bundle identity и host smoke.
|
|
11
|
-
6. Выполнить cleanup тестовой установки и убедиться, что постоянные плагины
|
|
12
|
-
сохранили работоспособность.
|