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:
@@ -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/
|
||||
@@ -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.** В каталоге проекта нет репозитория. Заводить?
|
||||
Reference in New Issue
Block a user