@goodandready/dsh-cost-meter 0.7.7 → 0.7.9

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 CHANGED
@@ -108,14 +108,20 @@ dsh-cost-meter:
108
108
 
109
109
  ### Configuration Parameters
110
110
 
111
- | Parameter | Type | Default | Description |
112
- |:---|:---|:---|:---|
113
- | `currency` | `string` | `"$"` | Display currency symbol or code |
114
- | `usdRate` | `number` | `1.0` | Exchange rate multiplier from USD |
115
- | `displayTimeZone` | `string` | `"UTC"` | Timezone for window schedule formatting (e.g. `Europe/London`, `Asia/Shanghai`) |
116
- | `useOpenRouter` | `boolean` | `true` | Fetch OpenRouter public catalog for fallback models |
117
- | `prices` | `object` | `{}` | Custom override rates per 1M tokens `{ prompt, completion, cacheRead?, cacheWrite? }` |
118
- | `modelMap` | `object` | `{}` | Model alias mappings `{ "alias": "target-model" }` |
111
+ | Parameter | Scope / Location | Type | Default | Description |
112
+ |:---|:---|:---|:---|:---|
113
+ | `currency` | GUI / `settings.yaml` | `string` | `"$"` | Display currency symbol (e.g. `$`, `₽`, `€`) |
114
+ | `usdRate` | GUI / `settings.yaml` | `number` | `1.0` | Exchange rate multiplier: units of currency per 1 USD |
115
+ | `displayTimeZone` | GUI / `settings.yaml` | `string` | `"Europe/Moscow"` | IANA timezone for peak/off-peak windows formatting |
116
+ | `useOpenRouter` | GUI / `settings.yaml` | `boolean` | `true` | Automatically fetch rates & discount windows from OpenRouter |
117
+ | `refreshHours` | GUI / `settings.yaml` | `number` | `24` | Hours between periodic OpenRouter catalog updates |
118
+ | `deepseekPeakPrices` | `settings.yaml` only | `object` | `{}` | Override built-in DeepSeek peak rates by model id |
119
+ | `prices` | `settings.yaml` only | `object` | `{}` | Manual rates per 1M tokens `{ input, output, cacheHit?, cacheWrite? }` |
120
+ | `modelMap` | `settings.yaml` only | `object` | `{}` | Route override: `"provider/model"` -> OpenRouter model id |
121
+ | `manualPeakWindowsUtc` | `settings.yaml` only | `array` | `[]` | Manual peak windows HH:MM-HH:MM UTC used with manual prices |
122
+ | `manualOffPeakMultiplier` | `settings.yaml` only | `number` | `1.0` | Multiplier applied outside manual peak windows |
123
+
124
+ > **Note on GUI vs YAML**: Primary scalar parameters (`currency`, `usdRate`, `displayTimeZone`, `useOpenRouter`, `refreshHours`) are directly editable in the GUI Settings Card under **Settings → Plugins → Plugin Settings → Cost Meter**. Advanced structured rules (`prices`, `modelMap`, `deepseekPeakPrices`, `manualPeakWindowsUtc`, `manualOffPeakMultiplier`) are configured in `settings.yaml` due to their complex dictionary/array schema.
119
125
 
120
126
  ---
121
127
 
package/docs/README.ru.md CHANGED
@@ -108,14 +108,20 @@ dsh-cost-meter:
108
108
 
109
109
  ### Параметры конфигурации
110
110
 
