@goodandready/dsh-cost-meter 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,132 +1,134 @@
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
1
+ # 📦 @goodandready/dsh-cost-meter
2
+
3
+ <div align="center">
4
+
5
+ <h3>Live Session Cost Chip, Peak/Off-Peak Tariff Switcher & Token Pricing for DeepSeek Harness</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-cost-meter"><img src="https://img.shields.io/npm/v/@goodandready/dsh-cost-meter.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-cost-meter.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <!-- Author Showcase Link -->
15
+ <p align="center">
16
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/All_Author_Projects-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="GoodAndReady Showcase"></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="README.md"><b>🇬🇧 English</b></a> •
21
+ <a href="docs/README.ru.md"><b>🇷🇺 Русский</b></a> •
22
+ <a href="docs/README.zh.md"><b>🇨🇳 中文说明</b></a>
23
+ </p>
24
+
25
+ </div>
26
+
27
+ ---
28
+
29
+ ## ⚡ Overview & The Problem
30
+
31
+ AI development and agentic coding consume large volumes of tokens across prompt generation, reasoning, and context caches. Without continuous financial feedback, developers risk unexpected billing spikes, missing off-peak discount windows, or failing to identify runaway subagent expenses.
32
+
33
+ **`@goodandready/dsh-cost-meter`** embeds a high-precision cost telemetry chip directly into the DeepSeek Harness conversation header (`conversation.session.header.utilities`):
34
+
35
+ ```text
36
+ ● ≈ $0.12 0:17 ← Live session cost chip with tariff countdown
37
+ ```
38
+
39
+ Clicking the chip expands an interactive breakdown modal displaying 1M token rate tables across peak and off-peak tiers, active UTC window status, and per-model session expenditure summaries.
40
+
41
+ ---
42
+
43
+ ## 🏛️ Architecture
44
+
45
+ ```mermaid
46
+ graph TD
47
+ subgraph StreamTelemetry ["DeepSeek Harness Runtime"]
48
+ Req["LLM Request / Header<br/>(provider, model)"]
49
+ StreamHook["Incremental Streaming Chunks"]
50
+ Proj["costByModel Projection<br/>(30-min UTC Slots)"]
51
+ end
52
+
53
+ subgraph PricingCatalog ["Tariff Resolution Engine"]
54
+ Manual["Manual Config Rates<br/>(settings.yaml: prices)"]
55
+ DeepSeekTier["DeepSeek Official Tier<br/>(Peak vs Off-Peak 50% discount)"]
56
+ OpenRouterTier["OpenRouter API Catalog<br/>(Cached Daily)"]
57
+ end
58
+
59
+ subgraph UI ["User Interface Surfaces"]
60
+ Chip["Header Cost Chip<br/>(Active tariff + countdown)"]
61
+ Modal["Breakdown Drawer<br/>(Rates per 1M, UTC windows, model table)"]
62
+ Settings["Settings Card<br/>(Currency, USD rate, timezones)"]
63
+ end
64
+
65
+ Req --> Proj
66
+ StreamHook --> Proj
67
+ Proj --> Chip
68
+ PricingCatalog --> Proj
69
+ Manual --> PricingCatalog
70
+ DeepSeekTier --> PricingCatalog
71
+ OpenRouterTier --> PricingCatalog
72
+ Chip --> Modal
73
+ ```
74
+
75
+ ---
76
+
77
+ ## ✨ Features & Key Capabilities
78
+
79
+ 1. **Incremental Streaming Telemetry**: Computes prompt, completion, and cache read/write tokens in real-time without polling or UI stutter.
80
+ 2. **Dual-Tier Tariff Engine**: Official DeepSeek peak windows (01:00–04:00 and 06:00–10:00 UTC) with automatic 50% off-peak discount detection.
81
+ 3. **UTC Half-Hour Slot Immutability**: Historical session expenditure is permanently anchored to the rate active at the moment of execution.
82
+ 4. **Provider-Aware Routing**: Distinguishes direct provider connections from hosted gateways (e.g. OpenRouter vs native endpoints).
83
+ 5. **Interactive UI Modal & Settings**: Custom currency symbols (`$`, `€`, `₽`, `¥`), exchange rates, and timezone configurations.
84
+
85
+ ---
86
+
87
+ ## 📦 Installation
88
+
89
+ ```bash
61
90
  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
91
+ ```
92
+
93
+ Restart your DeepSeek Harness instance and refresh the browser.
94
+
95
+ ---
96
+
97
+ ## ⚙️ Configuration Reference (`settings.yaml`)
98
+
99
+ ```yaml
100
+ dsh-cost-meter:
101
+ currency: "$"
102
+ usdRate: 1.0
103
+ displayTimeZone: "UTC"
104
+ useOpenRouter: true
105
+ prices: {}
106
+ modelMap: {}
107
+ ```
108
+
109
+ ### Configuration Parameters
110
+
111
+ | Parameter | Type | Default | Description |
112
+ |:---|:---|:---|:---|
113
+ | `currency` | `string` | `"$"` | Display currency symbol or code |
114
+ | `usdRate` | `number` | `1.0` | Exchange rate multiplier from USD |
115
+ | `displayTimeZone` | `string` | `"UTC"` | Timezone for window schedule formatting (e.g. `Europe/London`, `Asia/Shanghai`) |
116
+ | `useOpenRouter` | `boolean` | `true` | Fetch OpenRouter public catalog for fallback models |
117
+ | `prices` | `object` | `{}` | Custom override rates per 1M tokens `{ prompt, completion, cacheRead?, cacheWrite? }` |
118
+ | `modelMap` | `object` | `{}` | Model alias mappings `{ "alias": "target-model" }` |
119
+
120
+ ---
121
+
122
+ ## 🧪 Testing
123
+
124
+ Run the automated test suite:
125
+
126
+ ```bash
127
+ npm test
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 📄 License
133
+
134
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
@@ -0,0 +1,134 @@
1
+ # 📦 @goodandready/dsh-cost-meter
2
+
3
+ <div align="center">
4
+
5
+ <h3>Чип стоимости сессии, переключатель пиковых тарифов и мониторинг расходов токенов для DeepSeek Harness</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-cost-meter"><img src="https://img.shields.io/npm/v/@goodandready/dsh-cost-meter.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="../LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-cost-meter.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <!-- Обязательная кнопка перехода на витрину всех проектов -->
15
+ <p align="center">
16
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/Все_проекты_автора-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="Все проекты автора"></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="../README.md"><b>🇬🇧 English</b></a> •
21
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
22
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
23
+ </p>
24
+
25
+ </div>
26
+
27
+ ---
28
+
29
+ ## ⚡ Обзор и решаемая проблема
30
+
31
+ Разработка с помощью ИИ-агентов и генерация кода требуют активного потребления токенов (промпты, рассуждения, контекстный кэш). Без непрерывного финансового контроля разработчики рискуют столкнуться с перерасходом бюджета или упустить окна 50% скидок внепикового тарифа.
32
+
33
+ **`@goodandready/dsh-cost-meter`** встраивает компактный высокоточный телеметрический чип прямо в заголовок диалога DeepSeek Harness (`conversation.session.header.utilities`):
34
+
35
+ ```text
36
+ ● ≈ ₽8.55 0:17 ← Чип стоимости сессии в шапке с обратным отсчётом тарифа
37
+ ```
38
+
39
+ По клику открывается интерактивная панель: тарифная сетка за 1M токенов (не-пик / пик с подсветкой активного режима), статус окон в UTC и детальная разбивка расходов текущей сессии по моделям.
40
+
41
+ ---
42
+
43
+ ## 🏛️ Архитектура
44
+
45
+ ```mermaid
46
+ graph TD
47
+ subgraph StreamTelemetry ["Рантайм DeepSeek Harness"]
48
+ Req["Запрос LLM / Заголовок<br/>(provider, model)"]
49
+ StreamHook["Потоковые инкрементальные чанки"]
50
+ Proj["Проекция costByModel<br/>(30-минутные слоты UTC)"]
51
+ end
52
+
53
+ subgraph PricingCatalog ["Движок тарификации"]
54
+ Manual["Пользовательские цены<br/>(settings.yaml: prices)"]
55
+ DeepSeekTier["Сетка DeepSeek<br/>(Пик / 50% скидка не-пик)"]
56
+ OpenRouterTier["Каталог OpenRouter<br/>(Ежедневный кэш)"]
57
+ end
58
+
59
+ subgraph UI ["Интерфейс пользователя"]
60
+ Chip["Чип стоимости в шапке<br/>(Тариф + таймер смены)"]
61
+ Modal["Модальное окно разбивки<br/>(Ставки за 1M, окна UTC, модели)"]
62
+ Settings["Карточка настроек<br/>(Валюта, курс USD, таймзона)"]
63
+ end
64
+
65
+ Req --> Proj
66
+ StreamHook --> Proj
67
+ Proj --> Chip
68
+ PricingCatalog --> Proj
69
+ Manual --> PricingCatalog
70
+ DeepSeekTier --> PricingCatalog
71
+ OpenRouterTier --> PricingCatalog
72
+ Chip --> Modal
73
+ ```
74
+
75
+ ---
76
+
77
+ ## ✨ Ключевые возможности
78
+
79
+ 1. **Живой расчёт во время стриминга**: расчёт стоимости инкрементально обновляется в реальном времени прямо во время генерации ответа.
80
+ 2. **Двухуровневая сетка тарифов**: официальные пиковые окна DeepSeek (01:00–04:00 и 06:00–10:00 UTC) со скидкой 50% во внепиковое время.
81
+ 3. **Иммутабельность получасовых слотов UTC**: исторические расходы сессии фиксируются по ставке слота и не пересчитываются задним числом при смене тарифа.
82
+ 4. **Provider-aware тарификация**: раздельный учёт для прямых подключений DeepSeek и шлюзов OpenRouter.
83
+ 5. **Гибкая настройка валюты**: поддержка пользовательских символов валют (`₽`, `$`, `€`, `¥`), курсов конвертации и таймзон.
84
+
85
+ ---
86
+
87
+ ## 📦 Установка
88
+
89
+ ```bash
90
+ dsh plugin --profile web add @goodandready/dsh-cost-meter
91
+ ```
92
+
93
+ Перезапустите экземпляр DeepSeek Harness и обновите страницу в браузере.
94
+
95
+ ---
96
+
97
+ ## ⚙️ Таблица конфигурации (`settings.yaml`)
98
+
99
+ ```yaml
100
+ dsh-cost-meter:
101
+ currency: "₽"
102
+ usdRate: 90.0
103
+ displayTimeZone: "Europe/Moscow"
104
+ useOpenRouter: true
105
+ prices: {}
106
+ modelMap: {}
107
+ ```
108
+
109
+ ### Параметры конфигурации
110
+
111
+ | Параметр | Тип | По умолчанию | Описание |
112
+ |:---|:---|:---|:---|
113
+ | `currency` | `string` | `"₽"` | Символ или код валюты для отображения |
114
+ | `usdRate` | `number` | `90.0` | Множитель пересчёта долларовых цен в валюту отображения |
115
+ | `displayTimeZone` | `string` | `"UTC"` | Таймзона для отображения окон в панели |
116
+ | `useOpenRouter` | `boolean` | `true` | Загружать ли каталог цен OpenRouter для сторонних моделей |
117
+ | `prices` | `object` | `{}` | Ручные ставки за 1M токенов `{ prompt, completion, cacheRead?, cacheWrite? }` |
118
+ | `modelMap` | `object` | `{}` | Алиасы моделей `{ "alias": "target-model" }` |
119
+
120
+ ---
121
+
122
+ ## 🧪 Тестирование
123
+
124
+ Запуск набора юнит-тестов:
125
+
126
+ ```bash
127
+ npm test
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 📄 Лицензия
133
+
134
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
@@ -0,0 +1,134 @@
1
+ # 📦 @goodandready/dsh-cost-meter
2
+
3
+ <div align="center">
4
+
5
+ <h3>面向 DeepSeek Harness 的会话实时花费计费芯片、波峰/波谷阶梯电价与 Token 监控插件</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-cost-meter"><img src="https://img.shields.io/npm/v/@goodandready/dsh-cost-meter.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="../LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-cost-meter.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <!-- 作者所有项目展示页面链接 -->
15
+ <p align="center">
16
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者所有开源项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="所有项目"></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="../README.md"><b>🇬🇧 English</b></a> •
21
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
22
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
23
+ </p>
24
+
25
+ </div>
26
+
27
+ ---
28
+
29
+ ## ⚡ 概述与核心痛点
30
+
31
+ 在使用智能体与大语言模型编程时,模型推理、长上下文以及缓存读写会产生大量 Token 消耗。如果缺乏实时的财务监控,开发者容易错过官方 50% 闲时折扣窗口,或因死循环导致账单超出预算。
32
+
33
+ **`@goodandready/dsh-cost-meter`** 在 DeepSeek Harness 会话顶部工具栏(`conversation.session.header.utilities`)无缝嵌入高精度花费微件芯片:
34
+
35
+ ```text
36
+ ● ≈ ¥0.85 0:17 ← 会话顶部实时花费芯片与换价倒计时
37
+ ```
38
+
39
+ 点击芯片可展开交互式详情面板:按每 1M Token 展示峰谷两档费率表格(高亮显示当前激活档位)、UTC 优惠时间段状态以及各模型在当前会话中的累计消费明细。
40
+
41
+ ---
42
+
43
+ ## 🏛️ 架构设计
44
+
45
+ ```mermaid
46
+ graph TD
47
+ subgraph StreamTelemetry ["DSH 流式遥测运行时"]
48
+ Req["LLM 请求 / 报头<br/>(provider, model)"]
49
+ StreamHook["增量流式分片 Hook"]
50
+ Proj["costByModel 会话投影<br/>(半小时 UTC 时隙)"]
51
+ end
52
+
53
+ subgraph PricingCatalog ["费率解析引擎"]
54
+ Manual["自定义费率配置<br/>(settings.yaml: prices)"]
55
+ DeepSeekTier["DeepSeek 官方费率<br/>(高峰 / 闲时 50% 折扣)"]
56
+ OpenRouterTier["OpenRouter 公开目录<br/>(每日自动缓存)"]
57
+ end
58
+
59
+ subgraph UI ["用户交互界面"]
60
+ Chip["顶部花费芯片<br/>(实时计费 + 换价倒计时)"]
61
+ Modal["消费明细抽屉面板<br/>(1M 单价表, UTC 时段, 模型清单)"]
62
+ Settings["设置卡片<br/>(币种符号, 汇率, 时区)"]
63
+ end
64
+
65
+ Req --> Proj
66
+ StreamHook --> Proj
67
+ Proj --> Chip
68
+ PricingCatalog --> Proj
69
+ Manual --> PricingCatalog
70
+ DeepSeekTier --> PricingCatalog
71
+ OpenRouterTier --> PricingCatalog
72
+ Chip --> Modal
73
+ ```
74
+
75
+ ---
76
+
77
+ ## ✨ 核心特性
78
+
79
+ 1. **增量流式实时计算**:在模型打字生成过程中毫秒级更新 Prompt、Completion 和 Cache 费用,无轮询无卡顿。
80
+ 2. **两级阶梯电价引擎**:支持 DeepSeek 官方高峰时段(UTC 01:00–04:00 与 06:00–10:00)及闲时 50% 优惠折扣自动匹配。
81
+ 3. **UTC 半小时时隙不可变性**:历史消费严格锁定在消耗发生时刻的时隙费率,切换费率不溯及既往。
82
+ 4. **服务商智能路由**:精准区分直连 DeepSeek 服务商与 OpenRouter 等代理网关。
83
+ 5. **多币种与本地化**:支持自定义货币符号(`¥`, `$`, `€`, `₽`)、汇率换算与显示时区。
84
+
85
+ ---
86
+
87
+ ## 📦 快速安装
88
+
89
+ ```bash
90
+ dsh plugin --profile web add @goodandready/dsh-cost-meter
91
+ ```
92
+
93
+ 重启 DeepSeek Harness 实例并刷新浏览器页面。
94
+
95
+ ---
96
+
97
+ ## ⚙️ 配置项说明 (`settings.yaml`)
98
+
99
+ ```yaml
100
+ dsh-cost-meter:
101
+ currency: "¥"
102
+ usdRate: 7.2
103
+ displayTimeZone: "Asia/Shanghai"
104
+ useOpenRouter: true
105
+ prices: {}
106
+ modelMap: {}
107
+ ```
108
+
109
+ ### 配置参数列表
110
+
111
+ | 参数项 | 类型 | 默认值 | 说明 |
112
+ |:---|:---|:---|:---|
113
+ | `currency` | `string` | `"¥"` | 显示的货币符号或代码 |
114
+ | `usdRate` | `number` | `7.2` | 美元到目标货币的换算汇率 |
115
+ | `displayTimeZone` | `string` | `"Asia/Shanghai"` | 界面中优惠时间段显示的参考时区 |
116
+ | `useOpenRouter` | `boolean` | `true` | 是否加载 OpenRouter 价格表作为备用 |
117
+ | `prices` | `object` | `{}` | 自定义每 1M Token 的费率覆盖 |
118
+ | `modelMap` | `object` | `{}` | 模型别名映射表 |
119
+
120
+ ---
121
+
122
+ ## 🧪 自动化测试
123
+
124
+ 运行单元测试套件:
125
+
126
+ ```bash
127
+ npm test
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 📄 许可证
133
+
134
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
@@ -1,6 +1,6 @@
1
1
  # Публичный релиз и deployment
2
2
 
3
- Релизная версия повышается patch-изменением (v0.7.3). После merge в `main`
3
+ Релизная версия повышается patch-изменением (v0.7.4). После merge в `main`
4
4
  создаются annotated tag, пакет в npm (`@goodandready/dsh-cost-meter`) и
5
5
  публичный GitHub release в `GooDAnDReaDY/dsh-cost-meter`.
6
6
 
@@ -1,7 +1,7 @@
1
1
  # DESIGN.md — dsh-cost-meter
2
2
 
3
3
  ## Product / Purpose
4
- - Назначение: Отображение стоимости активной сессии диалога в реальном времени, тарифов (пик / не-пик / единый) за 1M токенов, обратного отсчёта до смены окна и прозрачной детализации расходов по каждой задействованной модели.
4
+ - Назначение: Отображение стоимости активной сессии диалога в реальном времени, тарифов (пик / не-пик / единый) за 1M токенов, обратного отсчёта до смены окна, прозрачной детализации расходов по каждой задействованной модели и нативного управления конфигурацией плагина.
5
5
  - Аудитория: Пользователи и инженеры DeepSeek Harness, контролирующие потребление токенов и бюджет на LLM-провайдеров.
6
6
  - Статус: Активная публичная поставка (@goodandready/dsh-cost-meter).
7
7
 
@@ -9,19 +9,22 @@
9
9
  - Web/UI:
10
10
  - Компактный чип в слоте `conversation.session.header.utilities` (order: 10).
11
11
  - Выпадающая панель детализации (width: 320px, absolute positioning, click-outside dismissal).
12
+ - Карточка настроек плагина в `settings.plugin.item` (слот `settings.plugin.item` с fallback на `settings.section`).
12
13
  - DSH UI / settings / slots:
13
- - Слот: `conversation.session.header.utilities`.
14
+ - Слот чипа: `conversation.session.header.utilities`.
15
+ - Слот карточки настроек: `settings.plugin.item`.
14
16
  - Настройки: реестр DSH `settings.register("dsh-cost-meter", Config)` (валюта `currency`, множитель курса `usdRate`, таймзона `displayTimeZone`, опрос OpenRouter `useOpenRouter`, ручные ставки `prices`, сопоставление `modelMap`).
17
+ - Локализация: регистрация словарей через `ctx.locale.register("dsh-cost-meter", { en, ru })` с поддержкой переключения языков через `useActiveLocale(ctx)`.
15
18
  - API:
16
19
  - HTTP `GET /dsh-cost-meter/state` (параметр `?models=...` для получения тарифов по задействованным моделям).
17
20
  - CLI:
18
21
  - Управление через CLI харнесса: `dsh plugin --profile web add @goodandready/dsh-cost-meter`.
19
22
  - Документация:
20
- - README.md, index.md, docs/testing/public-route.md, docs/deployment/public-release.md.
23
+ - README.md, index.md, docs/testing/public-route.md, docs/testing/projection-lifecycle.md, docs/deployment/public-release.md.
21
24
 
22
25
  ## Visual Direction
23
26
  - Атмосфера: Строгий утилитарный нативный интерфейс DSH, высокая информационная плотность без визуального шума.
24
- - Утверждённые референсы: Дизайн-система DeepSeek Harness (`--dsw-alias-*`), чипы в шапке сессии (`dsh-key-rotation`, `dsh-context-lens`).
27
+ - Утверждённые референсы: Дизайн-система DeepSeek Harness (`--dsw-alias-*`), чипы в шапке сессии (`dsh-key-rotation`, `dsh-context-lens`), стандарт карточек настроек DSH.
25
28
  - Не копировать: Избыточные градиенты, внешние модальные окна, тяжелые тени, кастомные несогласованные шрифты.
26
29
 
27
30
  ## Foundations
@@ -29,7 +32,7 @@
29
32
  - `--dsw-alias-label-primary`: основной текст, заголовки, суммы.
30
33
  - `--dsw-alias-label-tertiary`: второстепенные пояснения, метки столбцов, чип без тарифа.
31
34
  - `--dsw-alias-border-l2`: разделители секций и обводка панели.
32
- - `--dsw-alias-bg-layer-2` / `--dsw-alias-bg-base`: подложка всплывающей панели.
35
+ - `--dsw-alias-bg-layer-2` / `--dsw-alias-bg-base`: подложка всплывающей панели и карточки настроек.
33
36
  - `--dsw-alias-state-success-primary` / `--dsw-alias-state-success-tertiary`: подсветка льготного окна (не-пик).
34
37
  - `--dsw-alias-state-warn-primary` / `--dsw-alias-state-warn-tertiary`: подсветка полного тарифа (пик).
35
38
  - Типографика:
@@ -38,13 +41,10 @@
38
41
  - Сетка и отступы:
39
42
  - Чип: высота 22px, padding: 0 8px, gap: 6px, border-radius: 6px.
40
43
  - Панель: padding: 12px 14px, border-radius: 10px, ширина 320px.
41
- - Сетки: `grid` с выравниванием числовых колонок по правому краю (`text-align: right`).
42
- - Accessibility:
43
- - Семантические теги `button` для интерактивных элементов, нативные `title` подсказки при наведении, закрытие панели при клике вне компонента.
44
+ - Карточка настроек: нативный складной контейнер (`.dcm-settings-card`), отступы полей, бейдж статуса снимка (`ready`, `loading`, `unavailable`).
44
45
 
45
- ## Components And States
46
+ ## Components & States
46
47
  - Чип:
47
- - `Loading / Init`: если данные ещё не загружены, чип скрыт (`null`).
48
48
  - `Unknown Tariff`: отображает «тариф ?» в приглушённом цвете `label-tertiary`.
49
49
  - `Flat Tariff`: фон `--dsw-alias-state-warn-tertiary`, текст `--dsw-alias-state-warn-primary`, заголовок «Единый тариф».
50
50
  - `Off-Peak`: зелёная плашка успеха (льготный тариф).
@@ -55,28 +55,41 @@
55
55
  - Сетка тарифов за 1M токенов (не-пик / пик / единая ставка).
56
56
  - Блок «Из чего сложилась сумма»:
57
57
  - Список всех моделей сессии с токенами и суммарной стоимостью.
58
- - Для модели без тарифа отображается статус `не указана стоимость`.
58
+ - Для модели без тарифа отображается статус `не указана стоимость` (`rate_unspecified`).
59
59
  - Итог сессии: `Сессия: ≈ <валюта><сумма>`.
60
60
  - Отсутствие мусорных служебных плашек (строка источника OpenRouter исключена).
61
+ - Карточка настроек (`CostMeterCard`):
62
+ - Сворачиваемый заголовок с шевроном и иконкой счётчика.
63
+ - Статус снимка: `ready` (зелёный бейдж), `loading` (нейтральный), `unavailable` (серый).
64
+ - Поля: выбор символа валюты (`currency`), курс к USD (`usdRate`), таймзона отображения (`displayTimeZone`).
65
+ - Локализованные лейблы и плейсхолдеры на русском и английском языках.
61
66
 
62
67
  ## User Flows
63
68
  1. Пользователь ведёт диалог с ассистентом: чип в шапке инкрементально обновляет потраченную сумму прямо во время стриминга ответа ассистента.
64
69
  2. Пользователь нажимает на чип: открывается всплывающая панель с тарифной сеткой и расходами по каждой модели сессии.
65
70
  3. Если модель не тарифицирована: в списке моделей выводится статус `не указана стоимость`.
71
+ 4. Пользователь открывает настройки DSH (`Настройки` -> `Плагины`): карточка `dsh-cost-meter` позволяет изменить валюту, курс и таймзону с мгновенным сохранением в `settingsScope`.
66
72
 
67
73
  ## Do / Don't
68
74
  - Do:
69
75
  - Использовать CSS-переменные DSH для согласованности с тёмной и светлой темами.
70
76
  - Всегда разделять расходы по задействованным моделям.
71
77
  - Использовать табличные цифры (`tabular-nums`) для предотвращения дёргания вёрстки при стриме.
78
+ - Использовать `ctx.locale.register` и `useActiveLocale` для всех строк интерфейса.
72
79
  - Don't:
73
80
  - Не выводить техническую служебную информацию о каталоге OpenRouter в интерфейс пользователя.
74
81
  - Не заменять отсутствие тарифа обманчивым нулём или невнятным прочерком — писать прямо `не указана стоимость`.
75
82
  - Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
76
83
 
77
84
  ## History & Decisions
85
+ - 2026-09-05 (v0.7.4):
86
+ - Добавлена нативная карточка настроек в слот `settings.plugin.item` (`#12`).
87
+ - Реализована полная локализация клиентской части через `ctx.locale.register` (`#2`).
88
+ - Реализовано provider-aware разрешение тарифов OpenRouter vs DeepSeek (`#3`).
89
+ - Добавлены всеобъемлющие регрессионные тесты на проекцию и сопоставление (`test/projection-lifecycle.test.mjs`, `#1`).
90
+ - Закрыт issue о согласовании публичного scope пакета (`#13`).
78
91
  - 2026-09-05 (v0.7.3):
