@goodandready/dsh-cost-meter 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +14 -0
- package/docs/deployment/public-release.md +9 -0
- package/docs/design/DESIGN.md +82 -0
- package/docs/testing/public-route.md +12 -0
- package/lib/client.js +471 -0
- package/lib/index.js +530 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 goodandready
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# dsh-cost-meter
|
|
2
|
+
|
|
3
|
+
Чип стоимости сессии в шапке диалога DeepSeek Harness: живой расход по токенам,
|
|
4
|
+
текущий тариф (пик / не-пик) и обратный отсчёт до его смены.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
● ≈ ₽8.55 0:17 ← чип в conversation.session.header.utilities
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
По клику раскрывается панель: тарифная таблица за 1M токенов в двух колонках
|
|
11
|
+
(не-пик / пик, активная выделена), окна пика в UTC и итог за сессию.
|
|
12
|
+
|
|
13
|
+
## Откуда берутся цифры
|
|
14
|
+
|
|
15
|
+
**Токены** — из собственной проекции сессии `costByModel`, которую плагин
|
|
16
|
+
регистрирует тем же механизмом, что и штатный `@deepseek-ai/dsh-token-meter`.
|
|
17
|
+
В журнале есть всё нужное: `request/header` несёт `config: {provider, model}`,
|
|
18
|
+
а usage приходит следом за тем же шагом, поэтому каждая выборка относится к
|
|
19
|
+
модели, которая её заработала.
|
|
20
|
+
|
|
21
|
+
Накопление идёт не одной кучей, а по **получасовым слотам суток UTC**: расход
|
|
22
|
+
фиксируется по ставке, действовавшей в момент трат, и переключение тарифа не
|
|
23
|
+
пересчитывает уже потраченное. Все реальные окна выровнены по получасу, так что
|
|
24
|
+
слот воспроизводит ставку точно.
|
|
25
|
+
|
|
26
|
+
Сессионный слот отдаёт компоненту `useProjection`, поэтому значения обновляются
|
|
27
|
+
прямо во время стрима, без опроса.
|
|
28
|
+
|
|
29
|
+
**Цены** берутся из трёх источников, в таком порядке:
|
|
30
|
+
|
|
31
|
+
1. **Ручные `prices`** из конфига — для подписок и локальных моделей.
|
|
32
|
+
2. **Официальный прайс DeepSeek** — по имени модели, а не по провайдеру:
|
|
33
|
+
`deepseek-v4-flash` стоит столько, сколько объявил DeepSeek, кто бы её ни
|
|
34
|
+
маршрутизировал, а суффиксы версий (`deepseek-v4-flash:0731`) сводятся к той
|
|
35
|
+
же модели. Таблица зашита в плагин: у DeepSeek нет эндпоинта с
|
|
36
|
+
ценами (`/models` отдаёт только id, `/pricing` — 404), а записи `deepseek/*`
|
|
37
|
+
в OpenRouter — это чужой хостинг с другими ставками. Тариф двухуровневый:
|
|
38
|
+
пик 01:00–04:00 и 06:00–10:00 UTC, вне окон — ровно половина.
|
|
39
|
+
3. **Каталог OpenRouter** (`https://openrouter.ai/api/v1/models`, без ключа) —
|
|
40
|
+
для всех остальных. Оттуда четыре ставки (`prompt`, `completion`,
|
|
41
|
+
`input_cache_read`, `input_cache_write`) и, если есть, `pricing.overrides` —
|
|
42
|
+
окна скидок по времени суток. Каталог кэшируется на диск и обновляется раз в
|
|
43
|
+
сутки; без сети работает последний кэш.
|
|
44
|
+
|
|
45
|
+
Если модель не нашлась нигде, виджет пишет «тариф ?» и объясняет, что делать,
|
|
46
|
+
вместо того чтобы показать правдоподобный ноль.
|
|
47
|
+
|
|
48
|
+
Сам харнесс цен не знает и знать не хочет: у pi-ai есть поле `cost`, но оно
|
|
49
|
+
занулено, и в коде прямо сказано, что ни один потребитель не отчитывается о
|
|
50
|
+
тратах. Поэтому источник — внешний.
|
|
51
|
+
|
|
52
|
+
**Тариф** вычисляется на клиенте по часам UTC: активная ставка = подходящее
|
|
53
|
+
окно, иначе базовая. Если окон два — таблица показывает обе колонки, активная
|
|
54
|
+
подсвечена, рядом обратный отсчёт до смены. Часы выводятся в зоне
|
|
55
|
+
`displayTimeZone` (по умолчанию московской) через `Intl`, так что переход на
|
|
56
|
+
летнее время учитывается. Если окон нет — одна колонка «ставка» без подсветки.
|
|
57
|
+
|
|
58
|
+
## Установка из приватного registry
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
dsh plugin --profile web add @goodandready/dsh-cost-meter
|
|
62
|
+
sudo systemctl restart dsh-web
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Пакет публикуется в приватный GitHub Packages registry. Исходная разработка,
|
|
66
|
+
изолированная проверка и production-установка выполняются через Git и пакетный
|
|
67
|
+
маршрут; локальные установки из исходного checkout не являются поддерживаемым
|
|
68
|
+
способом развёртывания.
|
|
69
|
+
|
|
70
|
+
## Конфигурация
|
|
71
|
+
|
|
72
|
+
| Поле | По умолчанию | Значение |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `currency` | `$` | символ валюты в чипе |
|
|
75
|
+
| `usdRate` | `1` | сколько единиц валюты в одном USD; `1` — считать в долларах |
|
|
76
|
+
| `useOpenRouter` | `true` | тянуть ставки и окна скидок из каталога |
|
|
77
|
+
| `displayTimeZone` | `Europe/Moscow` | в какой зоне показывать часы тарифа |
|
|
78
|
+
| `deepseekPeakPrices` | `{}` | переопределить встроенные пиковые ставки DeepSeek |
|
|
79
|
+
| `refreshHours` | `24` | как часто обновлять каталог |
|
|
80
|
+
| `modelMap` | `{}` | сопоставление `провайдер/модель` → id модели в OpenRouter |
|
|
81
|
+
| `prices` | `{}` | ручные ставки по ключу `провайдер/модель` (или `*`); **перекрывают каталог** |
|
|
82
|
+
| `manualPeakWindowsUtc` | `[]` | окна пика `HH:MM-HH:MM` UTC — только для ручных ставок |
|
|
83
|
+
| `manualOffPeakMultiplier` | `1` | множитель вне окон — только для ручных ставок |
|
|
84
|
+
|
|
85
|
+
Обычно хватает валюты и курса:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
- id: dsh-cost-meter
|
|
89
|
+
config:
|
|
90
|
+
currency: '₽'
|
|
91
|
+
usdRate: 80
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Модель сопоставляется с каталогом по точному id, затем по суффиксу:
|
|
95
|
+
`deepseek-official/deepseek-v4-flash` → `deepseek/deepseek-v4-flash`. Если
|
|
96
|
+
угадано неверно или модели в каталоге нет — либо `modelMap`, либо ручные ставки:
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
modelMap:
|
|
100
|
+
'ollama/glm-5.2': 'z-ai/glm-5.2'
|
|
101
|
+
prices:
|
|
102
|
+
'opencode-go/minimax-m3': # подписка — считать бесплатным
|
|
103
|
+
cacheHit: 0
|
|
104
|
+
input: 0
|
|
105
|
+
cacheWrite: 0
|
|
106
|
+
output: 0
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Ограничения
|
|
110
|
+
|
|
111
|
+
- Модель DeepSeek, вызванная **через OpenRouter**, всё равно считается по
|
|
112
|
+
официальному прайсу DeepSeek, хотя счёт выставит OpenRouter. Помечено в коде.
|
|
113
|
+
- Данные приходят из usage, который присылает провайдер. Маршруты, не
|
|
114
|
+
сообщающие usage, дадут нули: в панели об этом сказано явно.
|
|
115
|
+
- Модели, для которых тариф не нашёлся, показываются отдельной строкой и в
|
|
116
|
+
сумму не входят — сумма честно неполная, а не занижена молча.
|
|
117
|
+
- Курс валюты — константа `usdRate`, живого курса плагин не тянет.
|
|
118
|
+
|
|
119
|
+
## Устройство
|
|
120
|
+
|
|
121
|
+
| Файл | Что делает |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `lib/index.js` | конфиг, проекция `costByModel`, тарифы, маршрут `GET /dsh-cost-meter/state` |
|
|
124
|
+
| `lib/client.js` | чип и панель; читает проекцию, считает арифметику по слотам |
|
|
125
|
+
| `cordis.patch.yml` | строка плагина в дереве загрузчика профиля |
|
|
126
|
+
|
|
127
|
+
Хост не вешает ни одного хука на ход агента — виджет только читает. Плагин,
|
|
128
|
+
который ничего не инжектит в сессию, не может её испортить.
|
|
129
|
+
|
|
130
|
+
## Лицензия
|
|
131
|
+
|
|
132
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# dsh-cost-meter bundle layer: inserts the plugin row into the profile's
|
|
2
|
+
# loader tree. Applied automatically because package.json declares
|
|
3
|
+
# `dsh.bundle.patch`. Prices and tariff windows are editable from the Web GUI
|
|
4
|
+
# (Settings -> Cost Meter) or here, e.g.:
|
|
5
|
+
#
|
|
6
|
+
# - id: dsh-cost-meter
|
|
7
|
+
# config:
|
|
8
|
+
# currency: '$'
|
|
9
|
+
# usdRate: 1
|
|
10
|
+
#
|
|
11
|
+
- insert:
|
|
12
|
+
- id: dsh-cost-meter
|
|
13
|
+
name: '@goodandready/dsh-cost-meter'
|
|
14
|
+
config: {}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Публичный релиз и deployment
|
|
2
|
+
|
|
3
|
+
Релизная версия повышается patch-изменением (v0.7.3). После merge в `main`
|
|
4
|
+
создаются annotated tag, пакет в npm (`@goodandready/dsh-cost-meter`) и
|
|
5
|
+
публичный GitHub release в `GooDAnDReaDY/dsh-cost-meter`.
|
|
6
|
+
|
|
7
|
+
Production должен содержать exact package version из npm и не должен иметь
|
|
8
|
+
локальной зависимости на исходный checkout. После restart проверяются
|
|
9
|
+
service health, browser loader и `GET /dsh-cost-meter/state`.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# DESIGN.md — dsh-cost-meter
|
|
2
|
+
|
|
3
|
+
## Product / Purpose
|
|
4
|
+
- Назначение: Отображение стоимости активной сессии диалога в реальном времени, тарифов (пик / не-пик / единый) за 1M токенов, обратного отсчёта до смены окна и прозрачной детализации расходов по каждой задействованной модели.
|
|
5
|
+
- Аудитория: Пользователи и инженеры DeepSeek Harness, контролирующие потребление токенов и бюджет на LLM-провайдеров.
|
|
6
|
+
- Статус: Активная публичная поставка (@goodandready/dsh-cost-meter).
|
|
7
|
+
|
|
8
|
+
## User Surfaces
|
|
9
|
+
- Web/UI:
|
|
10
|
+
- Компактный чип в слоте `conversation.session.header.utilities` (order: 10).
|
|
11
|
+
- Выпадающая панель детализации (width: 320px, absolute positioning, click-outside dismissal).
|
|
12
|
+
- DSH UI / settings / slots:
|
|
13
|
+
- Слот: `conversation.session.header.utilities`.
|
|
14
|
+
- Настройки: реестр DSH `settings.register("dsh-cost-meter", Config)` (валюта `currency`, множитель курса `usdRate`, таймзона `displayTimeZone`, опрос OpenRouter `useOpenRouter`, ручные ставки `prices`, сопоставление `modelMap`).
|
|
15
|
+
- API:
|
|
16
|
+
- HTTP `GET /dsh-cost-meter/state` (параметр `?models=...` для получения тарифов по задействованным моделям).
|
|
17
|
+
- CLI:
|
|
18
|
+
- Управление через CLI харнесса: `dsh plugin --profile web add @goodandready/dsh-cost-meter`.
|
|
19
|
+
- Документация:
|
|
20
|
+
- README.md, index.md, docs/testing/public-route.md, docs/deployment/public-release.md.
|
|
21
|
+
|
|
22
|
+
## Visual Direction
|
|
23
|
+
- Атмосфера: Строгий утилитарный нативный интерфейс DSH, высокая информационная плотность без визуального шума.
|
|
24
|
+
- Утверждённые референсы: Дизайн-система DeepSeek Harness (`--dsw-alias-*`), чипы в шапке сессии (`dsh-key-rotation`, `dsh-context-lens`).
|
|
25
|
+
- Не копировать: Избыточные градиенты, внешние модальные окна, тяжелые тени, кастомные несогласованные шрифты.
|
|
26
|
+
|
|
27
|
+
## Foundations
|
|
28
|
+
- Цвета и роли:
|
|
29
|
+
- `--dsw-alias-label-primary`: основной текст, заголовки, суммы.
|
|
30
|
+
- `--dsw-alias-label-tertiary`: второстепенные пояснения, метки столбцов, чип без тарифа.
|
|
31
|
+
- `--dsw-alias-border-l2`: разделители секций и обводка панели.
|
|
32
|
+
- `--dsw-alias-bg-layer-2` / `--dsw-alias-bg-base`: подложка всплывающей панели.
|
|
33
|
+
- `--dsw-alias-state-success-primary` / `--dsw-alias-state-success-tertiary`: подсветка льготного окна (не-пик).
|
|
34
|
+
- `--dsw-alias-state-warn-primary` / `--dsw-alias-state-warn-tertiary`: подсветка полного тарифа (пик).
|
|
35
|
+
- Типографика:
|
|
36
|
+
- Системный стек шрифтов DSH, размер 12px, интерлиньяж 1.5.
|
|
37
|
+
- Обязательно `fontVariantNumeric: "tabular-nums"` для всех числовых колонок, токенов и таймеров.
|
|
38
|
+
- Сетка и отступы:
|
|
39
|
+
- Чип: высота 22px, padding: 0 8px, gap: 6px, border-radius: 6px.
|
|
40
|
+
- Панель: padding: 12px 14px, border-radius: 10px, ширина 320px.
|
|
41
|
+
- Сетки: `grid` с выравниванием числовых колонок по правому краю (`text-align: right`).
|
|
42
|
+
- Accessibility:
|
|
43
|
+
- Семантические теги `button` для интерактивных элементов, нативные `title` подсказки при наведении, закрытие панели при клике вне компонента.
|
|
44
|
+
|
|
45
|
+
## Components And States
|
|
46
|
+
- Чип:
|
|
47
|
+
- `Loading / Init`: если данные ещё не загружены, чип скрыт (`null`).
|
|
48
|
+
- `Unknown Tariff`: отображает «тариф ?» в приглушённом цвете `label-tertiary`.
|
|
49
|
+
- `Flat Tariff`: фон `--dsw-alias-state-warn-tertiary`, текст `--dsw-alias-state-warn-primary`, заголовок «Единый тариф».
|
|
50
|
+
- `Off-Peak`: зелёная плашка успеха (льготный тариф).
|
|
51
|
+
- `Peak`: оранжевая плашка предупреждения (полный тариф).
|
|
52
|
+
- Таймер: время до смены тарифа с пониженной непрозрачностью (`opacity: 0.75`).
|
|
53
|
+
- Панель:
|
|
54
|
+
- Заголовок с текущей моделью и статусом окна.
|
|
55
|
+
- Сетка тарифов за 1M токенов (не-пик / пик / единая ставка).
|
|
56
|
+
- Блок «Из чего сложилась сумма»:
|
|
57
|
+
- Список всех моделей сессии с токенами и суммарной стоимостью.
|
|
58
|
+
- Для модели без тарифа отображается статус `не указана стоимость`.
|
|
59
|
+
- Итог сессии: `Сессия: ≈ <валюта><сумма>`.
|
|
60
|
+
- Отсутствие мусорных служебных плашек (строка источника OpenRouter исключена).
|
|
61
|
+
|
|
62
|
+
## User Flows
|
|
63
|
+
1. Пользователь ведёт диалог с ассистентом: чип в шапке инкрементально обновляет потраченную сумму прямо во время стриминга ответа ассистента.
|
|
64
|
+
2. Пользователь нажимает на чип: открывается всплывающая панель с тарифной сеткой и расходами по каждой модели сессии.
|
|
65
|
+
3. Если модель не тарифицирована: в списке моделей выводится статус `не указана стоимость`.
|
|
66
|
+
|
|
67
|
+
## Do / Don't
|
|
68
|
+
- Do:
|
|
69
|
+
- Использовать CSS-переменные DSH для согласованности с тёмной и светлой темами.
|
|
70
|
+
- Всегда разделять расходы по задействованным моделям.
|
|
71
|
+
- Использовать табличные цифры (`tabular-nums`) для предотвращения дёргания вёрстки при стриме.
|
|
72
|
+
- Don't:
|
|
73
|
+
- Не выводить техническую служебную информацию о каталоге OpenRouter в интерфейс пользователя.
|
|
74
|
+
- Не заменять отсутствие тарифа обманчивым нулём или невнятным прочерком — писать прямо `не указана стоимость`.
|
|
75
|
+
- Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
|
|
76
|
+
|
|
77
|
+
## History & Decisions
|
|
78
|
+
- 2026-09-05 (v0.7.3):
|
|
79
|
+
- Удалена строка источника цен OpenRouter.
|
|
80
|
+
- Список моделей в блоке «Из чего сложилась сумма» унифицирован и отображается для всех задействованных моделей сессии.
|
|
81
|
+
- Для нетарифицированных моделей введён явный статус «не указана стоимость».
|
|
82
|
+
- Пакет переведён на публичный scope `@goodandready/dsh-cost-meter`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Проверка публичного маршрута
|
|
2
|
+
|
|
3
|
+
1. Выполнить `npm test`.
|
|
4
|
+
2. Проверить `npm pack --dry-run` и отсутствие локальных установочных ссылок,
|
|
5
|
+
абсолютных infra-путей, IP-адресов и секретов в поставляемых файлах.
|
|
6
|
+
3. Собрать tarball из проверенного Git worktree.
|
|
7
|
+
4. Установить tarball на изолированный тестовый DSH web server (MiniPC).
|
|
8
|
+
5. Для browser half проверить loader id `@goodandready/dsh-cost-meter`, HTTP route
|
|
9
|
+
состояния `GET /dsh-cost-meter/state` и smoke-сценарий тарификации (список моделей,
|
|
10
|
+
расчёт стоимости, статус «не указана стоимость»); для host half — bundle identity и host smoke.
|
|
11
|
+
6. Выполнить cleanup тестовой установки и убедиться, что постоянные плагины
|
|
12
|
+
сохранили работоспособность.
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// dsh-cost-meter — browser half.
|
|
2
|
+
//
|
|
3
|
+
// One chip in `conversation.session.header.utilities` (kind: list, scope:
|
|
4
|
+
// session). Session-scoped slots hand the component `useProjection`, so token
|
|
5
|
+
// counts come from dsh-token-meter's `tokenUsage` projection and move while a
|
|
6
|
+
// turn streams. The tariff schedule arrives from the host over GET
|
|
7
|
+
// /dsh-cost-meter/state — a base rate card plus UTC discount windows.
|
|
8
|
+
//
|
|
9
|
+
// The client owns only the clock: which window is active now, when it flips,
|
|
10
|
+
// and which variant of the day is the cheap one.
|
|
11
|
+
//
|
|
12
|
+
// Not ESM: the shell loads this file as a lazy CJS factory. No import, no JSX.
|
|
13
|
+
window.__ModuleLoader__.load({
|
|
14
|
+
id: '@goodandready/dsh-cost-meter',
|
|
15
|
+
factory: (require) => {
|
|
16
|
+
var module = { exports: {} }
|
|
17
|
+
const React = require('react')
|
|
18
|
+
const h = React.createElement
|
|
19
|
+
|
|
20
|
+
const STATE_PATH = '/dsh-cost-meter/state'
|
|
21
|
+
const DAY = 1440
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------- tariff
|
|
24
|
+
|
|
25
|
+
function inWindow(minute, w) {
|
|
26
|
+
return w.start <= w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rates in force at a given UTC minute: the last matching window, else base. */
|
|
30
|
+
function ratesAt(schedule, minute) {
|
|
31
|
+
const windows = (schedule && schedule.windows) || []
|
|
32
|
+
for (const w of windows) if (inWindow(minute, w)) return w.rates
|
|
33
|
+
return (schedule && schedule.base) || { cacheHit: 0, input: 0, cacheWrite: 0, output: 0 }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Minutes until the rate card changes, or null when it never does. */
|
|
37
|
+
function minutesUntilChange(schedule, minute) {
|
|
38
|
+
const now = ratesAt(schedule, minute)
|
|
39
|
+
for (let step = 1; step <= DAY; step += 1) {
|
|
40
|
+
const probe = ratesAt(schedule, (minute + step) % DAY)
|
|
41
|
+
if (probe.output !== now.output || probe.input !== now.input) return step
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The day's two extremes, by output rate: what the widget labels
|
|
48
|
+
* "не-пик" and "пик". Equal extremes mean a flat tariff.
|
|
49
|
+
*/
|
|
50
|
+
function extremes(schedule) {
|
|
51
|
+
let low = null
|
|
52
|
+
let high = null
|
|
53
|
+
for (let minute = 0; minute < DAY; minute += 1) {
|
|
54
|
+
const r = ratesAt(schedule, minute)
|
|
55
|
+
if (!low || r.output < low.output) low = r
|
|
56
|
+
if (!high || r.output > high.output) high = r
|
|
57
|
+
}
|
|
58
|
+
return { low: low || {}, high: high || {}, flat: !low || !high || low.output === high.output }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Total tokens across every half-hour slot of one model. */
|
|
62
|
+
function sumSlots(slots) {
|
|
63
|
+
const total = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
64
|
+
for (const key of Object.keys(slots || {})) {
|
|
65
|
+
const b = slots[key] || {}
|
|
66
|
+
for (const field of Object.keys(total)) total[field] += Number(b[field]) || 0
|
|
67
|
+
}
|
|
68
|
+
return total
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Cost of one model, each half-hour slot charged at the rate that applied
|
|
73
|
+
* in that slot. Slot 14 is 07:00–07:30 UTC, so its midpoint (07:15) picks
|
|
74
|
+
* the window without landing on a boundary.
|
|
75
|
+
*/
|
|
76
|
+
function spendBySlots(slots, schedule) {
|
|
77
|
+
let usd = 0
|
|
78
|
+
for (const key of Object.keys(slots || {})) {
|
|
79
|
+
const minute = (Number(key) * 30 + 15) % 1440
|
|
80
|
+
usd += spendUsd(slots[key], ratesAt(schedule, minute))
|
|
81
|
+
}
|
|
82
|
+
return usd
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Session spend in USD for one rate card. */
|
|
86
|
+
function spendUsd(usage, rates) {
|
|
87
|
+
const u = usage || {}
|
|
88
|
+
const perMillion =
|
|
89
|
+
(Number(u.cacheReadTokens) || 0) * (rates.cacheHit || 0) +
|
|
90
|
+
(Number(u.uncachedInputTokens) || 0) * (rates.input || 0) +
|
|
91
|
+
(Number(u.cacheWriteTokens) || 0) * (rates.cacheWrite || 0) +
|
|
92
|
+
(Number(u.outputTokens) || 0) * (rates.output || 0)
|
|
93
|
+
return perMillion / 1e6
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --------------------------------------------------------------- format
|
|
97
|
+
|
|
98
|
+
function rate(value) {
|
|
99
|
+
if (!value) return '—'
|
|
100
|
+
const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
|
|
101
|
+
return '$' + text.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function money(value, currency) {
|
|
105
|
+
return currency + value.toFixed(Math.abs(value) >= 100 ? 0 : 2)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tokens(value) {
|
|
109
|
+
const n = Number(value) || 0
|
|
110
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
|
|
111
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
|
|
112
|
+
return String(n)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clock(minutes) {
|
|
116
|
+
if (minutes === null) return null
|
|
117
|
+
return Math.floor(minutes / 60) + ':' + String(minutes % 60).padStart(2, '0')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hhmm(minute) {
|
|
121
|
+
return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render a UTC minute-of-day in the display zone. Built from a real Date so
|
|
126
|
+
* the zone's current offset (and any DST) comes from Intl, not arithmetic.
|
|
127
|
+
*/
|
|
128
|
+
function hhmmInZone(minute, zone, reference) {
|
|
129
|
+
const d = new Date(Date.UTC(
|
|
130
|
+
reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate(), 0, minute,
|
|
131
|
+
))
|
|
132
|
+
try {
|
|
133
|
+
return new Intl.DateTimeFormat('ru-RU', {
|
|
134
|
+
hour: '2-digit', minute: '2-digit', hour12: false, timeZone: zone,
|
|
135
|
+
}).format(d)
|
|
136
|
+
} catch {
|
|
137
|
+
return hhmm(minute)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Short zone label for the panel: "МСК" for Moscow, else the city part. */
|
|
142
|
+
function zoneLabel(zone) {
|
|
143
|
+
if (!zone) return 'UTC'
|
|
144
|
+
if (zone === 'Europe/Moscow') return 'МСК'
|
|
145
|
+
const city = zone.includes('/') ? zone.slice(zone.lastIndexOf('/') + 1) : zone
|
|
146
|
+
return city.replace(/_/g, ' ')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function ago(timestamp) {
|
|
150
|
+
if (!timestamp) return 'нет данных'
|
|
151
|
+
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
|
|
152
|
+
if (minutes < 60) return minutes + ' мин назад'
|
|
153
|
+
const hours = Math.round(minutes / 60)
|
|
154
|
+
return hours < 48 ? hours + ' ч назад' : Math.round(hours / 24) + ' дн назад'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------- styles
|
|
158
|
+
|
|
159
|
+
const C = {
|
|
160
|
+
label: 'var(--dsw-alias-label-primary)',
|
|
161
|
+
muted: 'var(--dsw-alias-label-tertiary)',
|
|
162
|
+
line: 'var(--dsw-alias-border-l2)',
|
|
163
|
+
panelBg: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base))',
|
|
164
|
+
okFg: 'var(--dsw-alias-state-success-primary)',
|
|
165
|
+
okBg: 'var(--dsw-alias-state-success-tertiary)',
|
|
166
|
+
warnFg: 'var(--dsw-alias-state-warn-primary)',
|
|
167
|
+
warnBg: 'var(--dsw-alias-state-warn-tertiary)',
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const S = {
|
|
171
|
+
chip: {
|
|
172
|
+
display: 'inline-flex', alignItems: 'center', gap: 6, height: 22, padding: '0 8px',
|
|
173
|
+
borderRadius: 6, border: 'none', cursor: 'pointer', font: 'inherit', fontSize: 12,
|
|
174
|
+
fontVariantNumeric: 'tabular-nums', lineHeight: '22px',
|
|
175
|
+
},
|
|
176
|
+
panel: {
|
|
177
|
+
position: 'absolute', top: 28, right: 0, zIndex: 40, width: 320, padding: '12px 14px',
|
|
178
|
+
borderRadius: 10, border: '1px solid ' + C.line, background: C.panelBg,
|
|
179
|
+
boxShadow: '0 10px 30px rgba(0,0,0,.28)', fontSize: 12, lineHeight: 1.5,
|
|
180
|
+
color: C.label, textAlign: 'left', fontVariantNumeric: 'tabular-nums',
|
|
181
|
+
},
|
|
182
|
+
title: { fontWeight: 600, paddingBottom: 4 },
|
|
183
|
+
note: { color: C.muted },
|
|
184
|
+
hr: { height: 1, background: C.line, margin: '10px -14px' },
|
|
185
|
+
grid: { display: 'grid', gridTemplateColumns: '1fr 60px 60px', columnGap: 6, alignItems: 'center' },
|
|
186
|
+
gridFlat: { display: 'grid', gridTemplateColumns: '1fr 72px', columnGap: 6, alignItems: 'center' },
|
|
187
|
+
grid3: { display: 'grid', gridTemplateColumns: '1fr 58px 72px', columnGap: 6, alignItems: 'center' },
|
|
188
|
+
th: { color: C.muted, paddingBottom: 4 },
|
|
189
|
+
thNum: { color: C.muted, paddingBottom: 4, textAlign: 'right' },
|
|
190
|
+
td: { padding: '3px 0' },
|
|
191
|
+
cellIdle: { textAlign: 'right', padding: '3px 6px', borderRadius: 4, color: C.muted },
|
|
192
|
+
num: { textAlign: 'right', color: C.muted, padding: '3px 0' },
|
|
193
|
+
cost: { textAlign: 'right', padding: '3px 0' },
|
|
194
|
+
section: { color: C.muted, paddingBottom: 4 },
|
|
195
|
+
total: { display: 'flex', justifyContent: 'space-between', fontWeight: 600 },
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ------------------------------------------------------------ component
|
|
199
|
+
|
|
200
|
+
function CostMeter(props) {
|
|
201
|
+
const useProjection = props.useProjection
|
|
202
|
+
const usage = useProjection ? useProjection('tokenUsage') : undefined
|
|
203
|
+
const byModel = useProjection ? useProjection('costByModel') : undefined
|
|
204
|
+
const [state, setState] = React.useState(null)
|
|
205
|
+
const [open, setOpen] = React.useState(false)
|
|
206
|
+
const [now, setNow] = React.useState(() => new Date())
|
|
207
|
+
const box = React.useRef(null)
|
|
208
|
+
|
|
209
|
+
// Every model this session actually billed, so each part is priced on its
|
|
210
|
+
// own tariff instead of the current model's. Usage arrives split into
|
|
211
|
+
// half-hour slots of the UTC day — spend is charged at the rate that was
|
|
212
|
+
// in force then, so a tariff switch never reprices what is already spent.
|
|
213
|
+
const used = (byModel && Array.isArray(byModel.models) ? byModel.models : [])
|
|
214
|
+
.map((m) => ({ ...m, usage: sumSlots(m && m.slots) }))
|
|
215
|
+
.filter((m) => m.usage.uncachedInputTokens || m.usage.outputTokens ||
|
|
216
|
+
m.usage.cacheReadTokens || m.usage.cacheWriteTokens)
|
|
217
|
+
const usedKeys = used.map((m) => m.provider + '/' + m.model).sort().join(',')
|
|
218
|
+
|
|
219
|
+
const load = React.useCallback((keys) => {
|
|
220
|
+
const query = keys ? '?models=' + encodeURIComponent(keys) : ''
|
|
221
|
+
fetch(STATE_PATH + query, { headers: { accept: 'application/json' } })
|
|
222
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
223
|
+
.then((data) => { if (data) setState(data) })
|
|
224
|
+
.catch(() => {})
|
|
225
|
+
}, [])
|
|
226
|
+
|
|
227
|
+
// Refetch when the set of billed models changes — a new model needs its
|
|
228
|
+
// own tariff before its tokens can be priced.
|
|
229
|
+
React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
|
|
230
|
+
React.useEffect(() => { if (open) load(usedKeys) }, [open, load, usedKeys])
|
|
231
|
+
React.useEffect(() => {
|
|
232
|
+
const id = setInterval(() => setNow(new Date()), 15000)
|
|
233
|
+
return () => clearInterval(id)
|
|
234
|
+
}, [])
|
|
235
|
+
React.useEffect(() => {
|
|
236
|
+
if (!open) return undefined
|
|
237
|
+
const onDown = (e) => { if (box.current && !box.current.contains(e.target)) setOpen(false) }
|
|
238
|
+
document.addEventListener('mousedown', onDown)
|
|
239
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
240
|
+
}, [open])
|
|
241
|
+
|
|
242
|
+
if (!state) return null
|
|
243
|
+
|
|
244
|
+
const currency = state.currency || '$'
|
|
245
|
+
const fx = Number(state.usdRate) || 1
|
|
246
|
+
const schedule = state.schedule
|
|
247
|
+
const hasUsage = usage !== undefined && usage !== null
|
|
248
|
+
const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
|
|
249
|
+
|
|
250
|
+
// No rate card for this route: say so instead of showing a fake zero.
|
|
251
|
+
if (!schedule) {
|
|
252
|
+
return h('div', { ref: box, style: { position: 'relative' } }, [
|
|
253
|
+
h('button', {
|
|
254
|
+
key: 'chip', type: 'button',
|
|
255
|
+
style: Object.assign({}, S.chip, { background: 'transparent', color: C.muted }),
|
|
256
|
+
onClick: () => setOpen((v) => !v), title: 'Тариф неизвестен',
|
|
257
|
+
}, 'тариф ?'),
|
|
258
|
+
open ? h('div', { key: 'panel', style: S.panel }, [
|
|
259
|
+
h('div', { key: 't', style: S.title },
|
|
260
|
+
state.route ? state.route.provider + ' / ' + state.route.model : 'маршрут неизвестен'),
|
|
261
|
+
h('div', { key: 'n', style: S.note },
|
|
262
|
+
state.catalogError
|
|
263
|
+
? 'Каталог OpenRouter недоступен: ' + state.catalogError
|
|
264
|
+
: 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.'),
|
|
265
|
+
]) : null,
|
|
266
|
+
])
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const active = ratesAt(schedule, minute)
|
|
270
|
+
const { low, high, flat } = extremes(schedule)
|
|
271
|
+
const cheapNow = !flat && active.output === low.output
|
|
272
|
+
const left = clock(minutesUntilChange(schedule, minute))
|
|
273
|
+
|
|
274
|
+
// Per-model spend: each model on its own tariff, at that tariff's rate
|
|
275
|
+
// for the current moment. Falls back to the session-wide totals when the
|
|
276
|
+
// per-model projection is unavailable (older harness, headless).
|
|
277
|
+
const schedules = state.schedules || {}
|
|
278
|
+
const perModel = used.map((m) => {
|
|
279
|
+
const key = m.provider + '/' + m.model
|
|
280
|
+
const own = schedules[key]
|
|
281
|
+
const ownSchedule = own && own.schedule
|
|
282
|
+
return {
|
|
283
|
+
key,
|
|
284
|
+
model: m.model,
|
|
285
|
+
usage: m.usage,
|
|
286
|
+
known: Boolean(ownSchedule),
|
|
287
|
+
cost: ownSchedule ? spendBySlots(m.slots, ownSchedule) * fx : 0,
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
const multiModel = perModel.length > 1
|
|
291
|
+
const total = perModel.length
|
|
292
|
+
? perModel.reduce((sum, m) => sum + m.cost, 0)
|
|
293
|
+
: spendUsd(usage, active) * fx
|
|
294
|
+
const unpriced = perModel.filter((m) => !m.known)
|
|
295
|
+
|
|
296
|
+
const fg = cheapNow ? C.okFg : C.warnFg
|
|
297
|
+
const bg = cheapNow ? C.okBg : C.warnBg
|
|
298
|
+
const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: bg, color: fg, fontWeight: 600 }
|
|
299
|
+
|
|
300
|
+
// Two tiers: cheap and full side by side, the active one filled.
|
|
301
|
+
// One tier: a single plain column, no highlight to explain.
|
|
302
|
+
const rateRow = (label, key) => flat
|
|
303
|
+
? [
|
|
304
|
+
h('div', { key: label + '-l', style: S.td }, label),
|
|
305
|
+
h('div', { key: label + '-v', style: S.cost }, rate(active[key])),
|
|
306
|
+
]
|
|
307
|
+
: [
|
|
308
|
+
h('div', { key: label + '-l', style: S.td }, label),
|
|
309
|
+
h('div', { key: label + '-lo', style: cheapNow ? activeCell : S.cellIdle }, rate(low[key])),
|
|
310
|
+
h('div', { key: label + '-hi', style: cheapNow ? S.cellIdle : activeCell }, rate(high[key])),
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
// Breakdown row for the single-model case: tokens in the bucket and what
|
|
314
|
+
// they actually accrued, slot by slot, at the rate of each slot.
|
|
315
|
+
const soleSlots = perModel.length === 1 ? used[0].slots : null
|
|
316
|
+
const spentRow = (label, bucketField, rateField) => {
|
|
317
|
+
const n = Number(usage && usage[bucketField]) || 0
|
|
318
|
+
let usd = 0
|
|
319
|
+
if (soleSlots) {
|
|
320
|
+
for (const key of Object.keys(soleSlots)) {
|
|
321
|
+
const rates = ratesAt(schedule, (Number(key) * 30 + 15) % 1440)
|
|
322
|
+
usd += ((Number(soleSlots[key][bucketField]) || 0) / 1e6) * (rates[rateField] || 0)
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
usd = (n / 1e6) * (active[rateField] || 0)
|
|
326
|
+
}
|
|
327
|
+
return [
|
|
328
|
+
h('div', { key: label + '-l', style: S.td }, label),
|
|
329
|
+
h('div', { key: label + '-n', style: S.num }, tokens(n)),
|
|
330
|
+
h('div', { key: label + '-v', style: S.cost }, money(usd * fx, currency)),
|
|
331
|
+
]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const showCacheWrite = Number(usage && usage.cacheWriteTokens) > 0
|
|
335
|
+
const zone = state.displayTimeZone || 'Europe/Moscow'
|
|
336
|
+
const zl = zoneLabel(zone)
|
|
337
|
+
const windowText = (schedule.windows || [])
|
|
338
|
+
.map((w) => hhmmInZone(w.start, zone, now) + '–' + hhmmInZone(w.end, zone, now))
|
|
339
|
+
.join(', ')
|
|
340
|
+
const nowLocal = hhmmInZone(minute, zone, now)
|
|
341
|
+
|
|
342
|
+
return h('div', { ref: box, style: { position: 'relative' } }, [
|
|
343
|
+
h('button', {
|
|
344
|
+
key: 'chip', type: 'button',
|
|
345
|
+
style: Object.assign({}, S.chip, { background: bg, color: fg }),
|
|
346
|
+
onClick: () => setOpen((v) => !v),
|
|
347
|
+
title: flat ? 'Единый тариф' : cheapNow ? 'Льготный тариф' : 'Полный тариф',
|
|
348
|
+
}, [
|
|
349
|
+
h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
|
|
350
|
+
left ? h('span', { key: 'left', style: { opacity: 0.75 } }, left) : null,
|
|
351
|
+
]),
|
|
352
|
+
|
|
353
|
+
open ? h('div', { key: 'panel', style: S.panel }, [
|
|
354
|
+
h('div', { key: 'title', style: S.title },
|
|
355
|
+
(state.route ? state.route.model : 'модель') +
|
|
356
|
+
(flat ? '' : ' — сейчас ' + (cheapNow ? 'не-пик' : 'пик'))),
|
|
357
|
+
|
|
358
|
+
h('div', { key: 'note', style: S.note },
|
|
359
|
+
flat
|
|
360
|
+
? 'Единая ставка, от времени суток не зависит'
|
|
361
|
+
: (cheapNow ? 'Действует льготная ставка' : 'Действует полная ставка') +
|
|
362
|
+
(left ? ' · смена через ' + left : '')),
|
|
363
|
+
|
|
364
|
+
flat
|
|
365
|
+
? null
|
|
366
|
+
: h('div', { key: 'clock', style: S.note },
|
|
367
|
+
'Пик: ' + windowText + ' ' + zl + ' · сейчас ' + nowLocal + ' ' + zl),
|
|
368
|
+
|
|
369
|
+
h('div', { key: 'hr1', style: S.hr }),
|
|
370
|
+
|
|
371
|
+
h('div', { key: 'rates', style: flat ? S.gridFlat : S.grid }, [
|
|
372
|
+
h('div', { key: 'h0', style: S.th }, '1M токенов, $'),
|
|
373
|
+
flat ? null : h('div', { key: 'h1', style: S.thNum }, 'не-пик'),
|
|
374
|
+
h('div', { key: 'h2', style: S.thNum }, flat ? 'ставка' : 'пик'),
|
|
375
|
+
rateRow('Вход (кэш-хит)', 'cacheHit'),
|
|
376
|
+
rateRow('Вход (мимо кэша)', 'input'),
|
|
377
|
+
showCacheWrite ? rateRow('Вход (запись в кэш)', 'cacheWrite') : null,
|
|
378
|
+
rateRow('Выход', 'output'),
|
|
379
|
+
]),
|
|
380
|
+
|
|
381
|
+
h('div', { key: 'hr2', style: S.hr }),
|
|
382
|
+
|
|
383
|
+
multiModel
|
|
384
|
+
? null
|
|
385
|
+
: [
|
|
386
|
+
h('div', { key: 'btitle', style: S.section }, 'Токены сессии'),
|
|
387
|
+
h('div', { key: 'breakdown', style: S.grid3 }, [
|
|
388
|
+
h('div', { key: 'b0', style: S.th }, 'токенов'),
|
|
389
|
+
h('div', { key: 'b1', style: S.thNum }, ''),
|
|
390
|
+
h('div', { key: 'b2', style: S.thNum }, currency),
|
|
391
|
+
spentRow('Вход (кэш-хит)', 'cacheReadTokens', 'cacheHit'),
|
|
392
|
+
spentRow('Вход (мимо кэша)', 'uncachedInputTokens', 'input'),
|
|
393
|
+
showCacheWrite ? spentRow('Вход (запись в кэш)', 'cacheWriteTokens', 'cacheWrite') : null,
|
|
394
|
+
spentRow('Выход', 'outputTokens', 'output'),
|
|
395
|
+
]),
|
|
396
|
+
h('div', { key: 'hr-models', style: S.hr }),
|
|
397
|
+
],
|
|
398
|
+
|
|
399
|
+
h('div', { key: 'mtitle', style: S.section }, 'Расход по моделям'),
|
|
400
|
+
h(
|
|
401
|
+
'div',
|
|
402
|
+
{ key: 'models-list', style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
403
|
+
(perModel.length
|
|
404
|
+
? perModel
|
|
405
|
+
: (state.route ? [{ key: state.route.provider + '/' + state.route.model, model: state.route.model, known: Boolean(schedule), cost: 0 }] : [])
|
|
406
|
+
).map((m) =>
|
|
407
|
+
h(
|
|
408
|
+
'div',
|
|
409
|
+
{
|
|
410
|
+
key: m.key,
|
|
411
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '2px 0' },
|
|
412
|
+
},
|
|
413
|
+
[
|
|
414
|
+
h(
|
|
415
|
+
'span',
|
|
416
|
+
{
|
|
417
|
+
key: 'name',
|
|
418
|
+
title: m.key,
|
|
419
|
+
style: {
|
|
420
|
+
overflow: 'hidden',
|
|
421
|
+
textOverflow: 'ellipsis',
|
|
422
|
+
whiteSpace: 'nowrap',
|
|
423
|
+
maxWidth: m.known ? 200 : 130,
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
m.model,
|
|
427
|
+
),
|
|
428
|
+
h(
|
|
429
|
+
'span',
|
|
430
|
+
{
|
|
431
|
+
key: 'val',
|
|
432
|
+
style: {
|
|
433
|
+
textAlign: 'right',
|
|
434
|
+
fontVariantNumeric: 'tabular-nums',
|
|
435
|
+
whiteSpace: 'nowrap',
|
|
436
|
+
color: m.known ? C.label : C.muted,
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
m.known ? (hasUsage ? money(m.cost, currency) : '—') : 'не указана стоимость',
|
|
440
|
+
),
|
|
441
|
+
],
|
|
442
|
+
),
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
|
|
446
|
+
h('div', { key: 'hr3', style: S.hr }),
|
|
447
|
+
|
|
448
|
+
h('div', { key: 'total', style: S.total }, [
|
|
449
|
+
h('span', { key: 'l' }, 'Сессия:'),
|
|
450
|
+
h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : 'нет данных'),
|
|
451
|
+
]),
|
|
452
|
+
|
|
453
|
+
hasUsage ? null : h('div', { key: 'nodata', style: Object.assign({}, S.note, { paddingTop: 6 }) },
|
|
454
|
+
'Провайдер ещё не сообщил usage для этой сессии.'),
|
|
455
|
+
]) : null,
|
|
456
|
+
])
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function apply(ctx) {
|
|
460
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
461
|
+
ctx.slots.register(
|
|
462
|
+
{ name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10 },
|
|
463
|
+
CostMeter,
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
module.exports = { apply, inject: ['slots'] }
|
|
469
|
+
return module.exports
|
|
470
|
+
},
|
|
471
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// dsh-cost-meter — host half.
|
|
2
|
+
//
|
|
3
|
+
// The harness prices nothing on purpose: pi-ai carries a `cost` field but
|
|
4
|
+
// zeroes it and no consumer reports spend. So this half owns the rate card and
|
|
5
|
+
// serves it to the browser over GET /dsh-cost-meter/state.
|
|
6
|
+
//
|
|
7
|
+
// Rates come from OpenRouter's public catalog (https://openrouter.ai/api/v1/models,
|
|
8
|
+
// no auth). Besides the four token buckets it also publishes `pricing.overrides`
|
|
9
|
+
// — time-of-day discount windows keyed by UTC HHMM — which is exactly the
|
|
10
|
+
// peak/off-peak schedule DeepSeek runs. So the whole tariff, windows included,
|
|
11
|
+
// is derived rather than typed in; `prices` in config stays as a manual
|
|
12
|
+
// override for routes the catalog does not know (self-hosted, subscriptions).
|
|
13
|
+
//
|
|
14
|
+
// No agent hooks: the widget only reads, so it cannot corrupt a session.
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import z from '@deepseek-ai/schemastery'
|
|
20
|
+
import { z as zod } from 'zod'
|
|
21
|
+
|
|
22
|
+
export const name = 'dsh-cost-meter'
|
|
23
|
+
export const inject = ['webServer']
|
|
24
|
+
|
|
25
|
+
const NS = 'dsh-cost-meter'
|
|
26
|
+
const STATE_PATH = '/dsh-cost-meter/state'
|
|
27
|
+
const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
|
|
28
|
+
const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
|
|
29
|
+
|
|
30
|
+
/** One route's rates, in USD per 1M tokens. */
|
|
31
|
+
const PriceRow = z.object({
|
|
32
|
+
cacheHit: z.number().description('Input tokens served from the provider cache.').default(0),
|
|
33
|
+
input: z.number().description('Input tokens that missed the cache.').default(0),
|
|
34
|
+
cacheWrite: z.number().description('Input tokens written into the cache.').default(0),
|
|
35
|
+
output: z.number().description('Output tokens (reasoning included).').default(0),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
|
|
40
|
+
* Their API publishes no prices (/models lists ids, there is no pricing route),
|
|
41
|
+
* and OpenRouter's `deepseek/*` entries quote different numbers, so this table
|
|
42
|
+
* is the authoritative source for the official route. Values are PEAK, USD per
|
|
43
|
+
* 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
|
|
44
|
+
*/
|
|
45
|
+
const DEEPSEEK_PEAK_RATES = {
|
|
46
|
+
'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
|
|
47
|
+
'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
|
|
48
|
+
}
|
|
49
|
+
const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
|
|
50
|
+
|
|
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.
|
|
55
|
+
|
|
56
|
+
export const Config = z.object({
|
|
57
|
+
currency: z.string().description('Currency symbol shown in the widget.').default('$'),
|
|
58
|
+
usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
|
|
59
|
+
displayTimeZone: z
|
|
60
|
+
.string()
|
|
61
|
+
.description('IANA zone the widget shows tariff hours in.')
|
|
62
|
+
.default('Europe/Moscow'),
|
|
63
|
+
deepseekPeakPrices: z
|
|
64
|
+
.dict(PriceRow)
|
|
65
|
+
.description('Override the built-in DeepSeek peak rates, by model id.')
|
|
66
|
+
.default({}),
|
|
67
|
+
useOpenRouter: z
|
|
68
|
+
.boolean()
|
|
69
|
+
.description('Pull rates and discount windows from the public OpenRouter catalog.')
|
|
70
|
+
.default(true),
|
|
71
|
+
refreshHours: z.number().description('How often to refetch the catalog.').default(24),
|
|
72
|
+
modelMap: z
|
|
73
|
+
.dict(z.string())
|
|
74
|
+
.description('Route override: "provider/model" -> OpenRouter model id.')
|
|
75
|
+
.default({}),
|
|
76
|
+
prices: z
|
|
77
|
+
.dict(PriceRow)
|
|
78
|
+
.description('Manual rates by "provider/model" (or "*"). Win over the catalog.')
|
|
79
|
+
.default({}),
|
|
80
|
+
manualPeakWindowsUtc: z
|
|
81
|
+
.array(z.string())
|
|
82
|
+
.description('Peak windows HH:MM-HH:MM UTC, used only with manual prices.')
|
|
83
|
+
.default([]),
|
|
84
|
+
manualOffPeakMultiplier: z
|
|
85
|
+
.number()
|
|
86
|
+
.description('Multiplier outside the manual peak windows.')
|
|
87
|
+
.default(1),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
|
|
91
|
+
function perMillion(value) {
|
|
92
|
+
const n = Number(value)
|
|
93
|
+
return Number.isFinite(n) ? n * 1e6 : 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pull the four buckets out of one OpenRouter pricing object. */
|
|
97
|
+
function bucketsOf(pricing) {
|
|
98
|
+
return {
|
|
99
|
+
cacheHit: perMillion(pricing.input_cache_read),
|
|
100
|
+
input: perMillion(pricing.prompt),
|
|
101
|
+
cacheWrite: perMillion(pricing.input_cache_write),
|
|
102
|
+
output: perMillion(pricing.completion),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
|
|
107
|
+
export function hhmmToMinutes(value) {
|
|
108
|
+
const n = Number(value)
|
|
109
|
+
if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
|
|
110
|
+
return Math.floor(n / 100) * 60 + (n % 100)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
|
|
114
|
+
export function parseWindow(spec) {
|
|
115
|
+
const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
|
|
116
|
+
if (!m) return undefined
|
|
117
|
+
return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Normalize one catalog entry into the schedule the browser consumes:
|
|
122
|
+
* a base rate card plus the discount windows that shadow it.
|
|
123
|
+
*/
|
|
124
|
+
function scheduleOf(entry) {
|
|
125
|
+
const pricing = entry.pricing || {}
|
|
126
|
+
const base = bucketsOf(pricing)
|
|
127
|
+
const windows = []
|
|
128
|
+
for (const override of pricing.overrides || []) {
|
|
129
|
+
const start = hhmmToMinutes(override.utc_start)
|
|
130
|
+
const end = hhmmToMinutes(override.utc_end)
|
|
131
|
+
if (start === undefined || end === undefined) continue
|
|
132
|
+
// An override omits buckets it does not change; fall back to the base.
|
|
133
|
+
const rates = bucketsOf(override)
|
|
134
|
+
windows.push({
|
|
135
|
+
start,
|
|
136
|
+
end,
|
|
137
|
+
rates: {
|
|
138
|
+
cacheHit: rates.cacheHit || base.cacheHit,
|
|
139
|
+
input: rates.input || base.input,
|
|
140
|
+
cacheWrite: rates.cacheWrite || base.cacheWrite,
|
|
141
|
+
output: rates.output || base.output,
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
return { base, windows }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
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.
|
|
152
|
+
*/
|
|
153
|
+
export function matchModel(index, route, modelMap) {
|
|
154
|
+
if (!route) return undefined
|
|
155
|
+
const key = route.provider + '/' + route.model
|
|
156
|
+
const mapped = modelMap && modelMap[key]
|
|
157
|
+
if (mapped && index.byId[mapped]) return mapped
|
|
158
|
+
if (index.byId[key]) return key
|
|
159
|
+
if (index.byId[route.model]) return route.model
|
|
160
|
+
// Harness routes name models in their own casing (`MiniMax-M3`), the catalog
|
|
161
|
+
// is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
|
|
162
|
+
const lower = String(route.model).toLowerCase()
|
|
163
|
+
if (index.byLowerId[lower]) return index.byLowerId[lower]
|
|
164
|
+
if (index.byLowerId[key.toLowerCase()]) return index.byLowerId[key.toLowerCase()]
|
|
165
|
+
const bySuffix = index.bySuffix[lower]
|
|
166
|
+
return bySuffix && bySuffix.length ? bySuffix[0] : undefined
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
|
|
170
|
+
export function indexCatalog(models) {
|
|
171
|
+
const byId = {}
|
|
172
|
+
const byLowerId = {}
|
|
173
|
+
const bySuffix = {}
|
|
174
|
+
for (const entry of models) {
|
|
175
|
+
if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
|
|
176
|
+
// Variants like ":batch" or ":free" are different products; skip them so a
|
|
177
|
+
// suffix match cannot silently price against a cheaper batch tier.
|
|
178
|
+
if (entry.id.includes(':')) continue
|
|
179
|
+
byId[entry.id] = scheduleOf(entry)
|
|
180
|
+
byLowerId[entry.id.toLowerCase()] = entry.id
|
|
181
|
+
const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
|
|
182
|
+
;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
|
|
183
|
+
}
|
|
184
|
+
return { byId, byLowerId, bySuffix }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Per-model usage projection
|
|
189
|
+
//
|
|
190
|
+
// `tokenUsage` from dsh-token-meter totals the whole session, which prices
|
|
191
|
+
// wrongly the moment the model changes mid-session. The log has what is
|
|
192
|
+
// needed: `request/header` carries `header.config = {provider, model}`, and
|
|
193
|
+
// usage arrives afterwards for that same step. So this unit keeps the last
|
|
194
|
+
// header's route and files every usage sample under it.
|
|
195
|
+
//
|
|
196
|
+
// The replace-if-same-step guard mirrors token-meter: a streamed usage chunk
|
|
197
|
+
// and the finalized assistant message report the same numbers for one step.
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
201
|
+
|
|
202
|
+
// Projection schemas are validated with ZOD, not schemastery: the registry
|
|
203
|
+
// calls `schema.parse(view)` at the boundary, and a schemastery schema has no
|
|
204
|
+
// `.parse`, which fails every history read with
|
|
205
|
+
// "registration.def.schema.parse is not a function".
|
|
206
|
+
const bucketSchema = zod.object({
|
|
207
|
+
uncachedInputTokens: zod.number().nonnegative(),
|
|
208
|
+
outputTokens: zod.number().nonnegative(),
|
|
209
|
+
cacheReadTokens: zod.number().nonnegative(),
|
|
210
|
+
cacheWriteTokens: zod.number().nonnegative(),
|
|
211
|
+
}).strict()
|
|
212
|
+
|
|
213
|
+
/** Pull the four buckets out of one provider usage record. */
|
|
214
|
+
function bucketsFromUsage(usage) {
|
|
215
|
+
return {
|
|
216
|
+
uncachedInputTokens: Number(usage.inputTokens) || 0,
|
|
217
|
+
outputTokens: Number(usage.outputTokens) || 0,
|
|
218
|
+
cacheReadTokens: Number(usage.cacheReadTokens) || 0,
|
|
219
|
+
cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function addBuckets(base, remove, add) {
|
|
224
|
+
const out = {}
|
|
225
|
+
for (const k of Object.keys(ZERO)) {
|
|
226
|
+
out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sameBuckets(a, b) {
|
|
232
|
+
return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The usage a streamed chunk or a finalized assistant message reports. */
|
|
236
|
+
function usageOf(event) {
|
|
237
|
+
if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
|
|
238
|
+
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
|
|
239
|
+
}
|
|
240
|
+
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
|
241
|
+
return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
|
|
242
|
+
}
|
|
243
|
+
return undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Half-hour slot of the UTC day an event falls into (0..47). Tariff windows are
|
|
248
|
+
* expressed in UTC clock time, and every real schedule so far lands on a whole
|
|
249
|
+
* or half hour, so 48 slots reproduce the rate exactly while keeping the state
|
|
250
|
+
* small enough to ship on every projection frame.
|
|
251
|
+
*/
|
|
252
|
+
export function slotOf(timeMs) {
|
|
253
|
+
const d = new Date(timeMs)
|
|
254
|
+
return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const usageByModelProjection = {
|
|
258
|
+
key: 'costByModel',
|
|
259
|
+
schema: zod.object({
|
|
260
|
+
models: zod.array(zod.object({
|
|
261
|
+
provider: zod.string(),
|
|
262
|
+
model: zod.string(),
|
|
263
|
+
// Usage split by the half-hour of the UTC day it was spent in. Spend is
|
|
264
|
+
// priced at the rate in force THEN; a later tariff switch must not
|
|
265
|
+
// reprice tokens that are already paid for.
|
|
266
|
+
slots: zod.record(zod.string(), bucketSchema),
|
|
267
|
+
})),
|
|
268
|
+
}).strict(),
|
|
269
|
+
init: () => ({ route: null, byKey: {}, last: null }),
|
|
270
|
+
apply: (state, event) => {
|
|
271
|
+
if (event.type === 'request/header') {
|
|
272
|
+
const config = event.data && event.data.header && event.data.header.config
|
|
273
|
+
const provider = config && typeof config.provider === 'string' ? config.provider : undefined
|
|
274
|
+
const model = config && typeof config.model === 'string' ? config.model : undefined
|
|
275
|
+
if (provider === undefined || model === undefined) return state
|
|
276
|
+
if (state.route && state.route.provider === provider && state.route.model === model) return state
|
|
277
|
+
return { ...state, route: { provider, model } }
|
|
278
|
+
}
|
|
279
|
+
const sample = usageOf(event)
|
|
280
|
+
if (sample === undefined) return state
|
|
281
|
+
const route = state.route || { provider: '', model: 'unknown' }
|
|
282
|
+
const key = route.provider + '/' + route.model
|
|
283
|
+
const slot = String(slotOf(event.time))
|
|
284
|
+
const buckets = bucketsFromUsage(sample.usage)
|
|
285
|
+
// A streamed usage chunk and the finalized assistant message report the
|
|
286
|
+
// same step twice; the second one replaces the first instead of adding.
|
|
287
|
+
const previous =
|
|
288
|
+
state.last !== null &&
|
|
289
|
+
state.last.turn === sample.turn &&
|
|
290
|
+
state.last.step === sample.step &&
|
|
291
|
+
state.last.key === key &&
|
|
292
|
+
state.last.slot === slot
|
|
293
|
+
? state.last.buckets
|
|
294
|
+
: undefined
|
|
295
|
+
if (previous !== undefined && sameBuckets(previous, buckets)) return state
|
|
296
|
+
const slots = state.byKey[key] || {}
|
|
297
|
+
return {
|
|
298
|
+
route: state.route,
|
|
299
|
+
byKey: {
|
|
300
|
+
...state.byKey,
|
|
301
|
+
[key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
|
|
302
|
+
},
|
|
303
|
+
last: { turn: sample.turn, step: sample.step, key, slot, buckets },
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
view: (state) => ({
|
|
307
|
+
models: Object.keys(state.byKey).map((key) => {
|
|
308
|
+
const slash = key.indexOf('/')
|
|
309
|
+
return {
|
|
310
|
+
provider: key.slice(0, slash),
|
|
311
|
+
model: key.slice(slash + 1),
|
|
312
|
+
slots: state.byKey[key],
|
|
313
|
+
}
|
|
314
|
+
}),
|
|
315
|
+
}),
|
|
316
|
+
stateVersion: 2,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The agent's default route, read off settings descriptors when available. */
|
|
320
|
+
function defaultRoute(ctx) {
|
|
321
|
+
const settings = ctx.get('settings')
|
|
322
|
+
if (settings === undefined) return undefined
|
|
323
|
+
try {
|
|
324
|
+
const found = settings
|
|
325
|
+
.describe({ redactSecrets: true })
|
|
326
|
+
.find((descriptor) => descriptor.ns === 'agent-default-model')
|
|
327
|
+
const value = found && found.value
|
|
328
|
+
if (!value || typeof value.provider !== 'string' || typeof value.model !== 'string') return undefined
|
|
329
|
+
return { provider: value.provider, model: value.model }
|
|
330
|
+
} catch {
|
|
331
|
+
return undefined
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Resolve one route's tariff: manual config wins, then DeepSeek's own table,
|
|
337
|
+
* then the OpenRouter catalog. Returns `schedule: null` when nothing knows it,
|
|
338
|
+
* so the widget can say so instead of pricing at zero.
|
|
339
|
+
*/
|
|
340
|
+
export function resolveTariff(cfg, index, route) {
|
|
341
|
+
if (!route) return { schedule: null, source: 'none', matchedId: null }
|
|
342
|
+
const key = route.provider + '/' + route.model
|
|
343
|
+
const prices = cfg.prices || {}
|
|
344
|
+
const manual = prices[key] || prices[route.model] || prices['*']
|
|
345
|
+
|
|
346
|
+
if (manual) {
|
|
347
|
+
const base = {
|
|
348
|
+
cacheHit: Number(manual.cacheHit) || 0,
|
|
349
|
+
input: Number(manual.input) || 0,
|
|
350
|
+
cacheWrite: Number(manual.cacheWrite) || 0,
|
|
351
|
+
output: Number(manual.output) || 0,
|
|
352
|
+
}
|
|
353
|
+
const factor = Number(cfg.manualOffPeakMultiplier) || 1
|
|
354
|
+
const windows = []
|
|
355
|
+
for (const spec of cfg.manualPeakWindowsUtc || []) {
|
|
356
|
+
const parsed = parseWindow(spec)
|
|
357
|
+
if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
|
|
358
|
+
}
|
|
359
|
+
const schedule = windows.length
|
|
360
|
+
? {
|
|
361
|
+
base: {
|
|
362
|
+
cacheHit: base.cacheHit * factor,
|
|
363
|
+
input: base.input * factor,
|
|
364
|
+
cacheWrite: base.cacheWrite * factor,
|
|
365
|
+
output: base.output * factor,
|
|
366
|
+
},
|
|
367
|
+
windows,
|
|
368
|
+
}
|
|
369
|
+
: { base, windows: [] }
|
|
370
|
+
return { schedule, source: 'manual', matchedId: key }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Keyed by MODEL, not provider: a DeepSeek model is billed at DeepSeek's
|
|
374
|
+
// published rate whoever routes it (deepseek-official, opencode-go, ollama).
|
|
375
|
+
// Date/variant suffixes (`deepseek-v4-flash:0731`) name the same product.
|
|
376
|
+
const base = String(route.model).toLowerCase().split(':')[0]
|
|
377
|
+
const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
|
|
378
|
+
if (peak) {
|
|
379
|
+
const full = {
|
|
380
|
+
cacheHit: Number(peak.cacheHit) || 0,
|
|
381
|
+
input: Number(peak.input) || 0,
|
|
382
|
+
cacheWrite: Number(peak.cacheWrite) || 0,
|
|
383
|
+
output: Number(peak.output) || 0,
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
schedule: {
|
|
387
|
+
base: {
|
|
388
|
+
cacheHit: full.cacheHit / 2,
|
|
389
|
+
input: full.input / 2,
|
|
390
|
+
cacheWrite: full.cacheWrite / 2,
|
|
391
|
+
output: full.output / 2,
|
|
392
|
+
},
|
|
393
|
+
windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
|
|
394
|
+
},
|
|
395
|
+
source: 'deepseek',
|
|
396
|
+
matchedId: base,
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (cfg.useOpenRouter !== false) {
|
|
401
|
+
const matchedId = matchModel(index, route, cfg.modelMap)
|
|
402
|
+
if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
|
|
403
|
+
}
|
|
404
|
+
return { schedule: null, source: 'none', matchedId: null }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** "provider/model" -> {provider, model}; the model half may contain slashes. */
|
|
408
|
+
function splitRoute(key) {
|
|
409
|
+
const slash = String(key).indexOf('/')
|
|
410
|
+
if (slash < 0) return undefined
|
|
411
|
+
return { provider: key.slice(0, slash), model: key.slice(slash + 1) }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function apply(ctx, config) {
|
|
415
|
+
let current = config
|
|
416
|
+
let catalog = { index: { byId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
|
|
417
|
+
|
|
418
|
+
// Disk cache survives restarts, so a boot without network still prices.
|
|
419
|
+
try {
|
|
420
|
+
const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
|
|
421
|
+
if (cached && Array.isArray(cached.models)) {
|
|
422
|
+
catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
// absent or unreadable cache is normal on first run
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function refresh(signal) {
|
|
429
|
+
const cfg = current ?? config
|
|
430
|
+
if (cfg.useOpenRouter === false) return
|
|
431
|
+
const ageMs = Date.now() - catalog.fetchedAt
|
|
432
|
+
if (catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
|
|
433
|
+
try {
|
|
434
|
+
const response = await fetch(CATALOG_URL, { signal, headers: { accept: 'application/json' } })
|
|
435
|
+
if (!response.ok) throw new Error('HTTP ' + response.status)
|
|
436
|
+
const body = await response.json()
|
|
437
|
+
const models = Array.isArray(body && body.data) ? body.data : []
|
|
438
|
+
if (models.length === 0) throw new Error('empty catalog')
|
|
439
|
+
// Keep only what pricing needs — the raw payload is mostly prose.
|
|
440
|
+
const slim = models.map((m) => ({ id: m.id, pricing: m.pricing }))
|
|
441
|
+
catalog = { index: indexCatalog(slim), fetchedAt: Date.now(), error: null }
|
|
442
|
+
try {
|
|
443
|
+
mkdirSync(dirname(CACHE_FILE), { recursive: true })
|
|
444
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
|
|
445
|
+
} catch {
|
|
446
|
+
// a read-only storages dir only costs us the restart-time cache
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
catalog = { ...catalog, error: String((error && error.message) || error) }
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Live config from the GUI, with the composition entry as the base layer.
|
|
454
|
+
ctx.inject(['settings'], (sctx) => {
|
|
455
|
+
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
456
|
+
current = scope.get() ?? config
|
|
457
|
+
const stop = scope.watch(() => {
|
|
458
|
+
current = scope.get() ?? config
|
|
459
|
+
void refresh()
|
|
460
|
+
})
|
|
461
|
+
sctx.effect(() => () => {
|
|
462
|
+
stop()
|
|
463
|
+
current = config
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// Optional capability: headless assemblies without the registry keep working,
|
|
468
|
+
// the widget just falls back to the session-wide tokenUsage projection.
|
|
469
|
+
ctx.inject(['sessionProjections'], (pctx) => {
|
|
470
|
+
pctx.sessionProjections.register(usageByModelProjection)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
ctx.effect(() => {
|
|
474
|
+
const controller = new AbortController()
|
|
475
|
+
void refresh(controller.signal)
|
|
476
|
+
const timer = setInterval(() => void refresh(controller.signal), 3600e3)
|
|
477
|
+
return () => {
|
|
478
|
+
clearInterval(timer)
|
|
479
|
+
controller.abort()
|
|
480
|
+
}
|
|
481
|
+
}, 'dsh-cost-meter: catalog refresh')
|
|
482
|
+
|
|
483
|
+
ctx.effect(
|
|
484
|
+
() =>
|
|
485
|
+
ctx.webServer.register({
|
|
486
|
+
kind: 'exact',
|
|
487
|
+
path: STATE_PATH,
|
|
488
|
+
handler: (req, res) => {
|
|
489
|
+
if (req.method !== 'GET') {
|
|
490
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
491
|
+
res.end(JSON.stringify({ error: 'method not allowed' }))
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
const cfg = current ?? config
|
|
495
|
+
const route = defaultRoute(ctx) ?? null
|
|
496
|
+
const own = resolveTariff(cfg, catalog.index, route)
|
|
497
|
+
|
|
498
|
+
// The client asks for every model the session actually used, so a
|
|
499
|
+
// session that switched models can price each part on its own tariff.
|
|
500
|
+
const schedules = {}
|
|
501
|
+
const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
|
|
502
|
+
for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
|
|
503
|
+
const parsed = splitRoute(key.trim())
|
|
504
|
+
if (!parsed) continue
|
|
505
|
+
const resolved = resolveTariff(cfg, catalog.index, parsed)
|
|
506
|
+
schedules[key.trim()] = {
|
|
507
|
+
schedule: resolved.schedule,
|
|
508
|
+
source: resolved.source,
|
|
509
|
+
matchedId: resolved.matchedId,
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
514
|
+
res.end(JSON.stringify({
|
|
515
|
+
currency: cfg.currency,
|
|
516
|
+
usdRate: cfg.usdRate,
|
|
517
|
+
displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
|
|
518
|
+
route,
|
|
519
|
+
source: own.source,
|
|
520
|
+
matchedId: own.matchedId,
|
|
521
|
+
schedules,
|
|
522
|
+
fetchedAt: catalog.fetchedAt || null,
|
|
523
|
+
catalogError: catalog.error,
|
|
524
|
+
schedule: own.schedule,
|
|
525
|
+
}))
|
|
526
|
+
},
|
|
527
|
+
}),
|
|
528
|
+
'dsh-cost-meter: state route',
|
|
529
|
+
)
|
|
530
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goodandready/dsh-cost-meter",
|
|
3
|
+
"version": "0.7.3",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"docs",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web",
|
|
29
|
+
"inject": []
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"dsh",
|
|
35
|
+
"dsh-plugin",
|
|
36
|
+
"cost",
|
|
37
|
+
"tokens",
|
|
38
|
+
"pricing"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node --test"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
49
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"zod": "^3.25.76"
|
|
53
|
+
}
|
|
54
|
+
}
|