111
- | Параметр | Тип | По умолчанию | Описание |
112
- |:---|:---|:---|:---|
113
- | `currency` | `string` | `"₽"` | Символ или код валюты для отображения |
114
- | `usdRate` | `number` | `90.0` | Множитель пересчёта долларовых цен в валюту отображения |
115
- | `displayTimeZone` | `string` | `"UTC"` | Таймзона для отображения окон в панели |
116
- | `useOpenRouter` | `boolean` | `true` | Загружать ли каталог цен OpenRouter для сторонних моделей |
117
- | `prices` | `object` | `{}` | Ручные ставки за 1M токенов `{ prompt, completion, cacheRead?, cacheWrite? }` |
118
- | `modelMap` | `object` | `{}` | Алиасы моделей `{ "alias": "target-model" }` |
111
+ | Параметр | Доступность | Тип | По умолчанию | Описание |
112
+ |:---|:---|:---|:---|:---|
113
+ | `currency` | GUI / `settings.yaml` | `string` | `"₽"` | Символ валюты в интерфейсе (например, `₽`, `$`, `€`) |
114
+ | `usdRate` | GUI / `settings.yaml` | `number` | `80.0` | Курс обмена: единиц валюты за 1 USD |
115
+ | `displayTimeZone` | GUI / `settings.yaml` | `string` | `"Europe/Moscow"` | IANA часовой пояс для отображения окон пика и не-пика |
116
+ | `useOpenRouter` | GUI / `settings.yaml` | `boolean` | `true` | Загружать ли каталог тарифов и скидочных окон OpenRouter |
117
+ | `refreshHours` | GUI / `settings.yaml` | `number` | `24` | Частота обновления каталога OpenRouter (в часах) |
118
+ | `deepseekPeakPrices` | Только `settings.yaml` | `object` | `{}` | Переопределение встроенных пиковых ставок DeepSeek по id модели |
119
+ | `prices` | Только `settings.yaml` | `object` | `{}` | Ручные ставки за 1M токенов `{ input, output, cacheHit?, cacheWrite? }` |
120
+ | `modelMap` | Только `settings.yaml` | `object` | `{}` | Сопоставление маршрута `"provider/model"` на id в OpenRouter |
121
+ | `manualPeakWindowsUtc` | Только `settings.yaml` | `array` | `[]` | Ручные окна пика HH:MM-HH:MM UTC для ручных ставок |
122
+ | `manualOffPeakMultiplier` | Только `settings.yaml` | `number` | `1.0` | Коэффициент тарифа вне ручных окон пика |
123
+
124
+ > **Интерфейс настроек (GUI) vs settings.yaml**: Основные параметры (`currency`, `usdRate`, `displayTimeZone`, `useOpenRouter`, `refreshHours`) настраиваются прямо в карточке интерфейса DSH (**Настройки → Плагины → Настройки плагинов → Cost Meter**). Расширенные структурированные правила сопоставления и ручных тарифов задаются через `settings.yaml` из-за сложной древовидной структуры.
119
125
 
120
126
  ---
121
127
 
@@ -82,7 +82,7 @@
82
82
  - Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
83
83
 
84
84
  ## History & Decisions
85
- - 2026-09-05 (v0.7.7):
85
+ - 2026-09-05 (v0.7.9):
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):
@@ -1,16 +1,22 @@
1
- # Technical Findings & Architecture Decisions (v0.7.7)
1
+ # Technical Findings (Issue #21)
2
2
 
3
- ## 1. Zod Usage Rationale
4
- - `zod` is used in `lib/index.js` for `usageByModelProjection`:
5
- DeepSeek Harness core projection machinery requires `.parse(view)` method on projection schemas for runtime boundary contract validation. Schemastery does not provide `.parse()`, so `zod` is strictly required here and is not overengineering.
3
+ ## 1. Config Fields Breakdown
4
+ The plugin `Config` defines 10 fields:
5
+ 1. `currency` (string, default '$') -> Present in UI
6
+ 2. `usdRate` (number, default 1) -> Present in UI
7
+ 3. `displayTimeZone` (string, default 'Europe/Moscow') -> Present in UI
8
+ 4. `useOpenRouter` (boolean, default true) -> Missing in UI -> To be added as a checkbox in `CostMeterCard`
9
+ 5. `refreshHours` (number, default 24) -> Missing in UI -> To be added as a numeric input in `CostMeterCard`
10
+ 6. `modelMap` (dict of string -> string) -> Advanced JSON/YAML route mapping
11
+ 7. `prices` (dict of PriceRow) -> Advanced manual pricing rules
12
+ 8. `deepseekPeakPrices` (dict of PriceRow) -> Advanced DeepSeek peak overrides
13
+ 9. `manualPeakWindowsUtc` (array of string) -> Advanced manual time windows
14
+ 10. `manualOffPeakMultiplier` (number) -> Advanced discount multiplier for manual rates
6
15
 
