commit 82f8b7baefb7b7ce628d251aecca54c0052ccc7c Author: Anton Antonov Date: Thu Oct 15 13:00:55 2026 +0300 Add design for session cost pill in the composer dock Records what was verified against the live routerai API and the harness code: the provider reports usage.cost on every response, the harness discards it in mapUsage, and the cost can still be recovered by wrapping globalThis.fetch while session attribution rides the llm/stream seam. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d0e6bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Recon scratch: throwaway probes, extracted harness files, asar tooling. +# Kept out of the repo: these are working notes, not deliverables. +.recon/ + +# Visual companion session files (brainstorm mockups). +.superpowers/ + +node_modules/ diff --git a/docs/superpowers/specs/2026-10-15-session-cost-bar-design.md b/docs/superpowers/specs/2026-10-15-session-cost-bar-design.md new file mode 100644 index 0000000..d7fad79 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-session-cost-bar-design.md @@ -0,0 +1,227 @@ +# Плагин «Стоимость сессии» для 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 в фоне +return new Response(потребителю, { status, statusText, headers: response.headers }); +``` + +`tee()` — штатный примитив разделения потока: обе ветви текут инкрементально, ничего не буферизуется целиком, харнесс получает байты без задержки. Из своей ветви обёртка достаёт `usage.cost` и `id`; для не-потоковых ответов разбирает JSON так же. + +Результат кладётся в реестр **ключом «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` исходного ответа. Надо проверить, не полагается ли на него SDK; если полагается — переносить вручную. + +## Технические решения + +**Форма поставки.** Обычный пакет плагина 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-маршрута. + +**Зависимостей от других плагинов нет.** + +## Что остаётся сборщику + +- Порядок загрузки, гарантирующий, что обёртка встанет до первого вызова модели. +- Точный отбор запросов по URL и разбор SSE без потери чанков на границах буфера. +- Сохранение `url` (или подтверждение, что оно не нужно) при конструировании нового `Response`. +- Структура реестров и где их хранить; как переживать перезапуск. +- Разрешение гонки «стоимость и пара пришли в разном порядке»; политика для так и не собранных пар. +- Выбор между опросом и 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=…` сразу после ответа | +| `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.** В каталоге проекта нет репозитория. Заводить?