Session cost estimate (USD) next to the token counter — DeepSeek Harness plugin
  • JavaScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
agent-dsh ff9eb3f9c3 docs: add a pitfalls guide for client plugin work
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.
2026-10-22 16:15:40 +00:00
docs/plans docs: add a pitfalls guide for client plugin work 2026-10-22 16:15:40 +00:00
locale
test fix: keep the cost pill mounted when the popover opens 2026-10-22 15:49:40 +00:00
.gitignore
client.js fix: keep the cost pill mounted when the popover opens 2026-10-22 15:49:40 +00:00
cordis.patch.yml
icon.svg
index.js
LICENSE
package.json
README.md docs: add a pitfalls guide for client plugin work 2026-10-22 16:15:40 +00:00

dsh-session-cost

Плагин для DeepSeek Harness (dsh): показывает под композером оценку стоимости текущей сессии в долларах США — рядом со счётчиком токенов. По клику пилюля открывает поповер с разбивкой, статистикой сессии и балансом аккаунта.

1.2k токенов   ≈$0.75   ▮▮▮▮ 12%

Что делает

  • Добавляет пилюлю ≈$0.75 в док композера, сразу после счётчика токенов.
  • По клику, Enter или Space открывает поповер с подробностями; нативного title-тултипа больше нет.
  • Показывает разбивку по всей сессии: вход без кеша, вход из кеша, запись в кеш, выход и итог.
  • Показывает, сколько сэкономил кеш и какая доля входа пришла из кеша.
  • Показывает статистику сессии: ходы, шаги, время LLM и инструментов, TTFT, скорость декодирования.
  • Показывает баланс аккаунта — весь аккаунт, не сессию: пополненный и бонусный, со временем данных, кнопкой обновления и ссылкой на usage.
  • Явно подписывает сумму как оценку по тарифу, действующему сейчас.
  • Скрыта, пока в сессии нет ни одного токена.
  • Русский и английский интерфейс, тема (светлая/тёмная) наследуется от харнесса.

Требования

  • dsh 0.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-песочнице

Точки расширения:

  1. Loader — патч вставляет ряд session-cost, к которому цепляется бандл.
  2. dsh.client — харнесс отдаёт client.js странице как клиентский модуль; inject перечисляет обязательные сервисы (slots, locale), без них модуль не активируется.
  3. Слот — ctx.slots.inject('conversation.composer.dock', …) и ctx.slots.register({ id: 'session-cost', order: 2, locale: 'session-cost' }). Компонент получает стандартные пропсы слота, используются useProjection и t.
  4. Локаль — ctx.locale.register('session-cost', 'en'|'ru', …); опция locale в регистрации передаёт компоненту функцию t.
  5. Стили — <style> внутри ctx.effect, только токены --dsw-* и --dsh-content-*, классы с префиксом dsh-sc-; при выгрузке удаляются.
  6. 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; в тестах и SSR document.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'ы и открытые вопросы.

Ссылки

Лицензия

MIT, см. LICENSE.