Files
dsh-cost-plugin/docs/superpowers/specs/2026-10-15-session-cost-bar-design.md
Антонов Антон Владимирович 89cde16ddc Correct the install notes against a real install
The notes described a machine that no longer matches: the runtime paths
they hardcoded do not exist on this one, and three of their claims turned
out to be wrong when the plugin was actually installed.

What was wrong, and what is true:

1. Runtime paths. `~/.dsh/dsh-runtimes/...` is only where a downloaded
   runtime lives; this install carries it in the application. The notes
   now probe both locations and take the one that exists.

2. "You cannot install into the desktop profile from the CLI". The
   launcher refuses `desktop` only for booting and for --dump-config;
   `dsh plugin --profile desktop ...` is allowed because the Desktop
   carrier passes manageDesktopProfile. Verified: install, list, remove
   all work, and each one keeps dsh.profile.bundles in sync by itself.

3. "An application restart is required". Not on Desktop. The base bundle
   enables hmr whenever profileContext is supplied, and the application
   supplies it, so editing the profile manifest recomposes the tree live
   and the client half reaches an open page over the HMR transport. The
   application had started at 16:36 and the pill appeared in that same
   process after the 16:41 install.

Also documented, because it decides whether an edit reaches the harness:
`file:` on Windows produces hardlinks out of the pnpm store, so an
in-place write is visible immediately but an atomic replace silently
breaks the link; `link:` is a junction and always sees edits. The plugin
is now installed as `link:` for that reason.

The design note keeps its original text and carries dated corrections
plus a table of what was actually verified, so the next install does not
repeat the probing.
2026-10-28 16:51:26 +03:00

26 KiB
Raw Permalink Blame History

Плагин «Стоимость сессии» для DeepSeek Harness — дизайн

Дата: 2026-10-15 Статус: на утверждении История: первая версия считала стоимость по трём полям цены и валюте (отменено). Вторая брала usage.cost через подмену fetch и привязывала к сессии через responseId из лога (отменено: привязка через лог была самой хрупкой частью). Текущая версия берёт стоимость из обёртки fetch, а сессию — из штатного seam llm/stream.

Зачем

В нижнем ряду под полем ввода харнесс показывает статистику сессии — время, долю кэш-хитов, токены. Денег там нет, а цена кусается: длинный диалог незаметно стоит сотни рублей.

Хочется видеть реально списанную за сессию сумму там же, где видно, из чего она сложилась.

Существующий сторонний плагин dsh-bottom-info-bar это умеет, но подменяет родной ряд: регистрируется в тот же слот под тем же id и вытесняет родные ячейки. Нужно обратное — дополнить ряд своей ячейкой.

Считаются все обращения к модели, а не только основные ответы: компактификация, генерация заголовка сессии и любые другие внутренние вызовы стоят тех же денег. Это требование владельца.

Что выяснено (проверено на живом API и на коде харнесса)

1. Провайдер отдаёт стоимость в каждом ответе. Реальный ответ на ~deepseek/deepseek-v4-flash-latest:

{
  "id": "rai-1792055888-uTueLl0TuNo9SdJjjVEM",
  "model": "deepseek/deepseek-v4.1-flash",
  "provider": "BytePlus",
  "usage": {
    "total_tokens": 53, "prompt_tokens": 37, "completion_tokens": 16,
    "prompt_tokens_details": { "cached_tokens": 0 },
    "completion_tokens_details": { "reasoning_tokens": 16 },
    "cost": 0.0036374565513000003
  }
}

Заголовок x-generation-id совпадает с id тела. На потоковом ответе (stream: true, stream_options.include_usage) последний чанк тоже несёт cost, а id приходит и в каждом чанке.

2. Цены плавающие, поэтому расчёт по фиксированным полям неверен в принципе. В /models/{author}/{model}/endpoints у endpoint'ов есть variable_pricings: у deepseek-v4.1-flash днём 36 ₽/144 ₽ за млн, в выходные 18 ₽/72 ₽. Это и убило расчётный вариант.

