@goodandready/dsh-cost-meter 0.7.6 → 0.7.8
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/docs/design/DESIGN.md +1 -1
- package/docs/plans/findings.md +10 -0
- package/docs/plans/progress.md +17 -0
- package/docs/plans/task_plan.md +24 -0
- package/lib/client.js +40 -26
- package/lib/index.js +12 -10
- package/package.json +1 -1
package/docs/design/DESIGN.md
CHANGED
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
- Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
|
|
83
83
|
|
|
84
84
|
## History & Decisions
|
|
85
|
-
- 2026-09-05 (v0.7.
|
|
85
|
+
- 2026-09-05 (v0.7.8):
|
|
86
86
|
- Hotfix: исправлен ReferenceError (t is not defined) в объекте словаря ru (lib/client.js) (#15).
|
|
87
87
|
- Добавлен автоматический тест на чистое исполнение фабрики client loader (`test/public-route.test.mjs`).
|
|
88
88
|
- 2026-09-05 (v0.7.4):
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Findings & Architecture Decisions (v0.7.8)
|
|
2
|
+
|
|
3
|
+
## 1. Browser Request Race Conditions
|
|
4
|
+
In `CostMeter`, rapid model switches or toggling the popover calls `load(usedKeys)`. Since network latency varies, an older request might resolve after a newer one. By tracking an active request counter or cancellation token per effect invocation, we ensure only the most up-to-date state response mutates the component state.
|
|
5
|
+
|
|
6
|
+
## 2. IANA TimeZone Validation
|
|
7
|
+
`Intl.DateTimeFormat(undefined, { timeZone })` throws a `RangeError` if the timezone string is invalid. We can safely leverage this in `CostMeterCard` to reject typos (e.g. `Europe/Moskow` instead of `Europe/Moscow`) on the client before writing to `settingsScope`.
|
|
8
|
+
|
|
9
|
+
## 3. Query Parameter Sanitization
|
|
10
|
+
The endpoint `GET /dsh-cost-meter/state?models=...` accepts comma-separated model keys. Adding a length bound (e.g. <= 128 chars) and trimming ensures no malformed payloads cause unexpected overhead.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Implementation Progress (v0.7.8)
|
|
2
|
+
|
|
3
|
+
- [x] Create Gitea Issue #19
|
|
4
|
+
- [x] Create isolated worktree `stability-v078`
|
|
5
|
+
- [x] Bump version to 0.7.8 in `package.json`
|
|
6
|
+
- [x] Add monotonic request sequence guard in `lib/client.js` `CostMeter`
|
|
7
|
+
- [x] Add IANA time zone validation in `lib/client.js` `CostMeterCard`
|
|
8
|
+
- [x] Sanitize query parameters in `lib/index.js`
|
|
9
|
+
- [x] Add automated unit tests (`test/timezone-sanitization.test.mjs`, 11/11 passing)
|
|
10
|
+
- [x] Update design documentation in `docs/design/DESIGN.md`
|
|
11
|
+
- [ ] Commit changes with conventional commits (`git-antigravity`)
|
|
12
|
+
- [ ] Create Pull Request and merge into `main`
|
|
13
|
+
- [ ] Test package on MiniPC isolated test server (`192.168.1.123:3082`)
|
|
14
|
+
- [ ] Smoke-test package on production candidate (`192.168.1.111:3080`)
|
|
15
|
+
- [ ] Publish v0.7.8 to npm and GitHub Release
|
|
16
|
+
- [ ] Install v0.7.8 from npm on MiniAI and verify live health
|
|
17
|
+
- [ ] Close Gitea Issue #19 and clean up worktree
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Task Plan: Stability, Race Condition Prevention & TimeZone Validation (v0.7.8)
|
|
2
|
+
|
|
3
|
+
## 1. Goal
|
|
4
|
+
Strengthen `@goodandready/dsh-cost-meter` runtime resilience:
|
|
5
|
+
- Prevent race condition response overwrites in browser `CostMeter` component.
|
|
6
|
+
- Validate IANA TimeZone inputs in `CostMeterCard` settings before saving.
|
|
7
|
+
- Sanitize `models` query parameter keys in `lib/index.js` route handler.
|
|
8
|
+
- Maintain strict backward compatibility, zero regression in automated tests.
|
|
9
|
+
|
|
10
|
+
## 2. Checklist
|
|
11
|
+
- [ ] Bump version to `0.7.8` in `package.json`
|
|
12
|
+
- [ ] Implement cancellation flag / abort protection in `lib/client.js` `CostMeter`
|
|
13
|
+
- [ ] Implement `isValidTimeZone` helper and validation check in `CostMeterCard`
|
|
14
|
+
- [ ] Implement key sanitization (length <= 128 chars, alphanumeric/dash/slash) in `lib/index.js`
|
|
15
|
+
- [ ] Add unit tests for timezone check and query sanitization
|
|
16
|
+
- [ ] Run test suite (`npm test`)
|
|
17
|
+
- [ ] Update `docs/design/DESIGN.md`
|
|
18
|
+
- [ ] Conventional Commit & Push
|
|
19
|
+
- [ ] Gitea PR & Merge to `main`
|
|
20
|
+
- [ ] Test on MiniPC test server (`192.168.1.123`)
|
|
21
|
+
- [ ] Production Smoke Verification on MiniAI (`192.168.1.111`)
|
|
22
|
+
- [ ] Publish Tag `v0.7.8`, GitHub Release & npm
|
|
23
|
+
- [ ] Update production profile from npm
|
|
24
|
+
- [ ] Close Gitea Issue #19 and clean up worktree
|
package/lib/client.js
CHANGED
|
@@ -131,6 +131,7 @@ window.__ModuleLoader__.load({
|
|
|
131
131
|
if (document.getElementById('dsh-cost-meter-card-styles')) return
|
|
132
132
|
const el = document.createElement('style')
|
|
133
133
|
el.id = 'dsh-cost-meter-card-styles'
|
|
134
|
+
el.dataset.dshPlugin = 'dsh-cost-meter'
|
|
134
135
|
el.textContent = cardCss
|
|
135
136
|
document.head.appendChild(el)
|
|
136
137
|
}
|
|
@@ -208,9 +209,14 @@ window.__ModuleLoader__.load({
|
|
|
208
209
|
} catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
|
|
213
|
+
if (!isValidTimeZone(tzVal)) {
|
|
214
|
+
broken.push('displayTimeZone: invalid IANA time zone')
|
|
215
|
+
} else {
|
|
216
|
+
try {
|
|
217
|
+
await scope.set('displayTimeZone', tzVal)
|
|
218
|
+
} catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
|
|
219
|
+
}
|
|
214
220
|
|
|
215
221
|
setSaving(false)
|
|
216
222
|
if (broken.length) {
|
|
@@ -384,6 +390,16 @@ window.__ModuleLoader__.load({
|
|
|
384
390
|
return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
|
|
385
391
|
}
|
|
386
392
|
|
|
393
|
+
function isValidTimeZone(tz) {
|
|
394
|
+
if (!tz || typeof tz !== 'string') return false
|
|
395
|
+
try {
|
|
396
|
+
new Intl.DateTimeFormat(undefined, { timeZone: tz })
|
|
397
|
+
return true
|
|
398
|
+
} catch {
|
|
399
|
+
return false
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
387
403
|
/**
|
|
388
404
|
* Render a UTC minute-of-day in the display zone. Built from a real Date so
|
|
389
405
|
* the zone's current offset (and any DST) comes from Intl, not arithmetic.
|
|
@@ -471,6 +487,14 @@ window.__ModuleLoader__.load({
|
|
|
471
487
|
const [open, setOpen] = React.useState(false)
|
|
472
488
|
const [now, setNow] = React.useState(() => new Date())
|
|
473
489
|
const box = React.useRef(null)
|
|
490
|
+
const settingsScope = ctx.settingsScope || (ctx.services && ctx.services.settingsScope)
|
|
491
|
+
const settingsSnap = React.useSyncExternalStore
|
|
492
|
+
? React.useSyncExternalStore(
|
|
493
|
+
(cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}),
|
|
494
|
+
() => (settingsScope && typeof settingsScope.get === 'function' ? settingsScope.get(NS) : null),
|
|
495
|
+
() => null,
|
|
496
|
+
)
|
|
497
|
+
: null
|
|
474
498
|
|
|
475
499
|
// Every model this session actually billed, so each part is priced on its
|
|
476
500
|
// own tariff instead of the current model's. Usage arrives split into
|
|
@@ -482,14 +506,18 @@ window.__ModuleLoader__.load({
|
|
|
482
506
|
m.usage.cacheReadTokens || m.usage.cacheWriteTokens)
|
|
483
507
|
const usedKeys = used.map((m) => m.provider + '/' + m.model).sort().join(',')
|
|
484
508
|
|
|
509
|
+
const reqSeq = React.useRef(0)
|
|
510
|
+
|
|
485
511
|
const load = React.useCallback((keys) => {
|
|
512
|
+
const seq = ++reqSeq.current
|
|
486
513
|
const query = keys ? '?models=' + encodeURIComponent(keys) : ''
|
|
487
514
|
fetch(STATE_PATH + query, { headers: { accept: 'application/json' } })
|
|
488
515
|
.then((r) => (r.ok ? r.json() : null))
|
|
489
|
-
.then((data) => {
|
|
516
|
+
.then((data) => {
|
|
517
|
+
if (data && seq === reqSeq.current) setState(data)
|
|
518
|
+
})
|
|
490
519
|
.catch(() => {})
|
|
491
520
|
}, [])
|
|
492
|
-
|
|
493
521
|
// Refetch when the set of billed models changes — a new model needs its
|
|
494
522
|
// own tariff before its tokens can be priced.
|
|
495
523
|
React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
|
|
@@ -507,8 +535,9 @@ window.__ModuleLoader__.load({
|
|
|
507
535
|
|
|
508
536
|
if (!state) return null
|
|
509
537
|
|
|
510
|
-
const
|
|
511
|
-
const
|
|
538
|
+
const stored = (settingsSnap && settingsSnap.value) || {}
|
|
539
|
+
const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
|
|
540
|
+
const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
|
|
512
541
|
const schedule = state.schedule
|
|
513
542
|
const hasUsage = usage !== undefined && usage !== null
|
|
514
543
|
const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
|
|
@@ -734,27 +763,12 @@ window.__ModuleLoader__.load({
|
|
|
734
763
|
),
|
|
735
764
|
)
|
|
736
765
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
placed = true
|
|
740
|
-
return ctx.slots.register(
|
|
766
|
+
ctx.slots.inject('settings.plugin.item', () =>
|
|
767
|
+
ctx.slots.register(
|
|
741
768
|
{ name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
|
|
742
769
|
(p) => React.createElement(CostMeterCard, { ...p, ctx }),
|
|
743
|
-
)
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// Fallback to settings.section if settings.plugin.item is not declared
|
|
747
|
-
setTimeout(() => {
|
|
748
|
-
if (placed) return
|
|
749
|
-
try {
|
|
750
|
-
ctx.slots.inject('settings.section', () =>
|
|
751
|
-
ctx.slots.register(
|
|
752
|
-
{ name: 'settings.section', id: '@goodandready/dsh-cost-meter', order: 25, locale: NS, label: () => makeT(useActiveLocale(ctx) === 'ru' ? ru : en, en)('title'), inject: () => ({ ctx }) },
|
|
753
|
-
(p) => React.createElement(CostMeterCard, { ...p, ctx }),
|
|
754
|
-
),
|
|
755
|
-
)
|
|
756
|
-
} catch (_) {}
|
|
757
|
-
}, 500)
|
|
770
|
+
),
|
|
771
|
+
)
|
|
758
772
|
}
|
|
759
773
|
|
|
760
774
|
module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
|
package/lib/index.js
CHANGED
|
@@ -507,16 +507,18 @@ export function apply(ctx, config) {
|
|
|
507
507
|
// session that switched models can price each part on its own tariff.
|
|
508
508
|
const schedules = {}
|
|
509
509
|
const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
|
|
510
|
-
for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
|
|
511
|
-
const
|
|
512
|
-
if (!
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
510
|
+
for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
|
|
511
|
+
const trimmed = String(key || '').trim()
|
|
512
|
+
if (!trimmed || trimmed.length > 128) continue
|
|
513
|
+
const parsed = splitRoute(trimmed)
|
|
514
|
+
if (!parsed) continue
|
|
515
|
+
const resolved = resolveTariff(cfg, catalog.index, parsed)
|
|
516
|
+
schedules[trimmed] = {
|
|
517
|
+
schedule: resolved.schedule,
|
|
518
|
+
source: resolved.source,
|
|
519
|
+
matchedId: resolved.matchedId,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
520
522
|
|
|
521
523
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
522
524
|
res.end(JSON.stringify({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cost-meter",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
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",
|