@goodandready/dsh-cost-meter 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,3 +138,10 @@ npm test
138
138
  ## 📄 License
139
139
 
140
140
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
141
+
142
+ ### Performance & Smart Cost Features (v0.8.1)
143
+ - **O(K) Rate Change Calculation**: Next schedule transition is resolved in $O(K)$ boundary hops instead of full-day iterations.
144
+ - **Server Cache & ETag**: High-throughput state polling with `304 Not Modified` support and internal tariff LRU cache.
145
+ - **Manual Catalog Sync**: On-demand catalog fetch via `POST /dsh-cost-meter/refresh` or UI "Sync Now" button.
146
+ - **Savings Advice & Budget Threshold**: Popover hints on impending off-peak discounts (50% off) and optional warning thresholds (`budgetThreshold`).
147
+ - **Quick Summary Export**: Instant markdown/text summary copy to clipboard.
package/docs/README.ru.md CHANGED
@@ -138,3 +138,9 @@ npm test
138
138
  ## 📄 Лицензия
139
139
 
140
140
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
141
+
142
+ ### Производительность и интеллектуальные подсказки (v0.8.1)
143
+ - **Алгоритм O(K)**: Моментальный расчёт границ смены тарифов и мемоизация экстремумов без лишних циклов в браузере.
144
+ - **Серверный кэш и ETag**: Поддержка заголовка `304 Not Modified` для маршрута `/state` и кэширование сопоставлений моделей.
145
+ - **Ручная синхронизация**: Кнопка «Синхронизировать сейчас» в настройках с защитой от флуда (`POST /dsh-cost-meter/refresh`).
146
+ - **Подсказки экономии и порог бюджета**: Отсчёт времени до льготного окна (скидка 50%), порог сессии (`budgetThreshold`) и экспорт сводки в буфер обмена.
@@ -86,6 +86,15 @@
86
86
  - Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
87
87
 
88
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).
89
98
  - 2026-09-05 (v0.7.9):