3. Харнесс выбрасывает стоимость — дважды.

  • @earendil-works/pi-ai, parseChunkUsage (openai-completions.js): создаёт cost: {…все нули} и зовёт calculateCost(model, usage), считающий по каталогу модели. У маршрута routerai каталога нет — стоит NO_COST.
  • dsh-llm-pi-ai, mapUsage: переносит в харнесс только inputTokens/outputTokens/totalTokens/cacheReadTokens/cacheWriteTokens. cost не переносится.

Комментарий в самом харнессе: «the harness never reads pi-ai's cost metadata — replay.ts zeroes it and no consumer reports spend».

Проверено на durable-логах: 282 объекта usage в двух сессиях — ни одного с cost.

4. Штатный seam llm/stream видит сессию. Waterfall, через который идут все вызовы модели — так его используют dsh-session-checkpoint-policy и dsh-session-title:

ctx.on("llm/stream", (options, next) => {
  if (options.sessionId === void 0) return next();
  const session = ctx.sessions.get(options.sessionId);
  return session === void 0 ? next() : afterCheckpoint(ctx, session, next);
});

options несёт sessionId, provider, model и purpose (компактификация передаёт purpose: "compaction").

5. Терминальный чанк finish несёт id генерации. replayState.response.responseId, а в него pi-ai кладёт output.responseId ||= chunk.id — тот же rai-…, что в заголовке ответа.

6. Обёртка fetch безопасна и переживает обновления. OpenAI SDK берёт функцию один раз, при создании клиента:

this.fetch = options.fetch ?? Shims.getDefaultFetch();   // openai/client.js:160

pi-ai создаёт клиент на каждый запрос, а dsh-llm-pi-ai свой fetch не передаёт — значит, берётся globalThis.fetch. Подмена живёт только в памяти процесса, ни один файл не меняется, обновление DSH ничего не ломает.

Решение

Два независимых наблюдателя, результаты которых соединяются по id генерации. Ни один из них по отдельности не даёт нужного ответа: обёртка fetch видит деньги, но не видит сессию; seam видит сессию, но не видит денег.

Наблюдатель 1: обёртка fetch — деньги

Хостовая половина оборачивает globalThis.fetch. Обёртка срабатывает только для запросов к эндпоинтам провайдера (отбор по URL), чтобы не трогать ничего лишнего: проверено, что dsh-web-fetch-http тянет undici напрямую и подмену не замечает, то есть веб-инструмент не затрагивается.

Тело ответа не клонируется. Вместо этого поток разделяется на два:

const [дляНас, потребителю] = response.body.tee();
наблюдать(дляНас);                                  // разбираем SSE/JSON в фоне
const rebuilt = new Response(потребителю, { status, statusText, headers: response.headers });
Object.defineProperty(rebuilt, 'url', { value: response.url, configurable: true });
return rebuilt;

Это проверено на живом потоке routerai (.recon/spike-tee.mjs), а не выведено умозрительно:

  • потребитель получил 9041 байт за 19 чтений, первый чанк через 1 мс, последний через 1643 мс — поток течёт инкрементально, задержки нет;
  • наша ветвь увидела 30 SSE-чанков, достала id генерации и cost = 0.01082233360065;
  • content-type: text/event-stream, content-encoding и content-length пусты, поэтому пересборка заголовков на потоке ничего не портит.

Дефект, который нашёлся и уже закрыт: у пересобранного Response поле url пустое, а OpenAI SDK его читает (response.url в логах повторов, openai/client.js:432,446,458). Присваивание напрямую падает:

Cannot assign to read only property 'url' of object '#<_Response>'

Работает только Object.defineProperty — проверено в .recon/spike-url.mjs. Без этой строки SDK, скорее всего, не сломался бы (поле используется только в отладочном выводе), но полагаться на это не стоит.