79
92
  - Удалена строка источника цен OpenRouter.
80
93
  - Список моделей в блоке «Из чего сложилась сумма» унифицирован и отображается для всех задействованных моделей сессии.
81
94
  - Для нетарифицированных моделей введён явный статус «не указана стоимость».
82
- - Пакет переведён на публичный scope `@goodandready/dsh-cost-meter`.
95
+ - Пакет переведён на публичный scope `@goodandready/dsh-cost-meter`.
@@ -0,0 +1,28 @@
1
+ # Регрессионное тестирование проекции тарифов и жизненного цикла
2
+
3
+ Тесты проверяют логику тарификации, парсинг временных окон UTC, сопоставление моделей и provider-aware разрешение ставок.
4
+
5
+ ## Запуск тестов
6
+
7
+ ```bash
8
+ npm test
9
+ ```
10
+
11
+ ## Проверяемые сценарии (`test/projection-lifecycle.test.mjs`)
12
+
13
+ 1. **`hhmmToMinutes` и `parseWindow`**:
14
+ - Корректный разбор диапазонов `HH:MM-HH:MM` в минуты от начала суток UTC.
15
+ - Граничные условия: переход через полночь (`23:00-02:00`), пустые или невалидные строки.
16
+
17
+ 2. **`slotOf`**:
18
+ - Вычисление 30-минутного слота суток (`0..47`) на основе переданного timestamp.
19
+ - Независимость накопления расхода от текущего момента времени (сохранение исторической ставки слота).
20
+
21
+ 3. **`indexCatalog` и `matchModel`**:
22
+ - Индексация моделей OpenRouter с поддержкой поиска без учета регистра.
23
+ - Нормализация суффиксов вендоров и версий (например, `vendor/model:variant`).
24
+
25
+ 4. **`resolveTariff` (Provider-aware)**:
26
+ - Если указан `provider: "openrouter"`, плагин в первую очередь обращается к каталогу OpenRouter, даже если имя модели содержит `deepseek`.
27
+ - Если провайдер не `openrouter` (или `deepseek`), DeepSeek модели тарифицируются по официальной зашитой сетке (пик / не-пик).
28
+ - Ручные ставки `prices` из конфигурации имеют высший приоритет над внешними каталогами.
package/lib/client.js CHANGED
@@ -19,6 +19,269 @@ window.__ModuleLoader__.load({
19
19
 
20
20
  const STATE_PATH = '/dsh-cost-meter/state'
21
21
  const DAY = 1440
22
+ const NS = 'dsh-cost-meter'
23
+
24
+ const en = {
25
+ title: 'Cost Meter',
26
+ subtitle: 'Session cost in header, token rates, and per-model breakdown',
27
+ chipUnknown: 'tariff ?',
28
+ chipRate: 'rate',
29
+ offPeak: 'off-peak',
30
+ peak: 'peak',
31
+ flat: 'flat',
32
+ modelsBreakdown: 'Model Breakdown',
33
+ sessionTokens: 'Session Tokens',
34
+ totalCost: 'Total Cost',
35
+ noPriceSpecified: 'rate not specified',
36
+ noUsageYet: 'Provider has not reported usage for this session yet.',
37
+ routeUnknown: 'route unknown',
38
+ catalogUnavailable: 'OpenRouter catalog unavailable: ',
39
+ modelNotFound: 'Model not found in OpenRouter catalog. Set manual prices in settings (prices) or map model via modelMap.',
40
+ 'settings.loading': 'Loading settings…',
41
+ 'settings.unavailable': 'Settings unavailable (namespace is not registered yet).',
42
+ 'settings.save': 'Save',
43
+ 'settings.saving': 'Saving…',
44
+ 'settings.saved': 'Saved',
45
+ 'settings.saveFailed': 'Failed to save settings: ',
46
+ 'field.currency': 'Currency symbol',
47
+ 'field.usdRate': 'USD exchange rate',
48
+ 'field.displayTimeZone': 'Display timezone',
49
+ 'hint.currency': 'Currency symbol shown in the widget (e.g. $, ₽, €).',
50
+ 'hint.usdRate': 'Exchange rate: units of currency per 1 USD (e.g. 80 for ₽).',
51
+ 'hint.displayTimeZone': 'IANA timezone used for tariff peak/off-peak schedule.',
52
+ }
53
+
54
+ const ru = {
55
+ title: 'Счётчик расходов (Cost Meter)',
56
+ subtitle: 'Стоимость сессии в шапке, тарифы токенов и детализация по моделям',
57
+ chipUnknown: 'тариф ?',
58
+ chipRate: 'тариф',
59
+ offPeak: t('offPeak'),
60
+ peak: 'пик',
61
+ flat: 'единый',
62
+ modelsBreakdown: t('modelsBreakdown'),
63
+ sessionTokens: t('sessionTokens'),
64
+ totalCost: t('totalCost'),
65
+ noPriceSpecified: t('noPriceSpecified'),
66
+ noUsageYet: t('noUsageYet'),
67
+ routeUnknown: 'маршрут неизвестен',
68
+ catalogUnavailable: 'Каталог OpenRouter недоступен: ',
69
+ modelNotFound: t('modelNotFound'),
70
+ 'settings.loading': 'Загрузка настроек…',
71
+ 'settings.unavailable': 'Настройки недоступны (пространство ещё не зарегистрировано).',
72
+ 'settings.save': 'Сохранить',
73
+ 'settings.saving': 'Сохранение…',
74
+ 'settings.saved': 'Сохранено',
75
+ 'settings.saveFailed': 'Не удалось сохранить настройки: ',
76
+ 'field.currency': 'Символ валюты',
77
+ 'field.usdRate': 'Курс валюты к USD',
78
+ 'field.displayTimeZone': 'Часовой пояс отображения',
79
+ 'hint.currency': 'Символ валюты, отображаемый в виджете (например, ₽, $, €).',
80
+ 'hint.usdRate': 'Сколько единиц валюты в 1 долларе США (например, 80 для ₽).',
81
+ 'hint.displayTimeZone': 'IANA таймзона для расчёта часов пика и не-пика (например, Europe/Moscow).',
82
+ }
83
+
84
+ let ChevronIcon = null
85
+ try {
86
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
87
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14
88
+ } catch {
89
+ ChevronIcon = null
90
+ }
91
+
92
+ function Chevron(props) {
93
+ if (ChevronIcon) {
94
+ return React.createElement(ChevronIcon, {
95
+ className: 'dcm-chev' + (props.open ? ' dcm-chev-open' : ''),
96
+ style: {
97
+ marginLeft: 'auto', flex: 'none', color: 'var(--dsw-alias-label-tertiary)',
98
+ transition: 'transform .16s', transform: props.open ? 'rotate(180deg)' : 'none',
99
+ },
100
+ })
101
+ }
102
+ return React.createElement('svg', {
103
+ className: 'dcm-chev' + (props.open ? ' dcm-chev-open' : ''),
104
+ style: {
105
+ marginLeft: 'auto', flex: 'none', color: 'var(--dsw-alias-label-tertiary)',
106
+ transition: 'transform .16s', transform: props.open ? 'rotate(180deg)' : 'none',
107
+ },
108
+ width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true',
109
+ }, React.createElement('path', {
110
+ d: 'M3.5 5.25L7 8.75L10.5 5.25', stroke: 'currentColor', strokeWidth: 1.5,
111
+ strokeLinecap: 'round', strokeLinejoin: 'round',
112
+ }))
113
+ }
114
+
115
+ const cardCss = '.dcm-card { border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-3); border-radius:12px; list-style:none } ' +
116
+ '.dcm-head { appearance:none; width:100%; font:inherit; color:inherit; text-align:left; cursor:pointer; background:0 0; border:0; border-radius:12px; display:flex; align-items:center; gap:12px; padding:14px 16px } ' +
117
+ '.dcm-title { color:var(--dsw-alias-label-primary); font-size:15px; font-weight:600; line-height:1.4 } ' +
118
+ '.dcm-sub { color:var(--dsw-alias-label-secondary); font-size:13px } ' +
119
+ '.dcm-body { border-top:1px solid var(--dsw-alias-border-l2); margin:0 16px; padding-bottom:8px } ' +
120
+ '.dcm-field { display:flex; flex-direction:column; gap:6px; padding:12px 0 } ' +
121
+ '.dcm-label { color:var(--dsw-alias-label-primary); font-size:13px; font-weight:500 } ' +
122
+ '.dcm-hint { color:var(--dsw-alias-label-secondary); font-size:12px } ' +
123
+ '.dcm-input { height:34px; border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-3); color:var(--dsw-alias-label-primary); border-radius:8px; padding:0 12px; font-size:13px } ' +
124
+ '.dcm-foot { border-top:1px solid var(--dsw-alias-border-l2); display:flex; justify-content:flex-end; align-items:center; gap:8px; padding:12px 0 4px } ' +
125
+ '.dcm-save { appearance:none; font:inherit; cursor:pointer; border:1px solid transparent; border-radius:8px; padding:5px 14px; font-size:13px; background:var(--dsw-alias-label-primary); color:var(--dsw-alias-bg-layer-3) } ' +
126
+ '.dcm-msg-ok { color:var(--dsw-alias-state-success-primary); font-size:12px } ' +
127
+ '.dcm-msg-err { color:var(--dsw-alias-state-danger-primary, #e53935); font-size:12px }';
128
+
129
+ function ensureCardStyles() {
130
+ if (typeof document === 'undefined') return
131
+ if (document.getElementById('dsh-cost-meter-card-styles')) return
132
+ const el = document.createElement('style')
133
+ el.id = 'dsh-cost-meter-card-styles'
134
+ el.textContent = cardCss
135
+ document.head.appendChild(el)
136
+ }
137
+
138
+ function makeT(dict, fallback) {
139
+ return function t(key, vars) {
140
+ let val = (dict && dict[key]) || (fallback && fallback[key]) || key
141
+ if (vars && typeof val === 'string') {
142
+ for (const k of Object.keys(vars)) {
143
+ val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
144
+ }
145
+ }
146
+ return val
147
+ }
148
+ }
149
+
150
+ function useActiveLocale(ctx) {
151
+ return React.useSyncExternalStore(
152
+ React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
153
+ React.useCallback(() => (ctx && ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot().active : 'ru'), [ctx]),
154
+ React.useCallback(() => 'ru', []),
155
+ )
156
+ }
157
+
158
+ function CostMeterCard(props) {
159
+ const ctx = props.ctx
160
+ const locale = useActiveLocale(ctx)
161
+ const t = props.t || makeT(locale === 'ru' ? ru : en, en)
162
+ const [open, setOpen] = React.useState(false)
163
+ const [draft, setDraft] = React.useState(null)
164
+ const [saving, setSaving] = React.useState(false)
165
+ const [err, setErr] = React.useState('')
166
+ const [saved, setSaved] = React.useState(false)
167
+
168
+ const scope = React.useMemo(
169
+ () => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
170
+ [ctx],
171
+ )
172
+
173
+ const snapshot = React.useSyncExternalStore(
174
+ React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
175
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
176
+ React.useCallback(() => ({ status: 'loading' }), []),
177
+ )
178
+
179
+ React.useEffect(() => { ensureCardStyles() }, [])
180
+
181
+ const status = (snapshot && snapshot.status) || 'loading'
182
+ const stored = (snapshot && snapshot.value) || {}
183
+
184
+ React.useEffect(() => {
185
+ if (status === 'ready' && draft === null) {
186
+ setDraft({
187
+ currency: stored.currency !== undefined ? String(stored.currency) : '$',
188
+ usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
189
+ displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
190
+ })
191
+ }
192
+ }, [status, stored, draft])
193
+
194
+ const save = async () => {
195
+ if (!scope || !draft) return
196
+ setSaving(true); setErr(''); setSaved(false)
197
+ const broken = []
198
+ try {
199
+ await scope.set('currency', String(draft.currency || '$'))
200
+ } catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
201
+
202
+ const rateNum = Number(String(draft.usdRate).trim())
203
+ if (!Number.isFinite(rateNum) || rateNum <= 0) {
204
+ broken.push('usdRate: must be a positive number')
205
+ } else {
206
+ try {
207
+ await scope.set('usdRate', rateNum)
208
+ } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
209
+ }
210
+
211
+ try {
212
+ await scope.set('displayTimeZone', String(draft.displayTimeZone || 'Europe/Moscow').trim())
213
+ } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
214
+
215
+ setSaving(false)
216
+ if (broken.length) {
217
+ setErr(t('settings.saveFailed') + broken.join('; '))
218
+ return
219
+ }
220
+ setSaved(true)
221
+ setTimeout(() => setSaved(false), 2500)
222
+ }
223
+
224
+ return React.createElement('li', { className: 'dcm-card' },
225
+ React.createElement('button', {
226
+ type: 'button', className: 'dcm-head', 'aria-expanded': open,
227
+ onClick: () => setOpen((v) => !v),
228
+ },
229
+ React.createElement('span', { style: { display: 'flex', flexDirection: 'column' } },
230
+ React.createElement('span', { className: 'dcm-title' }, t('title')),
231
+ React.createElement('span', { className: 'dcm-sub' }, t('subtitle')),
232
+ ),
233
+ React.createElement(Chevron, { open }),
234
+ ),
235
+ open ? React.createElement('div', { className: 'dcm-body' },
236
+ status === 'loading'
237
+ ? React.createElement('p', { className: 'dcm-hint', style: { padding: '12px 0' } }, t('settings.loading'))
238
+ : status !== 'ready'
239
+ ? React.createElement('p', { className: 'dcm-msg-err', style: { padding: '12px 0' } }, t('settings.unavailable'))
240
+ : React.createElement('div', null,
241
+ React.createElement('div', { className: 'dcm-field' },
242
+ React.createElement('label', { className: 'dcm-label' }, t('field.currency')),
243
+ React.createElement('input', {
244
+ className: 'dcm-input',
245
+ value: (draft && draft.currency) !== undefined ? draft.currency : '',
246
+ onChange: (e) => setDraft({ ...draft, currency: e.target.value }),
247
+ }),
248
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.currency')),
249
+ ),
250
+ React.createElement('div', { className: 'dcm-field' },
251
+ React.createElement('label', { className: 'dcm-label' }, t('field.usdRate')),
252
+ React.createElement('input', {
253
+ className: 'dcm-input',
254
+ type: 'number',
255
+ step: 'any',
256
+ value: (draft && draft.usdRate) !== undefined ? draft.usdRate : '',
257
+ onChange: (e) => setDraft({ ...draft, usdRate: e.target.value }),
258
+ }),
259
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.usdRate')),
260
+ ),
261
+ React.createElement('div', { className: 'dcm-field' },
262
+ React.createElement('label', { className: 'dcm-label' }, t('field.displayTimeZone')),
263
+ React.createElement('input', {
264
+ className: 'dcm-input',
265
+ value: (draft && draft.displayTimeZone) !== undefined ? draft.displayTimeZone : '',
266
+ onChange: (e) => setDraft({ ...draft, displayTimeZone: e.target.value }),
267
+ }),
268
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.displayTimeZone')),
269
+ ),
270
+ err ? React.createElement('div', { className: 'dcm-msg-err', style: { padding: '4px 0' } }, err) : null,
271
+ saved ? React.createElement('div', { className: 'dcm-msg-ok', style: { padding: '4px 0' } }, t('settings.saved')) : null,
272
+ React.createElement('div', { className: 'dcm-foot' },
273
+ React.createElement('button', {
274
+ type: 'button',
275
+ className: 'dcm-save',
276
+ disabled: saving,
277
+ onClick: save,
278
+ }, saving ? t('settings.saving') : t('settings.save')),
279
+ ),
280
+ )
281
+ ) : null,
282
+ )
283
+ }
284
+
22
285
 
23
286
  // ---------------------------------------------------------------- tariff
24
287
 
@@ -198,6 +461,9 @@ window.__ModuleLoader__.load({
198
461
  // ------------------------------------------------------------ component
199
462
 
200
463
  function CostMeter(props) {
464
+ const ctx = props.ctx
465
+ const locale = useActiveLocale(ctx)
466
+ const t = props.t || makeT(locale === 'ru' ? ru : en, en)
201
467
  const useProjection = props.useProjection
202
468
  const usage = useProjection ? useProjection('tokenUsage') : undefined
203
469
  const byModel = useProjection ? useProjection('costByModel') : undefined
@@ -253,14 +519,14 @@ window.__ModuleLoader__.load({
253
519
  h('button', {
254
520
  key: 'chip', type: 'button',
255
521
  style: Object.assign({}, S.chip, { background: 'transparent', color: C.muted }),
256
- onClick: () => setOpen((v) => !v), title: 'Тариф неизвестен',
522
+ onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
257
523
  }, 'тариф ?'),
258
524
  open ? h('div', { key: 'panel', style: S.panel }, [
259
525
  h('div', { key: 't', style: S.title },
260
- state.route ? state.route.provider + ' / ' + state.route.model : 'маршрут неизвестен'),
526
+ state.route ? state.route.provider + ' / ' + state.route.model : t('routeUnknown')),
261
527
  h('div', { key: 'n', style: S.note },
262
528
  state.catalogError
263
- ? 'Каталог OpenRouter недоступен: ' + state.catalogError
529
+ ? t('catalogUnavailable') + state.catalogError
264
530
  : 'Модель не найдена в каталоге OpenRouter. Задайте ставки вручную в настройках (prices) или сопоставьте модель через modelMap.'),
265
531
  ]) : null,
266
532
  ])
@@ -353,7 +619,7 @@ window.__ModuleLoader__.load({
353
619
  open ? h('div', { key: 'panel', style: S.panel }, [
354
620
  h('div', { key: 'title', style: S.title },
355
621
  (state.route ? state.route.model : 'модель') +
356
- (flat ? '' : ' — сейчас ' + (cheapNow ? 'не-пик' : 'пик'))),
622
+ (flat ? '' : ' — сейчас ' + (cheapNow ? 'не-пик' : t('peak')))),
357
623
 
358
624
  h('div', { key: 'note', style: S.note },
359
625
  flat
@@ -457,15 +723,41 @@ window.__ModuleLoader__.load({
457
723
  }
458
724
 
459
725
  function apply(ctx) {
726
+ if (ctx.locale && ctx.locale.register) {
727
+ try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
728
+ }
729
+
460
730
  ctx.slots.inject('conversation.session.header.utilities', () =>
461
731
  ctx.slots.register(
462
- { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10 },
463
- CostMeter,
732
+ { name: 'conversation.session.header.utilities', id: 'dsh-cost-meter', order: 10, inject: () => ({ ctx }) },
733
+ (p) => React.createElement(CostMeter, { ...p, ctx }),
464
734
  ),
465
735
  )
736
+
737
+ let placed = false
738
+ ctx.slots.inject('settings.plugin.item', () => {
739
+ placed = true
740
+ return ctx.slots.register(
741
+ { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
742
+ (p) => React.createElement(CostMeterCard, { ...p, ctx }),
743
+ )
744
+ })
745
+
746
+ // Fallback to settings.section if settings.plugin.item is not declared
747
+ setTimeout(() => {
748
+ if (placed) return
749
+ try {
750
+ ctx.slots.inject('settings.section', () =>
751
+ ctx.slots.register(
752
+ { name: 'settings.section', id: '@goodandready/dsh-cost-meter', order: 25, locale: NS, label: () => makeT(useActiveLocale(ctx) === 'ru' ? ru : en, en)('title'), inject: () => ({ ctx }) },
753
+ (p) => React.createElement(CostMeterCard, { ...p, ctx }),
754
+ ),
755
+ )
756
+ } catch (_) {}
757
+ }, 500)
466
758
  }
467
759
 
468
- module.exports = { apply, inject: ['slots'] }
760
+ module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
469
761
  return module.exports
470
762
  },
471
763
  })
package/lib/index.js CHANGED
@@ -373,6 +373,14 @@ export function resolveTariff(cfg, index, route) {
373
373
  // Keyed by MODEL, not provider: a DeepSeek model is billed at DeepSeek's
374
374
  // published rate whoever routes it (deepseek-official, opencode-go, ollama).
375
375
  // Date/variant suffixes (`deepseek-v4-flash:0731`) name the same product.
376
+ const providerLower = String(route.provider || "").toLowerCase();
377
+ // #3: Provider-aware routing: if routed explicitly through OpenRouter,
378
+ // consult OpenRouter catalog first to honor actual provider billing.
379
+ if (providerLower === "openrouter" && cfg.useOpenRouter !== false) {
380
+ const matchedId = matchModel(index, route, cfg.modelMap);
381
+ if (matchedId) return { schedule: index.byId[matchedId], source: "openrouter", matchedId };
382
+ }
383
+
376
384
  const base = String(route.model).toLowerCase().split(':')[0]
377
385
  const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
378
386
  if (peak) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "DeepSeek Harness plugin: a session cost chip in the conversation header - live token usage by bucket, peak/off-peak tariff, and a countdown to the next tariff switch.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",