7
- ## 2. Browser Reactivity & Settings
8
- - Reactivity in browser UI (`lib/client.js`):
9
- Using `React.useSyncExternalStore` connected to `ctx.settingsScope` allows instant updates of `currency` and `usdRate` in the header chip and popover without needing a page refresh or roundtrip state reload.
16
+ By adding `useOpenRouter` and `refreshHours` to `CostMeterCard`, all 5 primary scalar/control settings are directly configurable in the DSH Settings GUI. The 5 advanced structured override rules (`prices`, `modelMap`, `deepseekPeakPrices`, `manualPeakWindowsUtc`, `manualOffPeakMultiplier`) are explicitly documented in README.md and DESIGN.md as YAML configuration rows.
10
17
 
11
- ## 3. Slot Hygiene
12
- - Cleaned up slot injection in `lib/client.js`:
13
- Removed `setTimeout(..., 500)` fallback registering `settings.section`. The standard `settings.plugin.item` slot cleanly renders the settings card inside the Harness Settings view.
14
-
15
- ## 4. Style Isolation & Deduplication
16
- - Added `data-dsh-plugin="dsh-cost-meter"` attribute to injected `<style>` tags to adhere to DSH UI design guidelines and prevent duplicate stylesheets.
18
+ ## 2. Service Access Pattern
19
+ In `lib/index.js`, we have:
20
+ `const settings = ctx.get('settings')` (canonical).
21
+ In `lib/client.js`, `ctx.settingsScope` is accessed directly. We should safely check `(ctx.get && ctx.get('settingsScope')) || ctx.settingsScope || (ctx.services && ctx.services.settingsScope)`.
22
+ Same for `ctx.locale` and `ctx.slots`.
@@ -1,15 +1,17 @@
1
- # Implementation Progress (v0.7.7)
1
+ # Progress Log (Issue #21 / v0.7.9)
2
2
 
3
- - [x] Create Gitea Issue #17
4
- - [x] Create isolated worktree `refactor/stability-and-quality`
5
- - [x] Bump version to 0.7.7 in `package.json`
6
- - [x] Refactor `lib/client.js` for reactive settings, clean slot registration, and style tagging
7
- - [x] Verify syntax and run test suite (`npm test`, 9 passed)
8
- - [ ] Document design contract in `docs/design/DESIGN.md`
3
+ - [x] Analyze Issue #21
4
+ - [x] Create worktree `.worktrees/issue-21`
5
+ - [x] Initialize plans (`task_plan.md`, `findings.md`, `progress.md`)
6
+ - [x] Bump version to 0.7.9 in `package.json`
7
+ - [x] Implement settings card additions (`useOpenRouter`, `refreshHours`) in `lib/client.js`
8
+ - [x] Safe service resolution with `ctx.get('...')` in `lib/client.js`
9
+ - [x] Update documentation with full 10-field matrix (`README.md`, `docs/README.ru.md`, `docs/design/DESIGN.md`)
10
+ - [x] Add regression test `test/settings-card-fields.test.mjs` (12/12 passing)
9
11
  - [ ] Commit changes with conventional commits (`git-antigravity`)
10
12
  - [ ] Create Pull Request and merge into `main`
11
13
  - [ ] Test package on MiniPC isolated test server (`192.168.1.123:3082`)