Результат кладётся в реестр ключом «id генерации → стоимость».

Наблюдатель 2: seam llm/stream — сессия

Хост подписывается на llm/stream, берёт options.sessionId и проходит поток насквозь, не меняя чанки. На терминальном чанке finish читает replayState.response.responseId и получает пару «сессия ↔ id генерации».

Побочная выгода: options.purpose доступен, поэтому видно, на что ушло — основной ответ, компактификация или заголовок.

Соединение

Когда приходит вторая половина пары, стоимость прибавляется к сумме сессии. Порядок прихода не гарантирован, поэтому обе стороны буферизуются: реестр стоимостей ждёт своей сессии, пары «сессия ↔ id» ждут своей стоимости. Что пришло первым — неважно.

Сессия получает свою сумму по sessionId; сумма по каждой сессии независима.

Почему это снимает главный риск прежней версии

В прошлой редакции сессия определялась через responseId, вычитанный из durable-лога сессии. Это работало, но держалось на формате лога, который харнесс может поменять в любой момент. Теперь sessionId берётся из штатного seam — это объявленный контракт, а не побочный формат файла. Хрупкость ушла.

Чего не делаем

  • Не ходим в сеть за стоимостью: она уже в ответе. /generation?id=… проверен и работает, но не нужен.
  • Не используем MCP: get_generation/get_statement — это OAuth, сеть и привязка к аккаунту, а не к сессии.
  • Не патчим файлы харнесса.
  • Не считаем по ценам — они плавающие.
  • Не парсим логи сессий.

Отображение

Одна ячейка в родном ряду conversation.composer.dock, сразу после родных. Родные ячейки не трогаются: слот списочный, элементы различаются по id, поэтому регистрация со своим id добавляет соседа, а не занимает чужое место.

◷ 2m42s · 12 turns 34 steps    ⛁ Cache hit 87%    12.4K in / 3.2K out    ₽ 0,0036
└────────────────── родные ячейки харнесса ──────────────────┘   └── наша ──┘
  • Всегда 4 знака после запятой (решение владельца).
  • Разделители — по языку интерфейса: при русском ₽ 0,0036 и ₽ 1 234,5678, при английском ₽ 0,0036 и ₽ 1,234.5678.
  • Цифры моноширинные, чтобы ячейка не дёргалась при росте суммы.
  • Символ валюты — ₽.
  • Ячейка молчит, пока в сессии нет ни одного оплаченного вызова.

Четыре знака сохранены сознательно, хотя на крупных суммах это шум: на мелких сессиях стоимость — доли копейки, и два знака показывали бы ₽ 0,00 почти всегда.

Страница состояния

Полей ввода нет — выбор валюты и три поля цены отменены вместе с расчётным вариантом: валюта приходит от провайдера, цены брать неоткуда.

Страница нужна, чтобы можно было понять, работает ли механизм, и почему ячейка молчит. Это моё решение, владелец его не называл.

Показывает:

  • активны ли оба наблюдателя;
  • сколько вызовов учтено, на какую сумму, с разбивкой по purpose (ответы / компактификация / заголовки);
  • сколько сессий в реестре;
  • сколько вызовов не удалось привязать к сессии — это ключевой диагностический показатель, потому что такие деньги не попадут в сумму, и об этом надо знать;
  • явную надпись, если провайдер не отдаёт стоимость (выбран не routerai) — тогда ячейка молчит, и должно быть понятно почему.

Границы

Не делаем:

  • поля цены, выбор валюты, конвертацию по курсу;
  • итоги за день / 30 дней / всё время — только текущая сессия;
  • баланс, квоты, подписки, выписки;
  • стоимость по каждому вызову в интерфейсе — только сумма за сессию;
  • замену или перестановку родных ячеек.

