# Плагин «Стоимость сессии» для 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 в него ставить нельзя. Ставится долгой командой референс-плагина, форма та же. Два требования, которые надо соблюсти, иначе установка откажется: - пакет должен объявить совместимость с рантаймом (`peerDependencies` на DSH) — версия рабочего рантайма **0.2.0-rc.2**; - пакет должен объявлять бандл (`dsh.bundle.patch`), иначе установка сообщит `not-a-bundle`. **Про перезапуск.** Плагины собираются при старте хоста. Если HMR в профиле не включён, уже запущенная композиция остаётся до перезапуска — перезагрузки страницы недостаточно. В `cordis.yml` рабочего профиля HMR не упомянут, поэтому исходить надо из того, что **потребуется перезапуск приложения**, а он прервёт текущий разговор. Порядок такой: сначала собрать и проверить пакет, перезапуск — последним шагом. ## Что остаётся сборщику - Порядок загрузки, гарантирующий, что обёртка встанет до первого вызова модели. - Точный отбор запросов по URL и разбор SSE без потери чанков на границах буфера. - Структура реестров и где их хранить; как переживать перезапуск. - Разрешение гонки «стоимость и пара пришли в разном порядке»; политика для так и не собранных пар. - Выбор между опросом и push для доставки числа. - Имя пакета, id строки в патче, пространство имён локали. - Обработка не-потоковых ответов (адаптер `dsh-llm-deepseek` тоже читает глобальный `fetch`). ## Как проверять **Готовые инструменты** (лежат в `.recon/`, запускаются встроенным Node из `C:\Users\Anton\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe`): | Файл | Что делает | |---|---| | `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`) и перезапуск приложения. **Git:** владелец выбрал завести локальный репозиторий. Заведён в `C:\Users\Anton\Documents\src\costs-plugin`, дизайн и спайки закоммичены.