90
99
  - Hotfix: исправлен ReferenceError (t is not defined) в объекте словаря ru (lib/client.js) (#15).
91
100
  - Добавлен автоматический тест на чистое исполнение фабрики client loader (`test/public-route.test.mjs`).
@@ -1,13 +1,17 @@
1
- # Findings: Issue #23
2
-
3
- ## Архитектурные паттерны dsh-clinebot
4
- - Изолированный тег `<style id="dsh-cost-meter-unified-css" data-dsh-plugin="dsh-cost-meter">` с переменными `--dsw-alias-...`.
5
- - `createErrorBoundary()` защищает слоты от падений при сбоях рендеринга.
6
- - `refreshMirrorUntilVisible(ctx)` обеспечивает доступность зеркала настроек в `settingsScope`.
7
- - Бейджи статуса (`cb-badge-ok`, `cb-badge-warn`, `cb-badge-bad`) позволяют пользователю моментально оценить состояние плагина.
8
-
9
- ## Найденные баги в dsh-cost-meter
10
- 1. Строка 427 `lib/index.js`: `catalog = { index: { byId: {}, bySuffix: {} }, fetchedAt: 0, error: null }` не содержала `byLowerId`. При обращении к `matchModel` до первой загрузки каталога вызывался `index.byLowerId[lower]`, что приводило к `TypeError`.
11
- 2. `slotOf(event.time)`: при `undefined` возвращал `NaN`, повреждая ключи слотов в проекции.
12
- 3. `fetch(CATALOG_URL)`: без таймаута мог блокировать цикл обновлений при сетевых лагах.
13
- 4. `splitRoute`: отбрасывал модели без слэша.
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 тариф.
@@ -1,25 +1,12 @@
1
- <!-- id: issue-23-progress -->
2
- # Progress: Issue #23 — Стабилизация кода и редизайн UI по стандарту dsh-clinebot
1
+ # Progress: Issue #25 (v0.8.1)
3
2
 
4
- - [x] Создана Issue #23 в Gitea
5
- - [x] Создан worktree `.worktrees/issue-23` на ветке `feat/stability-and-ui-clinebot`
6
- - [x] Инициализированы файлы планирования (`task_plan.md`, `findings.md`, `progress.md`)
7
- - [x] Применены серверные исправления (`lib/index.js`):
8
- - [x] Инициализация `byLowerId` в начальном состоянии `catalog.index` (устранён крах холодного старта)
9
- - [x] Защита `slotOf(timeMs)` от `NaN` при невалидных временных метках
10
- - [x] Добавлен таймаут (15с) для внешних запросов к OpenRouter
11
- - [x] Расширен `splitRoute` для поддержки моделей без слэша (`deepseek-chat`)
12
- - [x] Экспорт `catalogModelsCount` в ответе `/dsh-cost-meter/state`
13
- - [x] Применён визуальный редизайн по стандарту `dsh-clinebot` (`lib/client.js`):
14
- - [x] Внедрён `createErrorBoundary()` для защиты слотов от падений
15
- - [x] Внедрён `refreshMirrorUntilVisible(ctx)` для синхронизации настроек
16
- - [x] Внедрена нативная CSS-система дизайн-токенов (`--dsw-alias-*`)
17
- - [x] Шапка карточки снабжена бейджами статуса каталога, тарифа и таймзоны
18
- - [x] Настройки структурированы по 3 секциям (`Основные параметры`, `Каталог OpenRouter`, `Диагностика`)
19
- - [x] Модернизирован поповер шапки сессии
20
- - [x] Обновлена документация (`DESIGN.md`, `README.md`, `docs/README.ru.md`)
21
- - [x] Поднята версия до `0.7.10` в `package.json`
22
- - [x] Написаны и успешно пройдены 16 тестов в `test/stability-and-visual.test.mjs` (`npm test` 16/16 pass)
23
- - [ ] Оформление коммита, push ветки и создание PR #24
24
- - [ ] Merge PR в `main` и сборка тарбола
25
- - [ ] Тестирование на проде, публикация релиза v0.7.10 и закрытие Issue #23
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`.
@@ -1,25 +1,31 @@
1
- # Task Plan: Issue #23 — Стабилизация кода и редизайн UI карточки настроек по стандарту dsh-clinebot
1
+ # Task Plan: Issue #25 (v0.8.1) — Оптимизация производительности, ручная синхронизация каталога и подсказки экономии
2
2
 
3
- ## Цель
4
- Устранить дефекты стабильности (холодный старт, event.time, таймаут OpenRouter, модели без слэша) и обновить UI плагина до единого стандарта dsh-clinebot (ErrorBoundary, дизайн-токены, бейджи, секции, адаптивная сетка, поповер).
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 на прод.
5
21
 
6
- ## Задачи
7
- - [ ] 1. Исправление серверной части (`lib/index.js`):
8
- - [ ] Инициализация `byLowerId` в начальном состоянии каталога
9
- - [ ] Валидация `timeMs` в `slotOf(timeMs)`
10
- - [ ] Добавление таймаута (15с) для запросов к `openrouter.ai`
11
- - [ ] Поддержка моделей без префикса в `splitRoute`
12
- - [ ] Экспорт диагностических метаданных (`catalogModelsCount`, `fetchedAt`, `error`) в `/dsh-cost-meter/state`
13
- - [ ] 2. Обновление клиентской части (`lib/client.js`):
14
- - [ ] Внедрение `createErrorBoundary()` и обёртка карточки и поповера
15
- - [ ] Внедрение `refreshMirrorUntilVisible(ctx)`
16
- - [ ] Единая CSS-система дизайн-токенов (`.dcm-section-card`, `.dcm-badge`, `.dcm-grid-2`, `.dcm-btn`, `.dcm-alert-*`)
17
- - [ ] Структурирование настроек по 3 логическим секциям с бейджами статуса в шапке
18
- - [ ] Модернизация поповера шапки на классы дизайн-системы
19
- - [ ] Локализация новых ключей (`ru`, `en`)
20
- - [ ] 3. Обновление документации:
21
- - [ ] Актуализация `docs/design/DESIGN.md`, `README.md`, `docs/README.ru.md`
22
- - [ ] 4. Тестирование:
23
- - [ ] Написание юнит-тестов `test/stability-and-visual.test.mjs`
24
- - [ ] Прогон всех тестов (`npm test`)
25
- - [ ] 5. Сборка, PR, релиз v0.7.10, верификация на MiniAI и закрытие Issue #23
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
package/lib/client.js CHANGED
@@ -11,6 +11,7 @@ window.__ModuleLoader__.load({
11
11
 
12
12
  const NS = 'dsh-cost-meter'
13
13
  const STATE_PATH = '/dsh-cost-meter/state'
14
+ const REFRESH_PATH = '/dsh-cost-meter/refresh'
14
15
  const DAY = 1440
15
16
 
16
17
  // ----------------------------------------------------------- localization
@@ -24,9 +25,10 @@ window.__ModuleLoader__.load({
24
25
  'badge.catalog_empty': 'Каталог: загрузка…',
25
26
  'badge.catalog_off': 'Каталог: выключен',
26
27
  'badge.catalog_err': 'Каталог: ошибка',
28
+ 'badge.catalog_stale': 'Кэш: офлайн',
27
29
  'badge.deepseek': 'Тариф DeepSeek: активен',
28
30
  'section.general': 'Основные параметры',
29
- 'section.general_desc': 'Отображение валюты, курса пересчёта и часового пояса в интерфейсе',
31
+ 'section.general_desc': 'Отображение валюты, курса пересчёта, лимита бюджета и часового пояса',
30
32
  'section.openrouter': 'Каталог цен OpenRouter',
31
33
  'section.openrouter_desc': 'Публичный каталог моделей для автоматического определения ставок и окон скидок',
32
34
  'section.diagnostics': 'Диагностика и статус',
@@ -39,12 +41,23 @@ window.__ModuleLoader__.load({
39
41
  'hint.currency': 'Символ валюты для отображения расходов (например, $, ₽, €).',
40
42
  'field.usdRate': 'Курс валюты к USD',
41
43
  'hint.usdRate': 'Количество единиц вашей валюты за 1 USD (1 для долларов).',
44
+ 'field.budgetThreshold': 'Порог бюджета сессии (USD)',
45
+ 'hint.budgetThreshold': 'Оповещать цветом чипа при превышении суммы за сессию (0 — выключено).',
42
46
  'field.displayTimeZone': 'Временная зона (IANA)',
43
47
  'hint.displayTimeZone': 'Временная зона для отображения пиковых окон (например, Europe/Moscow, UTC).',
44
48
  'field.useOpenRouter': 'Использовать каталог OpenRouter',
45
49
  'hint.useOpenRouter': 'Автоматически загружать ставки и скидочные окна для моделей через OpenRouter API.',
46
50
  'field.refreshHours': 'Период обновления каталога (часов)',
47
51
  'hint.refreshHours': 'Интервал фонового обновления каталога в часах (от 1 до 168).',
52
+ 'action.sync_now': 'Синхронизировать сейчас',
53
+ 'action.syncing': 'Синхронизация…',
54
+ 'action.sync_ok': 'Каталог обновлён',
55
+ 'action.sync_err': 'Ошибка синхронизации',
56
+ 'action.sync_rate_limited': 'Подождите {seconds} сек',
57
+ 'action.copy_report': 'Скопировать сводку',
58
+ 'action.copied': 'Скопировано!',
59
+ 'savings.hint': '💡 Скидка 50% начнётся через {time} ({clock} {zone})',
60
+ 'budget.exceeded': '⚠️ Превышен лимит сессии: {limit}',
48
61
  'settings.save': 'Сохранить настройки',
49
62
  'settings.saving': 'Сохранение…',
50
63
  'settings.saved': 'Настройки успешно сохранены',
@@ -68,9 +81,10 @@ window.__ModuleLoader__.load({
68
81
  'badge.catalog_empty': 'Catalog: loading…',
69
82
  'badge.catalog_off': 'Catalog: disabled',
70
83
  'badge.catalog_err': 'Catalog: error',
84
+ 'badge.catalog_stale': 'Cache: offline',
71
85
  'badge.deepseek': 'DeepSeek tariff: active',
72
86
  'section.general': 'General Parameters',
73
- 'section.general_desc': 'Display currency, conversion rate and time zone configuration',
87
+ 'section.general_desc': 'Display currency, conversion rate, budget threshold and time zone configuration',
74
88
  'section.openrouter': 'OpenRouter Pricing Catalog',
75
89
  'section.openrouter_desc': 'Public catalog for dynamic model pricing and discount windows',
76
90
  'section.diagnostics': 'Diagnostics & Status',
@@ -83,12 +97,23 @@ window.__ModuleLoader__.load({
83
97
  'hint.currency': 'Currency symbol shown in the UI (e.g. $, ₽, €).',
84
98
  'field.usdRate': 'USD Exchange Rate',
85
99
  'hint.usdRate': 'Units of currency per 1 USD (1 keeps USD).',
100
+ 'field.budgetThreshold': 'Session Budget Threshold (USD)',
101
+ 'hint.budgetThreshold': 'Warn via chip color when session spend exceeds this amount (0 disables).',
86
102
  'field.displayTimeZone': 'Time Zone (IANA)',
87
103
  'hint.displayTimeZone': 'IANA time zone for peak hours display (e.g. Europe/Moscow, UTC).',
88
104
  'field.useOpenRouter': 'Use OpenRouter Catalog',
89
105
  'hint.useOpenRouter': 'Pull rates and discount windows dynamically from public OpenRouter API.',
90
106
  'field.refreshHours': 'Catalog Refresh Interval (hours)',
91
107
  'hint.refreshHours': 'Background catalog fetch interval in hours (1 to 168).',
108
+ 'action.sync_now': 'Sync Now',
109
+ 'action.syncing': 'Syncing…',
110
+ 'action.sync_ok': 'Catalog updated',
111
+ 'action.sync_err': 'Sync failed',
112
+ 'action.sync_rate_limited': 'Wait {seconds}s',
113
+ 'action.copy_report': 'Copy Summary',
114
+ 'action.copied': 'Copied!',
115
+ 'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
116
+ 'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
92
117
  'settings.save': 'Save Settings',
93
118
  'settings.saving': 'Saving…',
94
119
  'settings.saved': 'Settings saved successfully',
@@ -159,11 +184,12 @@ window.__ModuleLoader__.load({
159
184
  .dcm-btn-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
160
185
  .dcm-btn-primary:hover:not(:disabled) { opacity: 0.9; }
161
186
  .dcm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
187
+ .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
162
188
 
163
189
  .dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
164
190
  .dcm-alert-ok { background: rgba(16,185,129,0.1); color: var(--dsw-alias-state-success-primary); }
165
191
  .dcm-alert-err { background: rgba(239,68,68,0.1); color: var(--dsw-alias-state-error-primary); }
166
- .dcm-banner-warn { padding: 10px 14px; border-radius: 8px; background: rgba(245,158,11,0.1); color: var(--dsw-alias-state-warning-primary); font-size: 13px; }
192
+ .dcm-tip-box { padding: 8px 12px; border-radius: 8px; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
167
193
 
168
194
  .dcm-stat-box { padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); display: flex; flex-direction: column; gap: 2px; }
169
195
  .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
@@ -173,6 +199,8 @@ window.__ModuleLoader__.load({
173
199
 
174
200
  .dcm-chip { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 6px; border: 1px solid transparent; cursor: pointer; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; line-height: 22px; transition: opacity .15s ease; }
175
201
  .dcm-chip:hover { opacity: 0.88; }
202
+ .dcm-chip-budget-warn { animation: dcm-pulse 2s infinite ease-in-out; }
203
+ @keyframes dcm-pulse { 0%, 100% { border-color: rgba(239,68,68,0.4); } 50% { border-color: rgba(239,68,68,0.9); } }
176
204
 
177
205
  .dcm-panel { position: absolute; top: 28px; right: 0; z-index: 40; width: 330px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); box-shadow: 0 10px 30px rgba(0,0,0,.28); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; font-variant-numeric: tabular-nums; }
178
206
  .dcm-panel-title { font-weight: 600; padding-bottom: 4px; font-size: 13px; }
@@ -318,6 +346,10 @@ window.__ModuleLoader__.load({
318
346
  const [saved, setSaved] = React.useState(false)
319
347
  const [diagState, setDiagState] = React.useState(null)
320
348
 
349
+ // Manual sync button state
350
+ const [syncing, setSyncing] = React.useState(false)
351
+ const [syncMsg, setSyncMsg] = React.useState('')
352
+
321
353
  const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
322
354
  const scope = React.useMemo(
323
355
  () => (settingsScope ? settingsScope.bind({ namespace: NS }) : undefined),
@@ -332,14 +364,17 @@ window.__ModuleLoader__.load({
332
364
 
333
365
  React.useEffect(() => { ensureStyles() }, [])
334
366
 
335
- // Poll state for live diagnostics
336
- React.useEffect(() => {
337
- if (!open) return
367
+ const fetchDiag = React.useCallback(() => {
338
368
  fetch(STATE_PATH, { headers: { accept: 'application/json' } })
339
369
  .then((r) => (r.ok ? r.json() : null))
340
370
  .then((data) => { if (data) setDiagState(data) })
341
371
  .catch(() => {})
342
- }, [open])
372
+ }, [])
373
+
374
+ React.useEffect(() => {
375
+ if (!open) return
376
+ fetchDiag()
377
+ }, [open, fetchDiag])
343
378
 
344
379
  const status = (snapshot && snapshot.status) || 'loading'
345
380
  const stored = (snapshot && snapshot.value) || {}
@@ -349,6 +384,7 @@ window.__ModuleLoader__.load({
349
384
  setDraft({
350
385
  currency: stored.currency !== undefined ? String(stored.currency) : '$',
351
386
  usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
387
+ budgetThreshold: stored.budgetThreshold !== undefined ? String(stored.budgetThreshold) : '0',
352
388
  displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
353
389
  useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
354
390
  refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
@@ -358,8 +394,33 @@ window.__ModuleLoader__.load({
358
394
 
359
395
  const tzValid = draft ? isValidTimeZone(draft.displayTimeZone) : true
360
396
  const rateValid = draft ? (!isNaN(Number(draft.usdRate)) && Number(draft.usdRate) > 0) : true
397
+ const budgetValid = draft ? (!isNaN(Number(draft.budgetThreshold)) && Number(draft.budgetThreshold) >= 0) : true
361
398
  const hoursValid = draft ? (!isNaN(Number(draft.refreshHours)) && Number(draft.refreshHours) >= 1 && Number(draft.refreshHours) <= 168) : true
362
399
 
400
+ const handleManualSync = async () => {
401
+ if (syncing) return
402
+ setSyncing(true)
403
+ setSyncMsg('')
404
+ try {
405
+ const res = await fetch(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
406
+ if (res.status === 429) {
407
+ const data = await res.json().catch(() => ({}))
408
+ const retryAfter = data.retryAfter || 30
409
+ setSyncMsg(t('action.sync_rate_limited', { seconds: retryAfter }))
410
+ } else if (res.ok) {
411
+ setSyncMsg(t('action.sync_ok'))
412
+ fetchDiag()
413
+ } else {
414
+ setSyncMsg(t('action.sync_err'))
415
+ }
416
+ } catch (_) {
417
+ setSyncMsg(t('action.sync_err'))
418
+ } finally {
419
+ setSyncing(false)
420
+ setTimeout(() => setSyncMsg(''), 4000)
421
+ }
422
+ }
423
+
363
424
  const save = async () => {
364
425
  if (!scope || !draft) return
365
426
  setSaving(true); setErr(''); setSaved(false)
@@ -378,6 +439,15 @@ window.__ModuleLoader__.load({
378
439
  } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
379
440
  }
380
441
 
442
+ const budgetNum = Number(String(draft.budgetThreshold).trim())
443
+ if (!Number.isFinite(budgetNum) || budgetNum < 0) {
444
+ broken.push('budgetThreshold: must be non-negative')
445
+ } else {
446
+ try {
447
+ await scope.set('budgetThreshold', budgetNum)
448
+ } catch (e) { broken.push('budgetThreshold: ' + (e && e.message || String(e))) }
449
+ }
450
+
381
451
  const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
382
452
  if (!isValidTimeZone(tzVal)) {
383
453
  broken.push('displayTimeZone: invalid IANA time zone')
@@ -432,6 +502,7 @@ window.__ModuleLoader__.load({
432
502
  ? React.createElement('span', { className: 'dcm-badge dcm-badge-ok' }, t('badge.catalog_ok', { count: modelsCount }))
433
503
  : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_empty'))))
434
504
  : React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_off')),
505
+ catalogHasErr && modelsCount ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' }, t('badge.catalog_stale')) : null,
435
506
  draft && draft.displayTimeZone
436
507
  ? React.createElement('span', { className: 'dcm-badge' }, draft.displayTimeZone)
437
508
  : null,
@@ -471,25 +542,48 @@ window.__ModuleLoader__.load({
471
542
  React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
472
543
  ),
473
544
  ),
474
- React.createElement('div', { className: 'dcm-field' },
475
- React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
476
- React.createElement('input', {
477
- className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
478
- value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
479
- onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
480
- }),
481
- React.createElement('span', { className: 'dcm-hint' },
482
- tzValid
483
- ? t('hint.displayTimeZone')
484
- : 'Некорректный идентификатор временной зоны IANA'
545
+ React.createElement('div', { className: 'dcm-grid-2' },
546
+ React.createElement('div', { className: 'dcm-field' },
547
+ React.createElement('label', { className: 'dcm-label' }, t('field.budgetThreshold')),
548
+ React.createElement('input', {
549
+ className: 'dcm-input' + (budgetValid ? '' : ' dcm-input-err'),
550
+ type: 'number',
551
+ step: 'any',
552
+ min: 0,
553
+ value: (draft && draft.budgetThreshold) !== undefined ? draft.budgetThreshold : '',
554
+ onChange: (e) => setDraft({ ...draft, budgetThreshold: e.target.value }),
555
+ }),
556
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.budgetThreshold')),
557
+ ),
558
+ React.createElement('div', { className: 'dcm-field' },
559
+ React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
560
+ React.createElement('input', {
561
+ className: 'dcm-input' + (tzValid ? '' : ' dcm-input-err'),
562
+ value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
563
+ onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
564
+ }),
565
+ React.createElement('span', { className: 'dcm-hint' },
566
+ tzValid ? t('hint.displayTimeZone') : 'Некорректный идентификатор временной зоны IANA'
567
+ ),
485
568
  ),
486
569
  ),
487
570
  ),
488
571
 
489
572
  // Section 2: OpenRouter Pricing Catalog
490
573
  React.createElement('div', { className: 'dcm-section-card' },
491
- React.createElement('div', { className: 'dcm-section-title' }, t('section.openrouter')),
574
+ React.createElement('div', { className: 'dcm-section-title' },
575
+ React.createElement('span', null, t('section.openrouter')),
576
+ openRouterEnabled
577
+ ? React.createElement('button', {
578
+ type: 'button',
579
+ className: 'dcm-btn dcm-btn-sm',
580
+ disabled: syncing,
581
+ onClick: handleManualSync,
582
+ }, syncing ? t('action.syncing') : t('action.sync_now'))
583
+ : null
584
+ ),
492
585
  React.createElement('div', { className: 'dcm-section-desc' }, t('section.openrouter_desc')),
586
+ syncMsg ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok', style: { padding: '6px 10px', fontSize: 12 } }, syncMsg) : null,
493
587
  React.createElement('div', { className: 'dcm-row-check' },
494
588
  React.createElement('input', {
495
589
  id: 'dcm-use-openrouter',
@@ -554,7 +648,7 @@ window.__ModuleLoader__.load({
554
648
  React.createElement('button', {
555
649
  type: 'button',
556
650
  className: 'dcm-btn dcm-btn-primary',
557
- disabled: saving || !tzValid || !rateValid || !hoursValid,
651
+ disabled: saving || !tzValid || !rateValid || !budgetValid || !hoursValid,
558
652
  onClick: save,
559
653
  }, saving ? t('settings.saving') : t('settings.save')),
560
654
  ),
@@ -575,24 +669,55 @@ window.__ModuleLoader__.load({
575
669
  return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
576
670
  }
577
671
 
672
+ /**
673
+ * Fast O(K) lookup of next tariff rate boundary instead of a 1440-minute loop.
674
+ */
578
675
  function minutesUntilChange(schedule, minute) {
676
+ const windows = (schedule && schedule.windows) || []
677
+ if (!windows.length) return null
579
678
  const now = ratesAt(schedule, minute)
580
- for (let step = 1; step <= DAY; step += 1) {
581
- const probe = ratesAt(schedule, (minute + step) % DAY)
582
- if (probe.output !== now.output || probe.input !== now.input) return step
679
+ // Transition points can only occur at window start or window end
680
+ const transitions = new Set()
681
+ for (const w of windows) {
682
+ transitions.add(w.start % DAY)
683
+ transitions.add(w.end % DAY)
684
+ }
685
+ let minDiff = null
686
+ for (const t of transitions) {
687
+ let diff = (t - minute + DAY) % DAY
688
+ if (diff === 0) diff = DAY
689
+ const probe = ratesAt(schedule, (minute + diff) % DAY)
690
+ if (probe.output !== now.output || probe.input !== now.input) {
691
+ if (minDiff === null || diff < minDiff) minDiff = diff
692
+ }
583
693
  }
584
- return null
694
+ return minDiff
585
695
  }
586
696
 
697
+ /**
698
+ * Memoized calculation of low and high rate extremes for a schedule.
699
+ */
700
+ const extremesMemo = new WeakMap()
587
701
  function extremes(schedule) {
588
- let low = null
589
- let high = null
590
- for (let minute = 0; minute < DAY; minute += 1) {
591
- const r = ratesAt(schedule, minute)
592
- if (!low || r.output < low.output) low = r
593
- if (!high || r.output > high.output) high = r
702
+ if (!schedule) return { low: {}, high: {}, flat: true }
703
+ if (extremesMemo.has(schedule)) return extremesMemo.get(schedule)
704
+
705
+ const base = schedule.base || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
706
+ const candidates = [base]
707
+ for (const w of (schedule.windows || [])) {
708
+ if (w.rates) candidates.push(w.rates)
709
+ }
710
+
711
+ let low = candidates[0]
712
+ let high = candidates[0]
713
+ for (const r of candidates) {
714
+ if (r.output < low.output) low = r
715
+ if (r.output > high.output) high = r
594
716
  }
595
- return { low: low || {}, high: high || {}, flat: !low || !high || low.output === high.output }
717
+
718
+ const res = { low, high, flat: low.output === high.output }
719
+ extremesMemo.set(schedule, res)
720
+ return res
596
721
  }
597
722
 
598
723
  function sumSlots(slots) {
@@ -682,6 +807,7 @@ window.__ModuleLoader__.load({
682
807
  const [state, setState] = React.useState(null)
683
808
  const [open, setOpen] = React.useState(false)
684
809
  const [now, setNow] = React.useState(() => new Date())
810
+ const [copied, setCopied] = React.useState(false)
685
811
  const box = React.useRef(null)
686
812
 
687
813
  React.useEffect(() => { ensureStyles() }, [])
@@ -732,6 +858,7 @@ window.__ModuleLoader__.load({
732
858
  const stored = (settingsSnap && settingsSnap.value) || {}
733
859
  const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
734
860
  const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
861
+ const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
735
862
  const schedule = state.schedule
736
863
  const hasUsage = usage !== undefined && usage !== null
737
864
  const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
@@ -758,7 +885,8 @@ window.__ModuleLoader__.load({
758
885
  const active = ratesAt(schedule, minute)
759
886
  const { low, high, flat } = extremes(schedule)
760
887
  const cheapNow = !flat && active.output === low.output
761
- const left = clock(minutesUntilChange(schedule, minute))
888
+ const leftMinutes = minutesUntilChange(schedule, minute)
889
+ const left = clock(leftMinutes)
762
890
 
763
891
  const schedules = state.schedules || {}
764
892
  const perModel = used.map((m) => {
@@ -778,8 +906,16 @@ window.__ModuleLoader__.load({
778
906
  ? perModel.reduce((sum, m) => sum + m.cost, 0)
779
907
  : spendUsd(usage, active) * fx
780
908
 
781
- const fg = cheapNow ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warning-primary)'
782
- const bg = cheapNow ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)'
909
+ // Check budget threshold in USD
910
+ const totalUsd = fx > 0 ? total / fx : total
911
+ const budgetExceeded = budgetThreshold > 0 && totalUsd >= budgetThreshold
912
+
913
+ const fg = budgetExceeded
914
+ ? 'var(--dsw-alias-state-error-primary)'
915
+ : (cheapNow ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warning-primary)')
916
+ const bg = budgetExceeded
917
+ ? 'rgba(239,68,68,0.14)'
918
+ : (cheapNow ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)')
783
919
  const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: bg, color: fg, fontWeight: 600 }
784
920
 
785
921
  const rateRow = (label, key) => flat
@@ -820,13 +956,56 @@ window.__ModuleLoader__.load({
820
956
  .join(', ')
821
957
  const nowLocal = hhmmInZone(minute, zone, now)
822
958
 
959
+ // Copy textual report to clipboard
960
+ const copyReport = () => {
961
+ const lines = [
962
+ '=== ' + (state.route ? state.route.model : 'Cost Meter Report') + ' ===',
963
+ 'Сессия: ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (порог ' + money(budgetThreshold * fx, currency) + ')' : ''),
964
+ 'Тариф: ' + (cheapNow ? 'Льготный (не-пик)' : 'Полный (пик)'),
965
+ 'Ставки (1M): вход ' + rate(active.input) + ', выход ' + rate(active.output) + ', кэш ' + rate(active.cacheHit),
966
+ ]
967
+ if (hasUsage && usage) {
968
+ lines.push('Токены: вход ' + tokens(usage.inputTokens) + ', выход ' + tokens(usage.outputTokens) + ', кэш-хит ' + tokens(usage.cacheReadTokens))
969
+ }
970
+ const text = lines.join('\n')
971
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
972
+ navigator.clipboard.writeText(text).then(() => {
973
+ setCopied(true)
974
+ setTimeout(() => setCopied(false), 2000)
975
+ }).catch(() => {})
976
+ } else {
977
+ setCopied(true)
978
+ setTimeout(() => setCopied(false), 2000)
979
+ }
980
+ }
981
+
982
+ // Calculate upcoming discount hint
983
+ let discountHint = null
984
+ if (!flat && !cheapNow && leftMinutes) {
985
+ const nextTimeMinutes = (minute + leftMinutes) % DAY
986
+ const clockTime = hhmmInZone(nextTimeMinutes, zone, now)
987
+ discountHint = t('savings.hint', {
988
+ time: left,
989
+ clock: clockTime,
990
+ zone: zl,
991
+ })
992
+ }
993
+
823
994
  return h('div', { ref: box, style: { position: 'relative' } }, [
824
995
  h('button', {
825
996
  key: 'chip', type: 'button',
826
- className: 'dcm-chip',
827
- style: { background: bg, color: fg, borderColor: cheapNow ? 'rgba(16,185,129,0.3)' : 'rgba(245,158,11,0.3)' },
997
+ className: 'dcm-chip' + (budgetExceeded ? ' dcm-chip-budget-warn' : ''),
998
+ style: {
999
+ background: bg,
1000
+ color: fg,
1001
+ borderColor: budgetExceeded
1002
+ ? 'rgba(239,68,68,0.5)'
1003
+ : (cheapNow ? 'rgba(16,185,129,0.3)' : 'rgba(245,158,11,0.3)'),
1004
+ },
828
1005
  onClick: () => setOpen((v) => !v),
829
- title: flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф',
1006
+ title: budgetExceeded
1007
+ ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1008
+ : (flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф'),
830
1009
  }, [
831
1010
  h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
832
1011
  left ? h('span', { key: 'left', style: { opacity: 0.75 } }, left) : null,
@@ -848,6 +1027,11 @@ window.__ModuleLoader__.load({
848
1027
  : h('div', { key: 'clock', className: 'dcm-panel-note' },
849
1028
  'Пик: ' + windowText + ' ' + zl + ' · сейчас ' + nowLocal + ' ' + zl),
850
1029
 
1030
+ discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
1031
+
1032
+ budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1033
+ t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
1034
+
851
1035
  h('hr', { key: 'hr1', className: 'dcm-hr' }),
852
1036
 
853
1037
  h('div', { key: 'rates', className: flat ? 'dcm-panel-grid-flat' : 'dcm-panel-grid' }, [
@@ -934,6 +1118,14 @@ window.__ModuleLoader__.load({
934
1118
 
935
1119
  hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
936
1120
  'Провайдер ещё не сообщил usage для этой сессии.'),
1121
+
1122
+ h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
1123
+ h('button', {
1124
+ type: 'button',
1125
+ className: 'dcm-btn dcm-btn-sm',
1126
+ onClick: copyReport,
1127
+ }, copied ? t('action.copied') : t('action.copy_report'))
1128
+ ),
937
1129
  ]) : null,
938
1130
  ])
939
1131
  }
@@ -962,7 +1154,17 @@ window.__ModuleLoader__.load({
962
1154
  }
963
1155
  }
964
1156
 
965
- module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], CostMeterCard, CostMeter, ErrorBoundary, ensureStyles }
1157
+ module.exports = {
1158
+ apply,
1159
+ inject: ['slots', 'locale', 'settingsScope'],
1160
+ CostMeterCard,
1161
+ CostMeter,
1162
+ ErrorBoundary,
1163
+ ensureStyles,
1164
+ minutesUntilChange,
1165
+ extremes,
1166
+ ratesAt,
1167
+ }
966
1168
  return module.exports
967
1169
  },
968
1170
  })
package/lib/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // No agent hooks: the widget only reads, so it cannot corrupt a session.
15
15
 
16
+ import { createHash } from 'node:crypto'
16
17
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
18
  import { homedir } from 'node:os'
18
19
  import { dirname, join } from 'node:path'
@@ -24,6 +25,7 @@ export const inject = ['webServer']
24
25
 
25
26
  const NS = 'dsh-cost-meter'
26
27
  const STATE_PATH = '/dsh-cost-meter/state'
28
+ const REFRESH_PATH = '/dsh-cost-meter/refresh'
27
29
  const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
28
30
  const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
29
31
 
@@ -48,10 +50,21 @@ const DEEPSEEK_PEAK_RATES = {
48
50
  }
49
51
  const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
50
52
 
51
- // ponytail: a DeepSeek model reached THROUGH OpenRouter is still priced from
52
- // this table, though the bill comes from OpenRouter. Fix by classifying the
53
- // route (provider id / baseURL) before the model, and for a `:floor` slug take
54
- // the minimum over /models/{id}/endpoints rather than the base listing.
53
+ /** Common provider / vendor aliases for seamless catalog matching. */
54
+ export const VENDOR_ALIASES = {
55
+ 'z-ai': 'zai',
56
+ 'zai': 'zai',
57
+ 'google': 'google',
58
+ 'gemini': 'google',
59
+ 'deepseek-ai': 'deepseek',
60
+ 'deepseek-official': 'deepseek',
61
+ 'deepseek': 'deepseek',
62
+ 'anthropic-ai': 'anthropic',
63
+ 'anthropic': 'anthropic',
64
+ 'openai': 'openai',
65
+ 'meta-llama': 'meta-llama',
66
+ 'meta': 'meta-llama',
67
+ }
55
68
 
56
69
  export const Config = z.object({
57
70
  currency: z.string().description('Currency symbol shown in the widget.').default('$'),
@@ -60,6 +73,10 @@ export const Config = z.object({
60
73
  .string()
61
74
  .description('IANA zone the widget shows tariff hours in.')
62
75
  .default('Europe/Moscow'),
76
+ budgetThreshold: z
77
+ .number()
78
+ .description('Optional session budget threshold in USD. 0 disables threshold warnings.')
79
+ .default(0),
63
80
  deepseekPeakPrices: z
64
81
  .dict(PriceRow)
65
82
  .description('Override the built-in DeepSeek peak rates, by model id.')
@@ -146,9 +163,8 @@ function scheduleOf(entry) {
146
163
  }
147
164
 
148
165
  /**
149
- * Map a harness route onto an OpenRouter id. Exact id first, then the vendor
150
- * suffix (`deepseek-official/deepseek-v4-flash` -> `deepseek/deepseek-v4-flash`),
151
- * which is what the shared model names make possible.
166
+ * Map a harness route onto an OpenRouter id. Exact id first, then normalized
167
+ * vendor alias + model, then vendor suffix.
152
168
  */
153
169
  export function matchModel(index, route, modelMap) {
154
170
  if (!route || !index) return undefined
@@ -157,6 +173,15 @@ export function matchModel(index, route, modelMap) {
157
173
  if (mapped && index.byId && index.byId[mapped]) return mapped
158
174
  if (index.byId && index.byId[key]) return key
159
175
  if (index.byId && index.byId[route.model]) return route.model
176
+
177
+ // Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
178
+ const prov = String(route.provider || '').toLowerCase()
179
+ const aliasProv = VENDOR_ALIASES[prov]
180
+ if (aliasProv && route.model) {
181
+ const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
182
+ if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
183
+ }
184
+
160
185
  // Harness routes name models in their own casing (`MiniMax-M3`), the catalog
161
186
  // is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
162
187
  const lower = String(route.model).toLowerCase()
@@ -188,23 +213,10 @@ export function indexCatalog(models) {
188
213
 
189
214
  // ---------------------------------------------------------------------------
190
215
  // Per-model usage projection
191
- //
192
- // `tokenUsage` from dsh-token-meter totals the whole session, which prices
193
- // wrongly the moment the model changes mid-session. The log has what is
194
- // needed: `request/header` carries `header.config = {provider, model}`, and
195
- // usage arrives afterwards for that same step. So this unit keeps the last
196
- // header's route and files every usage sample under it.
197
- //
198
- // The replace-if-same-step guard mirrors token-meter: a streamed usage chunk
199
- // and the finalized assistant message report the same numbers for one step.
200
216
  // ---------------------------------------------------------------------------
201
217
 
202
218
  const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
203
219
 
204
- // Projection schemas are validated with ZOD, not schemastery: the registry
205
- // calls `schema.parse(view)` at the boundary, and a schemastery schema has no
206
- // `.parse`, which fails every history read with
207
- // "registration.def.schema.parse is not a function".
208
220
  const bucketSchema = zod.object({
209
221
  uncachedInputTokens: zod.number().nonnegative(),
210
222
  outputTokens: zod.number().nonnegative(),
@@ -246,10 +258,7 @@ function usageOf(event) {
246
258
  }
247
259
 
248
260
  /**
249
- * Half-hour slot of the UTC day an event falls into (0..47). Tariff windows are
250
- * expressed in UTC clock time, and every real schedule so far lands on a whole
251
- * or half hour, so 48 slots reproduce the rate exactly while keeping the state
252
- * small enough to ship on every projection frame.
261
+ * Half-hour slot of the UTC day an event falls into (0..47).
253
262
  */
254
263
  export function slotOf(timeMs) {
255
264
  let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
@@ -270,9 +279,6 @@ const usageByModelProjection = {
270
279
  models: zod.array(zod.object({
271
280
  provider: zod.string(),
272
281
  model: zod.string(),
273
- // Usage split by the half-hour of the UTC day it was spent in. Spend is
274
- // priced at the rate in force THEN; a later tariff switch must not
275
- // reprice tokens that are already paid for.
276
282
  slots: zod.record(zod.string(), bucketSchema),
277
283
  })),
278
284
  }).strict(),
@@ -292,8 +298,6 @@ const usageByModelProjection = {
292
298
  const key = route.provider + '/' + route.model
293
299
  const slot = String(slotOf(event.time))
294
300
  const buckets = bucketsFromUsage(sample.usage)
295
- // A streamed usage chunk and the finalized assistant message report the
296
- // same step twice; the second one replaces the first instead of adding.
297
301
  const previous =
298
302
  state.last !== null &&
299
303
  state.last.turn === sample.turn &&
@@ -344,12 +348,11 @@ function defaultRoute(ctx) {
344
348
 
345
349
  /**
346
350
  * Resolve one route's tariff: manual config wins, then DeepSeek's own table,
347
- * then the OpenRouter catalog. Returns `schedule: null` when nothing knows it,
348
- * so the widget can say so instead of pricing at zero.
351
+ * then the OpenRouter catalog.
349
352
  */
350
353
  export function resolveTariff(cfg, index, route) {
351
354
  if (!route) return { schedule: null, source: 'none', matchedId: null }
352
- const key = route.provider + '/' + route.model
355
+ const key = route.provider ? route.provider + '/' + route.model : route.model
353
356
  const prices = cfg.prices || {}
354
357
  const manual = prices[key] || prices[route.model] || prices['*']
355
358
 
@@ -380,15 +383,10 @@ export function resolveTariff(cfg, index, route) {
380
383
  return { schedule, source: 'manual', matchedId: key }
381
384
  }
382
385
 
383
- // Keyed by MODEL, not provider: a DeepSeek model is billed at DeepSeek's
384
- // published rate whoever routes it (deepseek-official, opencode-go, ollama).
385
- // Date/variant suffixes (`deepseek-v4-flash:0731`) name the same product.
386
- const providerLower = String(route.provider || "").toLowerCase();
387
- // #3: Provider-aware routing: if routed explicitly through OpenRouter,
388
- // consult OpenRouter catalog first to honor actual provider billing.
389
- if (providerLower === "openrouter" && cfg.useOpenRouter !== false) {
390
- const matchedId = matchModel(index, route, cfg.modelMap);
391
- if (matchedId) return { schedule: index.byId[matchedId], source: "openrouter", matchedId };
386
+ const providerLower = String(route.provider || '').toLowerCase()
387
+ if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
388
+ const matchedId = matchModel(index, route, cfg.modelMap)
389
+ if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
392
390
  }
393
391
 
394
392
  const base = String(route.model).toLowerCase().split(':')[0]
@@ -434,6 +432,25 @@ export function splitRoute(key) {
434
432
  export function apply(ctx, config) {
435
433
  let current = config
436
434
  let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
435
+ let lastManualRefreshAt = 0
436
+
437
+ // Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
438
+ const resolveCache = new Map()
439
+
440
+ function getCachedTariff(cfg, route) {
441
+ if (!route) return resolveTariff(cfg, catalog.index, route)
442
+ const cacheKey = (route.provider || '') + '/' + (route.model || '')
443
+ if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
444
+ const res = resolveTariff(cfg, catalog.index, route)
445
+ // Limit cache size to prevent runaway memory
446
+ if (resolveCache.size >= 500) resolveCache.clear()
447
+ resolveCache.set(cacheKey, res)
448
+ return res
449
+ }
450
+
451
+ function clearResolveCache() {
452
+ resolveCache.clear()
453
+ }
437
454
 
438
455
  // Disk cache survives restarts, so a boot without network still prices.
439
456
  try {
@@ -445,11 +462,11 @@ export function apply(ctx, config) {
445
462
  // absent or unreadable cache is normal on first run
446
463
  }
447
464
 
448
- async function refresh(signal) {
465
+ async function refresh(signal, force = false) {
449
466
  const cfg = current ?? config
450
467
  if (cfg.useOpenRouter === false) return
451
468
  const ageMs = Date.now() - catalog.fetchedAt
452
- if (catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
469
+ if (!force && catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
453
470
  try {
454
471
  const timeoutSignal = AbortSignal.timeout(15000)
455
472
  const combinedSignal = signal ? (AbortSignal.any ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal) : timeoutSignal
@@ -458,9 +475,9 @@ export function apply(ctx, config) {
458
475
  const body = await response.json()
459
476
  const models = Array.isArray(body && body.data) ? body.data : []
460
477
  if (models.length === 0) throw new Error('empty catalog')
461
- // Keep only what pricing needs — the raw payload is mostly prose.
462
478
  const slim = models.map((m) => ({ id: m.id, pricing: m.pricing }))
463
479
  catalog = { index: indexCatalog(slim), fetchedAt: Date.now(), error: null }
480
+ clearResolveCache()
464
481
  try {
465
482
  mkdirSync(dirname(CACHE_FILE), { recursive: true })
466
483
  writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
@@ -478,16 +495,17 @@ export function apply(ctx, config) {
478
495
  current = scope.get() ?? config
479
496
  const stop = scope.watch(() => {
480
497
  current = scope.get() ?? config
498
+ clearResolveCache()
481
499
  void refresh()
482
500
  })
483
501
  sctx.effect(() => () => {
484
502
  stop()
485
503
  current = config
504
+ clearResolveCache()
486
505
  })
487
506
  })
488
507
 
489
- // Optional capability: headless assemblies without the registry keep working,
490
- // the widget just falls back to the session-wide tokenUsage projection.
508
+ // Optional capability: headless assemblies without the registry keep working
491
509
  ctx.inject(['sessionProjections'], (pctx) => {
492
510
  pctx.sessionProjections.register(usageByModelProjection)
493
511
  })
@@ -502,6 +520,46 @@ export function apply(ctx, config) {
502
520
  }
503
521
  }, 'dsh-cost-meter: catalog refresh')
504
522
 
523
+ // Route 1: POST /dsh-cost-meter/refresh (manual on-demand sync with 30s cooldown)
524
+ ctx.effect(
525
+ () =>
526
+ ctx.webServer.register({
527
+ kind: 'exact',
528
+ path: REFRESH_PATH,
529
+ handler: async (req, res) => {
530
+ if (req.method !== 'POST') {
531
+ res.writeHead(405, { 'Content-Type': 'application/json' })
532
+ res.end(JSON.stringify({ error: 'method not allowed' }))
533
+ return
534
+ }
535
+ const now = Date.now()
536
+ const COOLDOWN_MS = 30000
537
+ if (now - lastManualRefreshAt < COOLDOWN_MS) {
538
+ const retryAfter = Math.ceil((COOLDOWN_MS - (now - lastManualRefreshAt)) / 1000)
539
+ res.writeHead(429, {
540
+ 'Content-Type': 'application/json',
541
+ 'Retry-After': String(retryAfter),
542
+ })
543
+ res.end(JSON.stringify({ error: 'rate limit exceeded', retryAfter }))
544
+ return
545
+ }
546
+ lastManualRefreshAt = now
547
+ const controller = new AbortController()
548
+ await refresh(controller.signal, true)
549
+ const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
550
+ res.writeHead(200, { 'Content-Type': 'application/json' })
551
+ res.end(JSON.stringify({
552
+ ok: !catalog.error,
553
+ fetchedAt: catalog.fetchedAt,
554
+ modelsCount,
555
+ error: catalog.error,
556
+ }))
557
+ },
558
+ }),
559
+ 'dsh-cost-meter: refresh route',
560
+ )
561
+
562
+ // Route 2: GET /dsh-cost-meter/state (with ETag and 304 Not Modified)
505
563
  ctx.effect(
506
564
  () =>
507
565
  ctx.webServer.register({
@@ -515,10 +573,8 @@ export function apply(ctx, config) {
515
573
  }
516
574
  const cfg = current ?? config
517
575
  const route = defaultRoute(ctx) ?? null
518
- const own = resolveTariff(cfg, catalog.index, route)
576
+ const own = getCachedTariff(cfg, route)
519
577
 
520
- // The client asks for every model the session actually used, so a
521
- // session that switched models can price each part on its own tariff.
522
578
  const schedules = {}
523
579
  const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
524
580
  for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
@@ -526,7 +582,7 @@ export function apply(ctx, config) {
526
582
  if (!trimmed || trimmed.length > 128) continue
527
583
  const parsed = splitRoute(trimmed)
528
584
  if (!parsed) continue
529
- const resolved = resolveTariff(cfg, catalog.index, parsed)
585
+ const resolved = getCachedTariff(cfg, parsed)
530
586
  schedules[trimmed] = {
531
587
  schedule: resolved.schedule,
532
588
  source: resolved.source,
@@ -534,11 +590,11 @@ export function apply(ctx, config) {
534
590
  }
535
591
  }
536
592
 
537
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
538
- res.end(JSON.stringify({
593
+ const payloadObj = {
539
594
  currency: cfg.currency,
540
595
  usdRate: cfg.usdRate,
541
596
  displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
597
+ budgetThreshold: Number(cfg.budgetThreshold) || 0,
542
598
  route,
543
599
  source: own.source,
544
600
  matchedId: own.matchedId,
@@ -547,7 +603,26 @@ export function apply(ctx, config) {
547
603
  catalogError: catalog.error,
548
604
  catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
549
605
  schedule: own.schedule,
550
- }))
606
+ }
607
+
608
+ const serialized = JSON.stringify(payloadObj)
609
+ const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
610
+
611
+ if (req.headers['if-none-match'] === etag) {
612
+ res.writeHead(304, {
613
+ 'ETag': etag,
614
+ 'Cache-Control': 'no-cache',
615
+ })
616
+ res.end()
617
+ return
618
+ }
619
+
620
+ res.writeHead(200, {
621
+ 'Content-Type': 'application/json',
622
+ 'Cache-Control': 'no-cache',
623
+ 'ETag': etag,
624
+ })
625
+ res.end(serialized)
551
626
  },
552
627
  }),
553
628
  'dsh-cost-meter: state route',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",