Files
dsh-cost-plugin/docs/superpowers/specs/2026-10-15-session-cost-bar-design.md
Антонов Антон Владимирович 89cde16ddc Correct the install notes against a real install
The notes described a machine that no longer matches: the runtime paths
they hardcoded do not exist on this one, and three of their claims turned
out to be wrong when the plugin was actually installed.

What was wrong, and what is true:

1. Runtime paths. `~/.dsh/dsh-runtimes/...` is only where a downloaded
   runtime lives; this install carries it in the application. The notes
   now probe both locations and take the one that exists.

2. "You cannot install into the desktop profile from the CLI". The
   launcher refuses `desktop` only for booting and for --dump-config;
   `dsh plugin --profile desktop ...` is allowed because the Desktop
   carrier passes manageDesktopProfile. Verified: install, list, remove
   all work, and each one keeps dsh.profile.bundles in sync by itself.

3. "An application restart is required". Not on Desktop. The base bundle
   enables hmr whenever profileContext is supplied, and the application
   supplies it, so editing the profile manifest recomposes the tree live
   and the client half reaches an open page over the HMR transport. The
   application had started at 16:36 and the pill appeared in that same
   process after the 16:41 install.

Also documented, because it decides whether an edit reaches the harness:
`file:` on Windows produces hardlinks out of the pnpm store, so an
in-place write is visible immediately but an atomic replace silently
breaks the link; `link:` is a junction and always sees edits. The plugin
is now installed as `link:` for that reason.

The design note keeps its original text and carries dated corrections
plus a table of what was actually verified, so the next install does not
repeat the probing.
2026-10-28 16:51:26 +03:00

270 lines
26 KiB
Markdown
Raw Permalink 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 в фоне
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 <pkg>` в десктопной поставке разрешён (запуск и `--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:<tgz>` | работает; сам дописывает бандл в `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` не читает. Пока провайдер один, это незаметно; при втором — цифра будет подписана неверно.