Провал, даже если всё работает:

  • родной ряд исчез, съехал или изменился;
  • сумма расходится с реальными списаниями в личном кабинете routerai;
  • стоимость теряется при перезапуске харнесса;
  • ответы модели приходят с задержкой, обрывом или порчей;
  • учтены только основные ответы, а компактификация и заголовки — нет;
  • ячейка показывает ноль или выдуманное число, когда данных нет.

Риски

1. Успеть до первого вызова модели. Подмена должна стоять раньше, чем OpenAI SDK создаст клиент. Проверяется первым же запуском: отправить сообщение и увидеть стоимость.

2. Соединение по id. Если провайдер не вернёт id генерации (или вернёт в неизвестном месте), пара не соберётся, и деньги не попадут в сумму. Поэтому непривязанные вызовы считаются отдельно и показываются на странице состояния — молча терять их нельзя.

3. tee() и backpressure. Обе ветви надо читать. Свою ветвь мы читаем сразу и до конца; если этого не делать, буфер потребителя будет расти. Требование к реализации: свою ветвь никогда не оставлять невычитанной.

4. Конструирование нового Response. new Response(potок, {status, headers}) теряет поле url исходного ответа. Проверено и закрыто: url действительно теряется, SDK его читает, поэтому поле восстанавливается через Object.defineProperty. Прямое присваивание не работает (read-only), подробности и команда проверки — в разделе про обёртку fetch.

Технические решения

Форма поставки. Обычный пакет плагина DSH: package.json с dsh.bundle.patch (свой cordis.patch.yml) и dsh.client (platform: web + inject), сам cordis.patch.yml со вставкой строки id: session-cost, name: dsh-session-cost.

Хостовая половина (lib/index.js): обёртка fetch, слушатель llm/stream, реестр стоимостей, суммы по сессиям, персистентность, HTTP-маршрут для браузера.

Браузерная половина (lib/client.js): ячейка в conversation.composer.dock со своим id (отличным от stats) и страница состояния. Формат сборки — обёртка window.__ModuleLoader__.load({ id, factory }), как у штатных клиентских пакетов.

Доставка числа в браузер. Стоимость меняется только по завершении вызова, поэтому достаточно периодического опроса HTTP-маршрута.

Зависимостей от других плагинов нет.

Установка. Менеджер плагинов принимает локальный путь как спецификацию (inspect читает package.json по абсолютному пути; GitHub-проверка для путей пропускается). Ставить можно и со страницы плагинов в приложении, и из CLI: dsh plugin --profile desktop add <pkg> в десктопной поставке разрешён (запуск и --dump-config по этому профилю закрыты, управление пакетами — нет). Ровно эта команда и была использована.

Поправка после установки (2026-10-28). Здесь стояло «из CLI в него ставить нельзя» — неверно: launcher отбивает desktop только в режиме запуска, а plugin пропускает, если приложение передало manageDesktopProfile.

Два требования, которые надо соблюсти, иначе установка откажется:

  • пакет должен объявить совместимость с рантаймом (peerDependencies на DSH) — версия рабочего рантайма при установке была 0.2.1-rc.3, и пакет прошёл проверку без исключений в compatibility.json;
  • пакет должен объявлять бандл (dsh.bundle.patch), иначе установка сообщит not-a-bundle.

Про перезапуск. Поправка после установки (2026-10-28). Здесь стояло «потребуется перезапуск приложения» — на десктопе не потребовался. Базовый бандл включает hmr при живом profileContext (а приложение его передаёт), dsh-hmr следит за манифестом профиля, и правка dsh.profile.bundles пересобирает дерево на ходу; клиентская половина доезжает до открытой страницы по HMR-транспорту. Проверено: приложение стартовало в 16:36, пакет встал в 16:41, ячейка появилась в том же процессе. Перезапуск нужен только там, где HMR погашен (бандлы headless, sdk, acp) или профиль правили в обход пересборки; перезагрузки страницы в этом случае действительно недостаточно.

