@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 +7 -0
- package/docs/README.ru.md +6 -0
- package/docs/design/DESIGN.md +9 -0
- package/docs/plans/findings.md +17 -13
- package/docs/plans/progress.md +11 -24
- package/docs/plans/task_plan.md +29 -23
- package/lib/client.js +240 -38
- package/lib/index.js +128 -53
- package/package.json +1 -1
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`) и экспорт сводки в буфер обмена.
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -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`).
|
package/docs/plans/findings.md
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
# Findings: Issue #
|
|
2
|
-
|
|
3
|
-
##
|
|
4
|
-
-
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
CHANGED
|
@@ -1,25 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
# Progress: Issue #23 — Стабилизация кода и редизайн UI по стандарту dsh-clinebot
|
|
1
|
+
# Progress: Issue #25 (v0.8.1)
|
|
3
2
|
|
|
4
|
-
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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`.
|
package/docs/plans/task_plan.md
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
# Task Plan: Issue #
|
|
1
|
+
# Task Plan: Issue #25 (v0.8.1) — Оптимизация производительности, ручная синхронизация каталога и подсказки экономии
|
|
2
2
|
|
|
3
|
-
##
|
|
4
|
-
|
|
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
|
-
- [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- [ ]
|
|
14
|
-
|
|
15
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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-
|
|
475
|
-
React.createElement('
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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' },
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
782
|
-
const
|
|
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: {
|
|
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:
|
|
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 = {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
150
|
-
*
|
|
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).
|
|
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.
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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",
|