# Плагин «Стоимость сессии» для 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`: ```json { "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`: ```js 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 берёт функцию один раз, при создании клиента: ```js 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` напрямую и подмену не замечает, то есть веб-инструмент не затрагивается. Тело ответа **не клонируется**. Вместо этого поток разделяется на два: ```js 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 ` в десктопной поставке разрешён (запуск и `--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:` | работает; сам дописывает бандл в `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` не читает. Пока провайдер один, это незаметно; при втором — цифра будет подписана неверно.