Files
dsh-cost-plugin/docs/superpowers/specs/2026-10-15-session-cost-bar-design.md
T
antonov 82f8b7baef 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.
2026-10-15 13:00:55 +03:00

228 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Плагин «Стоимость сессии» для 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.** В каталоге проекта нет репозитория. Заводить?