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.
This commit is contained in:
2026-10-15 13:00:55 +03:00
commit 82f8b7baef
2 changed files with 235 additions and 0 deletions
@@ -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.** В каталоге проекта нет репозитория. Заводить?