@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 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
@@ -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
+ }