12
14
  - [ ] Smoke-test package on production candidate (`192.168.1.111:3080`)
13
- - [ ] Publish v0.7.7 to npm and GitHub Release
14
- - [ ] Install v0.7.7 from npm on MiniAI and verify live health
15
- - [ ] Close Gitea Issue #17 and clean up worktree
15
+ - [ ] Publish v0.7.9 to npm and GitHub Release
16
+ - [ ] Install v0.7.9 from npm on MiniAI and verify live health
17
+ - [ ] Close Gitea Issue #21 and clean up worktree
@@ -1,25 +1,32 @@
1
- # Task Plan: Refactor dsh-cost-meter for stability, simplification & performance
1
+ # Task Plan: Address Issue #21 (Settings Card Schema Fields & Service Access)
2
2
 
3
- - **Issue**: Gitea #17
4
- - **Worktree**: `.worktrees/refactor-stability`
5
- - **Branch**: `refactor/stability-and-quality`
3
+ ## 1. Goal
4
+ Fulfill Gitea Issue #21 requirements:
5
+ 1. **Schema Fields Availability in Settings Card**:
6
+ - Audit all 10 fields of `Config`:
7
+ - UI fields: `currency`, `usdRate`, `displayTimeZone`.
8
+ - Boolean toggle: `useOpenRouter` (checkbox / toggle in settings card).
9
+ - Numeric: `refreshHours` (catalog refresh frequency, e.g. 24 hours).
10
+ - Complex structured overrides: `prices` (dict), `modelMap` (dict), `deepseekPeakPrices` (dict), `manualPeakWindowsUtc` (array), `manualOffPeakMultiplier` (number).
11
+ - Add `useOpenRouter` and `refreshHours` directly to the `CostMeterCard` form so all scalar/toggle configuration is editable in GUI.
12
+ - Document structured dict/array fields (`prices`, `modelMap`, `deepseekPeakPrices`, `manualPeakWindowsUtc`, `manualOffPeakMultiplier`) in documentation, READMEs and DESIGN.md as YAML-only/advanced configuration rows.
13
+ 2. **Context Service Access Hygiene**:
14
+ - Inspect all `ctx.settings` / `ctx.settingsScope` / `ctx.locale` / `ctx.webServer` calls.
15
+ - Use `ctx.get('...')` or inject where appropriate, or guard against undefined proxiable properties.
16
+ 3. **Locale & Translations**:
17
+ - Provide clean English locale strings as canonical with ru dictionaries, and hint descriptions for all new fields.
6
18
 