Что остаётся сборщику

  • Порядок загрузки, гарантирующий, что обёртка встанет до первого вызова модели.
  • Точный отбор запросов по URL и разбор SSE без потери чанков на границах буфера.
  • Структура реестров и где их хранить; как переживать перезапуск.
  • Разрешение гонки «стоимость и пара пришли в разном порядке»; политика для так и не собранных пар.
  • Выбор между опросом и push для доставки числа.
  • Имя пакета, id строки в патче, пространство имён локали.
  • Обработка не-потоковых ответов (адаптер dsh-llm-deepseek тоже читает глобальный fetch).

Как проверять

Готовые инструменты (лежат в .recon/, запускаются встроенным Node из C:\Users\Anton\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe; на машине, где ставился плагин, такого пути нет — рантайм вложен в приложение, см. INSTALL.md):

Файл Что делает
probe-routerai.mjs сырой ответ routerai: заголовки и тело с usage.cost
probe-stream.mjs то же для потокового ответа
probe-immediate.mjs доступность generation?id=… сразу после ответа
spike-tee.mjs спайк механизма: tee() на живом потоке — инкрементальность, извлечение cost, потеря url
spike-url.mjs восстановление url на пересобранном Response
probe-generation.mjs generation?id=… и цены endpoint'ов с variable_pricings
audit-usage.mjs что лежит в durable-логах сессий
asar.mjs, list.mjs, extract.mjs, grep2.mjs чтение app.asar: список, извлечение, поиск

Приёмка после установки:

  1. Родные ячейки на месте и выглядят как раньше.
  2. Наша ячейка стоит сразу после них.
  3. Сумма совпадает с total_cost за сессию в личном кабинете routerai.
  4. Число растёт по мере ответов и меняется при переключении сессии.
  5. Компактификация и генерация заголовков попадают в сумму — проверяется на длинной сессии, где срабатывает компактификация.
  6. Перезапуск харнесса не обнуляет накопленное.
  7. Ответы приходят целиком, без задержки и порчи.
  8. Отключение плагина возвращает ряд к исходному виду и не мешает модели.

Решённое

Установка: владелец выбрал ставить плагин в рабочий харнесс. Запись идёт в ~/.dsh/profiles/desktop; перезапуск приложения, как выяснилось при установке, не требуется — HMR на десктопе включён (см. поправку выше).

Git: владелец выбрал завести локальный репозиторий. Заведён в C:\Users\Anton\Documents\src\costs-plugin, дизайн и спайки закоммичены; позже перенесён на git.trtd.pro/antonov/dsh-cost-plugin.

Как это встало на самом деле (2026-10-28)

Записано, чтобы следующая установка не повторяла проверок заново.

Что проверяли Итог
dsh plugin --profile desktop add file:<tgz> работает; сам дописывает бандл в dsh.profile.bundles
dsh plugin --profile desktop remove … работает; сам убирает запись из списка бандлов
dsh --profile desktop --dump-config отказ: profile "desktop" is managed exclusively by the Electron application
голая pnpm add --dir … ставит пакет, но список бандлов не трогает — шаг со списком обязателен
file: на Windows pnpm делает хардлинки из store: правка на месте видна, атомарная замена файла рвёт линк
link: на Windows junction на исходник: правки видны всегда; pnpm предупреждает про peerDependencies (у нас react), для браузерной половины это неважно
перезапуск приложения не потребовался: hmr включён через profileContext, ячейка появилась в работающем процессе
тумблер в Plugins доступен: host-строка plugin-manager включается тем же profileContext
plugin-manager/tool-plugin-manager в dsh-base plugin-manager — !!js "!ctx.get('profileContext')", tool-plugin-manager — всегда disabled: true

Открытым осталось расхождение валюты: хост кладёт в ответ currency: 'RUB', а клиент печатает \u20BD жёстко и currency не читает. Пока провайдер один, это незаметно; при втором — цифра будет подписана неверно.