- JavaScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Document the Cordis inject trap that removed the slot entry, portal and hook pitfalls, account RPC races, test-harness blind spots, and live-debugging tips. Update the plan checklist and the account RPC notes to match the child ctx.inject implementation. |
||
| docs/plans | ||
| locale | ||
| test | ||
| .gitignore | ||
| client.js | ||
| cordis.patch.yml | ||
| icon.svg | ||
| index.js | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
dsh-session-cost
Плагин для DeepSeek Harness (dsh): показывает под композером оценку стоимости
текущей сессии в долларах США — рядом со счётчиком токенов. По клику пилюля
открывает поповер с разбивкой, статистикой сессии и балансом аккаунта.
1.2k токенов ≈$0.75 ▮▮▮▮ 12%
Что делает
- Добавляет пилюлю
≈$0.75в док композера, сразу после счётчика токенов. - По клику,
EnterилиSpaceоткрывает поповер с подробностями; нативногоtitle-тултипа больше нет. - Показывает разбивку по всей сессии: вход без кеша, вход из кеша, запись в кеш, выход и итог.
- Показывает, сколько сэкономил кеш и какая доля входа пришла из кеша.
- Показывает статистику сессии: ходы, шаги, время LLM и инструментов, TTFT, скорость декодирования.
- Показывает баланс аккаунта — весь аккаунт, не сессию: пополненный и бонусный, со временем данных, кнопкой обновления и ссылкой на usage.
- Явно подписывает сумму как оценку по тарифу, действующему сейчас.
- Скрыта, пока в сессии нет ни одного токена.
- Русский и английский интерфейс, тема (светлая/тёмная) наследуется от харнесса.
Требования
dsh0.2.1-rc.2 — на этой версии плагин проверен; более старые не тестировались.- Веб-интерфейс (
web-профиль).
Установка
Рекомендуемый путь — через агента dsh:
plugin_manager install_bundle target=/абсолютный/путь/к/dsh-session-cost
Агент сам добавит пакет в профиль, включит бандл и применит изменения.
Альтернатива для разработки — dsh plugin --profile web add /путь/к/dsh-session-cost:
команда ставит пакет, но бандл после неё нужно включить в dsh.profile.bundles
профиля (проще пользоваться агентским путём).
Перезагрузка страницы обычно не нужна: харнесс присылает обновлённый граф клиентских модулей, и новый плагин подгружается на лету. Если пилюля не появилась — перезагрузите страницу вручную.
Проверка, что бандл попал в композицию:
dsh --profile web --dump-config | grep -A1 session-cost
Проверка, что плагин живой: в слоте conversation.composer.dock появляется
ячейка session-cost с order: 2, а под композером — пилюля ≈$… (она скрыта,
пока в сессии нет токенов). Состояние слотов агент умеет читать через
client-инспекцию слотов.
Как считается стоимость
Исходные данные — три штатные проекции сессии:
tokenUsage— суммы, которые сообщил провайдер, по всему журналу сессии (включая страницы; fork наследует журнал целиком);modelSelection— модель, которую использует следующий запрос (или последняя использованная);sessionStats—turns,steps,llmMs,toolMs,ttftMs,ttftSteps,decodeMs,decodeTokensпо всему журналу.
Цены DeepSeek — публичный прайс-лист за 1M токенов, пиковый тариф, USD:
| модель | cache miss | cache hit | output |
|---|---|---|---|
deepseek-flash |
$0.30 | $0.0060 | $1.20 |
deepseek-v4-pro |
$1.32 | $0.0440 | $3.96 |
Правила:
- Непиковый тариф — ровно половина цены. Пиковые часы: 01:00–04:00 и 06:00–10:00 UTC по будням; выходные — всегда непиковые.
- Запись в кеш отдельно не тарифицируется и считается по ставке cache miss.
- Старые идентификаторы (
deepseek-v4-flash,deepseek-v4-flash-vision-exp) считаются по ценеdeepseek-flashбез пометки. - Неизвестная модель — по цене
deepseek-flash, в поповере появляется предупреждение: «No built-in rate for …, priced as …». - Сумма округляется до 2–6 знаков: маленькие суммы не превращаются в
$0.00.
Формула для каждой категории:
стоимость = токены × цена_за_1M / 1 000 000 × (пиковый ? 1 : 0.5)
Пример: 1M входных токенов без кеша и 1M выходных на deepseek-flash
в непиковый тариф = (0.30 + 1.20) / 2 = $0.75.
В поповере дополнительно считаются:
- экономия кеша —
cacheRead × (cacheMiss − cacheHit) × 1e-6 × (пиковый ? 1 : 0.5), оценка относительно варианта «весь вход по cache miss»; - доля входа из кеша —
cacheRead / (uncached + cacheRead + cacheWrite); - TTFT —
ttftMs / ttftSteps, скорость —decodeTokens / (decodeMs / 1000).
Баланс берётся из account RPC в дочернем inject-скоупе
(ctx.inject(['remote', 'remote.account'], …)): сначала getState(), затем
getBalance(metadata). Данные кешируются в браузере на
60 секунд, запрос ограничен логическим таймаутом 5 секунд; при ошибке
обновления старые значения остаются с пометкой «данные устарели».
Настройка цен
Таблица цен лежит в client.js (PRICES) и является единственным
местом, где правится ставка. Клиентская половина плагина не может
читать Host Config, поэтому конфигурации в cordis.patch.yml нет.
После правки: при установке по ссылке (link:) правка подхватывается сама,
горячей перезагрузкой модуля (см. «Режим разработки»); при упакованной
установке — переустановить пакет.
Добавление новой модели: строка в PRICES и, при необходимости, алиас в
ALIASES. Цены — только за 1M токенов, в долларах.
Устройство
| Файл | Роль |
|---|---|
| package.json | манифест: dsh.bundle.patch, dsh.client, экспорты локалей и иконки |
| cordis.patch.yml | Loader-патч: один ряд session-cost |
| index.js | Host-половина, пустой apply() — даёт бандлу ряд композиции |
| client.js | клиентский модуль: цены, локали, стили, пилюля и портальный поповер, регистрация в слоте |
| locale/en.json, locale/ru.json | название и описание для Plugin Manager |
| icon.svg | иконка плагина |
| test/pricing.test.mjs | тесты расчётов, панели и клика в VM-песочнице |
Точки расширения:
- Loader — патч вставляет ряд
session-cost, к которому цепляется бандл. dsh.client— харнесс отдаётclient.jsстранице как клиентский модуль;injectперечисляет обязательные сервисы (slots,locale), без них модуль не активируется.- Слот —
ctx.slots.inject('conversation.composer.dock', …)иctx.slots.register({ id: 'session-cost', order: 2, locale: 'session-cost' }). Компонент получает стандартные пропсы слота, используютсяuseProjectionиt. - Локаль —
ctx.locale.register('session-cost', 'en'|'ru', …); опцияlocaleв регистрации передаёт компоненту функциюt. - Стили —
<style>внутриctx.effect, только токены--dsw-*и--dsh-content-*, классы с префиксомdsh-sc-; при выгрузке удаляются. - Account RPC —
ctx.inject(['remote', 'remote.account'], …): сервис читается в дочернем скоупе, поэтому без account RPC пилюля и поповер работают, а секция «Провайдер» не рисуется. Прямойctx.get('remote').accountиз корневого контекста бросаетcannot get property "remote.account" without inject; ошибка в эффекте попадает в error boundary слота DSH, и пилюля исчезает до перезагрузки страницы.
Поток данных:
журнал сессии → tokenUsage ─────┐
→ modelSelection ─┼→ пропсы слота → CostPill → пилюля
→ sessionStats ───┘ └→ портал → CostPanel
↑
account RPC → getState → getBalance → useAccountSummary ────┘ (кеш 60 с)
Своего хранилища и событий сессии у плагина нет: сумма и статистика считаются
на клиенте из проекций, баланс читается по запросу и живёт в useRef компонента.
Разработка
npm test # расчёты, панель, клик, границы тарифных окон
npm run check # синтаксис client.js и index.js
Тест загружает client.js в node:vm с заглушками загрузчика модулей,
React, react-dom и DOM, фиксирует часы и проверяет пиковый/непиковый тариф,
кеш, алиасы, неизвестные модели, округление, границы тарифных окон,
форматирование баланса и длительностей, позиционирование панели, содержимое
поповера для состояний ready / signed-out / error / stale / unavailable,
RU-локаль и открытие панели по клику. Для клика используется мини-рантайм
хуков: useState/useRef/useEffect живут между рендерами, поэтому тест
видит «пилюля осталась, панель открылась», а не только начальное состояние.
Клиентский модуль — не ES-модуль: он регистрирует фабрику через
window.__ModuleLoader__.load и получает React через require('react').
Сборка не нужна.
Режим разработки
Профиль web ставит плагин ссылкой на исходники:
"@local/session-cost": "link:/абсолютный/путь/к/чекауту"
Поэтому правка сразу попадает в работающий харнесс — без пересборки,
переустановки пакета и перезапуска dsh.
Горячая перезагрузка клиентской половины: dsh-client-hmr раз в 500 мс
сверяет mtime/ctime/size файла бандла у каждой строки графа и на изменение
шлёт кадр rebuilt в /plugins/events; браузер в ответ перезагружает только
этот модуль, страницу обновлять не надо. Сборки нет: client.js отдаётся
файлом с диска как есть.
Отсюда:
- правка
client.jsвидна в открытой вкладке через ~секунду; - правка
index.jsиcordis.patch.ymlтребует перезагрузки строки плагина или перезапускаdsh; - сломанное состояние откатывается
git checkout -- client.js, и вкладка сама подхватывает откат.
Если компонент упал в эффекте, error boundary слота снимает entry: HMR подхватит новый код, но пилюля не вернётся до перезагрузки вкладки. Это не сбой HMR — так устроен DSH; при отладке падений сразу перезагружайте страницу (подробнее — «Грабли»).
Стабильность без второй папки
Стабильность держится на git, а не на второй копии дерева:
main— всегда рабочее состояние, правки мелкими коммитами;- перед push —
npm testиnpm run check; - «релиз» — тег:
git tag -a v1.0.0 -m "…", откат —git switch --detach v1.0.0. Путь вlink:при этом не меняется, переустановка не нужна.
Дублировать дерево в том же профиле смысла нет: переключение link: — это
remove_bundle + install_bundle (переустановка пакета и перезагрузка
страницы), а расхождение копий — типичный источник потерянных правок.
Когда разделять
Если нужен постоянный стабильный интерфейс рядом с экспериментами, единица изоляции — профиль, а не папка:
git worktree add ../dsh-session-cost-dev dev
dsh --profile web-dev # в профиле web-dev тот же плагин, но link: на worktree
web продолжает работать на main, эксперименты идут в web-dev, менять
ссылку в web не нужно.
Грабли
Почти всё ниже уже ломало плагин или тесты. Симптомы похожи — «пилюля исчезла», «панель не там», «тест зелёный, а в браузере нет», — поэтому смотреть нужно не на место симптома, а на причину.
Cordis: контекст и inject
- Незадекларированный сервис бросает, а не возвращает
undefined.ctx.get('remote')возвращает контекст, ноctx.get('remote').accountбезinjectбросаетcannot get property "remote.account" without inject. ВuseEffectэту ошибку ловит error boundary слота и снимает entry (abdicate) до перезагрузки: HMR и повторный рендер пилюлю не вернут. В консоли —slot entry crashed in 'conversation.composer.dock', в DOM —[data-slot-error="conversation.composer.dock"]. Обязательный сервис объявляйте вinject, необязательный читайте черезctx.inject(['remote', 'remote.account'], (scope) => …). - Два
inject— разные вещи. Вpackage.json(dsh.client.inject) — порядок активации клиентских пакетов; вclient.js(return { inject: [...] }) — обязательные сервисы Cordis, без нихapplyне запустится. Не переносите строки из одного в другой по аналогии. - Необязательный сервис в корневом
injectгейтит весь плагин. Положите тудаremote.account— и в сборке без account RPC не будет ни пилюли, ни поповера. Для правила «пока есть — показываем» нужен дочернийctx.inject. ctx.injectможет сработать позжеapply. Callback может выполниться после первого рендера; ссылку на сервис держите в замыкании и читайте в момент запроса, а не копируйте в пропсы один раз.- Проверяйте форму сервиса, а не только факт.
getStateиgetBalanceмогут быть не функциями; чтение оборачивайте вtry/catch, иначе снова throw в эффекте.
React, слот и портал
- Хуки — до любого
return. Компонент может вернутьnullпри нулевых токенах, ноuseState/useEffectдолжны выполниться всегда; иначе React падает на смене порядка хуков. - Ошибка в
useEffectубивает слот, а не только эффект. Error boundary ловит эффекты и снимает entry. Любой внешний вызов в эффекте — вtry/catch, с fallback-состоянием. - StrictMode дважды запускает эффекты. В cleanup ставьте
alive.current = falseи увеличивайте generation, а в setup сноваalive.current = true: иначе после повторного монтирования все ответы RPC будут отброшены. Не полагайтесь на «эффект выполнился один раз». - Панель нельзя мерить до монтирования. Первый рендер портала — скрытый
(
visibility: hidden), затемgetBoundingClientRect/scrollHeightиsetPos; иначе панель мигнёт в углу. - Не зацикливайте измерение и
ResizeObserver. Сравнивайте новыйposсо старым и не вызывайтеsetStateпри равенстве:maxHeightменяет размер панели и снова будит observer. - Фокус — после позиционирования и с
preventScroll. Иначе страница прыгает к порталу в концеbody; при закрытии возвращайте фокус на триггер. - Портал — только в
document.bodyи только еслиdocumentесть.position: fixedвнутри композера обрезаетсяoverflow; в тестах и SSRdocument.bodyможет отсутствовать. - JSX здесь не компилируется. Клиентский модуль отдаётся как есть: только
React.createElement(h),require('react')иrequire('react-dom'). Новый.jsx/TS в бандле никто не соберёт. - Только токены темы. Литеральные цвета ломают тёмную тему; проверяйте
--dsw-*и--dsh-content-*.
Account RPC
- RPC не отменяется.
setTimeout— логический таймаут; поздний ответ может прийти после нового запроса. Сверяйте generation иalive, иначе старый ответ перезапишет свежий. - Ошибка не должна стирать готовые данные. При наличии
ready-кеша оставляйте его и помечайтеstale; ошибку в кеш не кладите. signed-out— не ошибка. Показывайте приглашение, не вызывайтеgetBalance, но ссылку на usage сохраняйте.- Кошельки: дубли и валюты. Суммируйте одинаковые валюты, игнорируйте неизвестные, нулевые бонусы скрывайте; CNY/USD не конвертируйте.
timezoneOffsetSecondsсо знаком минус. Это-new Date().getTimezoneOffset() * 60, а неgetTimezoneOffset(): у JavaScript знак противоположен ISO-смещению.CLIENT_VERSION— часть контракта RPC. Обновляйте вместе сpackage.json, иначе сервер получит старое значение.
Тесты
- Заглушки хуков, игнорирующие
setStateи эффекты, не проверяют клик. Такой тест зелёный, пока поповер вообще не открывается. Нужен мини-рантайм с ячейками состояния и очередью эффектов (он вtest/pricing.test.mjs) или реальный React. - VM-контекст даёт чужие прототипы.
assert.deepStrictEqualна массиве из песочницы падает с «same structure but not reference-equal»; сравнивайте поля илиjoin. node --checkпроверяет только синтаксис. Cordis-инъекции, портал, хуки и error boundary он не увидит — нужен живой прогон.- Фиксируйте часы и режьте границы в UTC. Иначе тест тарифа зависит от
часового пояса машины;
FakeDateдолжен подменять иDate.now(). - Не округляйте проценты через
toFixed(2). 99.9999% превращается в 100% при неполном кеше; нужна минимальная точность. createPortalв тестах — заглушка. Она возвращает узел, но не проверяет, чтоdocument.bodyсуществует; guard проверяйте отдельно.
Живая проверка
innerTextв headless может быть пустым. Если Playwright показывает пустойbody.innerTextпри живой вёрстке, читайтеtextContentиaria-label; не делайте вывод «ничего не отрисовалось».- После падения слота нужна перезагрузка. HMR обновляет модуль, но снятый entry не возвращает; это ожидаемое поведение DSH, а не баг HMR.
- Смотрите консоль, а не только DOM.
slot entry crashedвместе с[data-slot-error]— быстрый способ отличить падение компонента от «пилюля скрыта из-за нулевых токенов».
Ограничения
- Это оценка, а не биллинг: провайдер сообщает только суммарные бакеты, а тариф берётся на момент отрисовки, а не на момент каждого запроса. Если сессия шла и в пик, и вне пика, сумма будет приблизительной (план B исправляет это).
- Китайские государственные праздники не учитываются: в такие дни пиковое окно может фактически быть непиковым, поэтому оценка остаётся верхней границей.
- При смене модели в середине сессии вся история считается по текущей модели.
compaction, title/web-search вызовы и подагенты в сумму не входят.- Баланс в поповере — весь аккаунт, а не стоимость сессии; валюты не конвертируются, суммы показываются в валюте кошелька.
- Ссылка на usage ведёт на страницу платформы, а не на разрез этой сессии.
- Разбивки по ходам и по моделям нет — только итог по сессии.
conversation.composer.dockи ключи проекций — публичный API харнесса, но версия 0.2.1-rc.2 предрелизная: при обновлении dsh их стоит перепроверить.
Планы развития
- План A — клиентский поповер: разбивка, статистика сессии, баланс аккаунта и ссылка на usage. Реализован. Формулу не меняет, добавляет честную подпись «оценка по текущему тарифу».
- План B — Host-проекция
sessionCost: стоимость каждого запроса по его времени и модели, compaction и peak/off-peak. Убирает переоценку ≈ +33% на смешанных сессиях. - План C — необязательная сверка с фактическими суммами провайдера через приватный API (beta, высокий риск).
- Данные и справка — прайс, замеры, endpoint'ы и открытые вопросы.
Ссылки
- Прайс-лист DeepSeek: https://api-docs.deepseek.com/quick_start/pricing/
Лицензия
MIT, см. LICENSE.