Files
dsh-cost-plugin/docs/superpowers/specs/2026-10-15-session-cost-bar-design.md
T
antonov c9d2314bb2 Verify the tee interception on a live stream
Spike proves response.body.tee() splits a real routerai SSE stream: the
consumer sees chunks progressively (first at 1ms of 1643ms) and our branch
still recovers the generation id and usage.cost. It also found a real
defect -- a rebuilt Response loses url, which the OpenAI SDK reads -- and
that Object.defineProperty is the only way to restore it.
2026-10-15 13:02:32 +03:00

21 KiB
Raw Blame History

Плагин «Стоимость сессии» для 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:

{
  "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:

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 берёт функцию один раз, при создании клиента:

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 напрямую и подмену не замечает, то есть веб-инструмент не затрагивается.

Тело ответа не клонируется. Вместо этого поток разделяется на два:

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-маршрута.

Зависимостей от других плагинов нет.

Что остаётся сборщику

  • Порядок загрузки, гарантирующий, что обёртка встанет до первого вызова модели.
  • Точный отбор запросов по URL и разбор SSE без потери чанков на границах буфера.
  • Структура реестров и где их хранить; как переживать перезапуск.
  • Разрешение гонки «стоимость и пара пришли в разном порядке»; политика для так и не собранных пар.
  • Выбор между опросом и 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=… сразу после ответа
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) и перезапуск харнесса — перезапуск прервёт текущий разговор.

Открытый вопрос

Git. В каталоге проекта нет репозитория. Заводить?