7
- ## Phases
8
-
9
- - [ ] **Phase 1: Architecture & Overengineering Cleanup**
10
- - Remove `zod` dependency from `lib/index.js`, standardize on `@deepseek-ai/schemastery`.
11
- - Remove dynamic fragile `require('@deepseek-ai/dsh-client-ui-primitives')` from `lib/client.js`, standardize on clean native inline SVG chevron.
12
- - Remove race-prone `setTimeout(..., 500)` fallback slot registration in `lib/client.js`.
13
- - [ ] **Phase 2: Fault Tolerance & Safe Caching**
14
- - Wrap catalog filesystem operations (`mkdirSync`, `writeFileSync`, `readFileSync`) in safe `try/catch`.
15
- - Add safe error catching in `lib/client.js` for `/dsh-cost-meter/state` fetch to prevent console error spam on DSH reload.
16
- - [ ] **Phase 3: Performance & Settings Reactivity**
17
- - Implement in-memory catalog index Map in `lib/index.js` for O(1) model rate lookups.
18
- - Subscribe to `settingsScope` updates in `lib/client.js` to dynamically refresh currency and USD rate without reload.
19
- - [ ] **Phase 4: Verification, Tests & Documentation**
20
- - Run regression test suite `npm test` and add tests for schema validation and safe catalog failure.
21
- - Update `docs/design/DESIGN.md` and release notes for v0.7.7.
22
- - [ ] **Phase 5: Release Gate & Production Rollout**
23
- - Test on MiniPC test server (`192.168.1.123:3082`).
24
- - Merge PR, tag v0.7.7, publish to npm & GitHub release.
25
- - Deploy to production web profile on MiniAI (`192.168.1.111:3080`).
19
+ ## 2. Checklist
20
+ - [ ] Bump version to 0.7.9 in `package.json`
21
+ - [ ] Add `useOpenRouter` and `refreshHours` fields to `CostMeterCard` in `lib/client.js`
22
+ - [ ] Update `en` and `ru` dictionaries for new fields
23
+ - [ ] Audit and refine service accesses in `lib/index.js` and `lib/client.js`
24
+ - [ ] Update `README.md`, `docs/README.ru.md`, `docs/README.zh.md`, and `docs/design/DESIGN.md`
25
+ - [ ] Add unit test verifying settings card field contract
26
+ - [ ] Run `npm test` (all tests passing)
27
+ - [ ] Conventional commit & push
28
+ - [ ] Gitea PR & merge
29
+ - [ ] Test on MiniPC test server (`192.168.1.123`)
30
+ - [ ] Install on production MiniAI (`192.168.1.111`) & verify
31
+ - [ ] Release v0.7.9 on GitHub & npm
32
+ - [ ] Close Issue #21 & cleanup worktree
package/lib/client.js CHANGED
@@ -49,6 +49,10 @@ window.__ModuleLoader__.load({
49
49
  'hint.currency': 'Currency symbol shown in the widget (e.g. $, ₽, €).',
50
50
  'hint.usdRate': 'Exchange rate: units of currency per 1 USD (e.g. 80 for ₽).',
51
51
  'hint.displayTimeZone': 'IANA timezone used for tariff peak/off-peak schedule.',
52
+ 'field.useOpenRouter': 'Use OpenRouter Catalog',
53
+ 'hint.useOpenRouter': 'Pull rates and discount windows automatically from the OpenRouter catalog.',
54
+ 'field.refreshHours': 'Catalog Refresh Interval (hours)',
55
+ 'hint.refreshHours': 'How often to fetch fresh model rates from OpenRouter (default: 24).',
52
56
  }
53
57
 
54
58
  const ru = {
@@ -79,6 +83,10 @@ window.__ModuleLoader__.load({
79
83
  'hint.currency': 'Символ валюты, отображаемый в виджете (например, ₽, $, €).',
80
84
  'hint.usdRate': 'Сколько единиц валюты в 1 долларе США (например, 80 для ₽).',
81
85
  'hint.displayTimeZone': 'IANA таймзона для расчёта часов пика и не-пика (например, Europe/Moscow).',
86
+ 'field.useOpenRouter': 'Каталог OpenRouter',
87
+ 'hint.useOpenRouter': 'Автоматически загружать тарифы и скидочные окна из каталога OpenRouter.',
88
+ 'field.refreshHours': 'Интервал обновления каталога (часы)',
89
+ 'hint.refreshHours': 'Как часто обновлять ставки из OpenRouter (по умолчанию: 24).',
82
90
  }
83
91
 
84
92
  let ChevronIcon = null
@@ -118,6 +126,8 @@ window.__ModuleLoader__.load({
118
126
  '.dcm-sub { color:var(--dsw-alias-label-secondary); font-size:13px } ' +
119
127
  '.dcm-body { border-top:1px solid var(--dsw-alias-border-l2); margin:0 16px; padding-bottom:8px } ' +
120
128
  '.dcm-field { display:flex; flex-direction:column; gap:6px; padding:12px 0 } ' +
129
+ '.dcm-row-check { display:flex; align-items:center; gap:8px; padding:12px 0 } ' +
130
+ '.dcm-check { width:16px; height:16px; accent-color:var(--dsw-alias-brand-primary, #6366f1); cursor:pointer } ' +
121
131
  '.dcm-label { color:var(--dsw-alias-label-primary); font-size:13px; font-weight:500 } ' +
122
132
  '.dcm-hint { color:var(--dsw-alias-label-secondary); font-size:12px } ' +
123
133
  '.dcm-input { height:34px; border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-3); color:var(--dsw-alias-label-primary); border-radius:8px; padding:0 12px; font-size:13px } ' +
@@ -149,9 +159,10 @@ window.__ModuleLoader__.load({
149
159
  }
150
160
 
151
161
  function useActiveLocale(ctx) {
162
+ const localeService = (ctx && typeof ctx.get === 'function' ? ctx.get('locale') : null) || (ctx && ctx.locale) || (ctx && ctx.services && ctx.services.locale)
152
163
  return React.useSyncExternalStore(
153
- React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
154
- React.useCallback(() => (ctx && ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot().active : 'ru'), [ctx]),
164
+ React.useMemo(() => (cb) => (localeService && typeof localeService.subscribe === 'function' ? localeService.subscribe(cb) : () => {}), [localeService]),
165
+ React.useCallback(() => (localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot().active : 'ru'), [localeService]),
155
166
  React.useCallback(() => 'ru', []),
156
167
  )
157
168
  }
@@ -166,9 +177,10 @@ window.__ModuleLoader__.load({
166
177
  const [err, setErr] = React.useState('')
167
178
  const [saved, setSaved] = React.useState(false)
168
179
 
180
+ const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
169
181
  const scope = React.useMemo(
170
- () => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
171
- [ctx],
182
+ () => (settingsScope ? settingsScope.bind({ namespace: NS }) : undefined),
183
+ [settingsScope],
172
184
  )
173
185
 
174
186
  const snapshot = React.useSyncExternalStore(
@@ -188,6 +200,8 @@ window.__ModuleLoader__.load({
188
200
  currency: stored.currency !== undefined ? String(stored.currency) : '$',
189
201
  usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
190
202
  displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
203
+ useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
204
+ refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
191
205
  })
192
206
  }
193
207
  }, [status, stored, draft])
@@ -209,9 +223,27 @@ window.__ModuleLoader__.load({
209
223
  } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
210
224
  }
211
225
 
226
+ const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
227
+ if (!isValidTimeZone(tzVal)) {
228
+ broken.push('displayTimeZone: invalid IANA time zone')
229
+ } else {
230
+ try {
231
+ await scope.set('displayTimeZone', tzVal)
232
+ } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
233
+ }
234
+
212
235
  try {
213
- await scope.set('displayTimeZone', String(draft.displayTimeZone || 'Europe/Moscow').trim())
214
- } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
236
+ await scope.set('useOpenRouter', Boolean(draft.useOpenRouter))
237
+ } catch (e) { broken.push('useOpenRouter: ' + (e && e.message || String(e))) }
238
+
239
+ const hoursNum = Number(String(draft.refreshHours).trim())
240
+ if (!Number.isFinite(hoursNum) || hoursNum <= 0) {
241
+ broken.push('refreshHours: must be a positive number')
242
+ } else {
243
+ try {
244
+ await scope.set('refreshHours', hoursNum)
245
+ } catch (e) { broken.push('refreshHours: ' + (e && e.message || String(e))) }
246
+ }
215
247
 
216
248
  setSaving(false)
217
249
  if (broken.length) {
@@ -268,6 +300,28 @@ window.__ModuleLoader__.load({
268
300
  }),
269
301
  React.createElement('span', { className: 'dcm-hint' }, t('hint.displayTimeZone')),
270
302
  ),
303
+ React.createElement('div', { className: 'dcm-row-check' },
304
+ React.createElement('input', {
305
+ id: 'dcm-use-openrouter',
306
+ type: 'checkbox',
307
+ className: 'dcm-check',
308
+ checked: draft ? Boolean(draft.useOpenRouter) : true,
309
+ onChange: (e) => setDraft({ ...draft, useOpenRouter: e.target.checked }),
310
+ }),
311
+ React.createElement('label', { htmlFor: 'dcm-use-openrouter', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.useOpenRouter')),
312
+ ),
313
+ React.createElement('span', { className: 'dcm-hint', style: { marginTop: -4, display: 'block', paddingBottom: 10 } }, t('hint.useOpenRouter')),
314
+ React.createElement('div', { className: 'dcm-field' },
315
+ React.createElement('label', { className: 'dcm-label' }, t('field.refreshHours')),
316
+ React.createElement('input', {
317
+ className: 'dcm-input',
318
+ type: 'number',
319
+ step: 'any',
320
+ value: (draft && draft.refreshHours) !== undefined ? draft.refreshHours : '',
321
+ onChange: (e) => setDraft({ ...draft, refreshHours: e.target.value }),
322
+ }),
323
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.refreshHours')),
324
+ ),
271
325
  err ? React.createElement('div', { className: 'dcm-msg-err', style: { padding: '4px 0' } }, err) : null,
272
326
  saved ? React.createElement('div', { className: 'dcm-msg-ok', style: { padding: '4px 0' } }, t('settings.saved')) : null,
273
327
  React.createElement('div', { className: 'dcm-foot' },
@@ -385,6 +439,16 @@ window.__ModuleLoader__.load({
385
439
  return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
386
440
  }
387
441
 
442
+ function isValidTimeZone(tz) {
443
+ if (!tz || typeof tz !== 'string') return false
444
+ try {
445
+ new Intl.DateTimeFormat(undefined, { timeZone: tz })
446
+ return true
447
+ } catch {
448
+ return false
449
+ }
450
+ }
451
+
388
452
  /**
389
453
  * Render a UTC minute-of-day in the display zone. Built from a real Date so
390
454
  * the zone's current offset (and any DST) comes from Intl, not arithmetic.
@@ -472,7 +536,7 @@ window.__ModuleLoader__.load({
472
536
  const [open, setOpen] = React.useState(false)
473
537
  const [now, setNow] = React.useState(() => new Date())
474
538
  const box = React.useRef(null)
475
- const settingsScope = ctx.settingsScope || (ctx.services && ctx.services.settingsScope)
539
+ const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
476
540
  const settingsSnap = React.useSyncExternalStore
477
541
  ? React.useSyncExternalStore(
478
542
  (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}),
@@ -491,14 +555,18 @@ window.__ModuleLoader__.load({
491
555
  m.usage.cacheReadTokens || m.usage.cacheWriteTokens)
492
556
  const usedKeys = used.map((m) => m.provider + '/' + m.model).sort().join(',')
493
557
 
558
+ const reqSeq = React.useRef(0)
559
+
494
560
  const load = React.useCallback((keys) => {
561
+ const seq = ++reqSeq.current
495
562
  const query = keys ? '?models=' + encodeURIComponent(keys) : ''
496
563
  fetch(STATE_PATH + query, { headers: { accept: 'application/json' } })
497
564
  .then((r) => (r.ok ? r.json() : null))
498
- .then((data) => { if (data) setState(data) })
565
+ .then((data) => {
566
+ if (data && seq === reqSeq.current) setState(data)
567
+ })
499
568
  .catch(() => {})
500
569
  }, [])
501
-
502
570
  // Refetch when the set of billed models changes — a new model needs its
503
571
  // own tariff before its tokens can be priced.
504
572
  React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
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 parsed = splitRoute(key.trim())
512
- if (!parsed) continue
513
- const resolved = resolveTariff(cfg, catalog.index, parsed)
514
- schedules[key.trim()] = {
515
- schedule: resolved.schedule,
516
- source: resolved.source,
517
- matchedId: resolved.matchedId,
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.7",
3
+ "version": "0.7.9",
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",