WEB board

всеканоны и докиворкеры↗ iOS↗ Легаси
WEB-666 · Дефект · billing · web

P2 [биллинг]: расшифровка речи всегда списывает потолок резерва — провайдер не отдаёт usage (SETTLED_MAX / usage_missing)

Закрыт P2 ведёт: —
Суть
## Находка платной пробы посадки l115o, 15.09 01:28Z

При проверке денежного контура после посадки обнаружено: **расшифровка речи всегда списывает по потолку резерва, а не по факту.**

За полчаса четыре резервирования `media_transcribe` (`openai` / `gpt-4o-mini-transcribe`):

| зарезервировано, микро | списано, микро | фактическое | причина |
|---:|---:|---:|---|
| 1837 | 1837 | пусто | `usage_missing` |
| 2071 | 2071 | пусто | `usage_missing` |
| 4704 | 4704 | пусто | `usage_missing` |
| 2074 | 2074 | пусто | `usage_missing` |

Состояние `SETTLED_MAX`, `actualMicros` пустой, `releaseReason = usage_missing`.

**Для сравнения**, чат в той же пробе отработал правильно: зарезервировано 40724, списано по факту **1017** — то есть оценка была в 40 раз щедрее факта, и разница вернулась. У расшифровки такого возврата нет.

### Почему это важно

Провайдер расшифровки не возвращает данные о потреблении, поэтому контур делает консервативную вещь — списывает потолок. Это правильное поведение при неизвестности (лучше пересчитать себе в минус, чем недосчитать), но оно означает, что **мы платим себе по оценке, а не по факту, и не знаем, насколько оценка завышена**. Судя по чату, оценки у нас завышены в десятки раз.

### Что надо выяснить

1. Правда ли провайдер не отдаёт usage для этого метода, или мы его не читаем? Проверить в коде путь разбора ответа расшифровки и сам ответ провайдера.
2. Если usage действительно не приходит — можно ли вывести факт из длительности аудио? В `TerminalSpendEvent` есть поле `audioMillis`, а прайс расшифровки обычно за минуту звука. Тогда оценка заменяется расчётом.
3. Насколько велика переплата: собрать по `SETTLED_MAX` с `releaseReason=usage_missing` за месяц сумму `settledMicros` и сравнить с расчётом по `audioMillis`.

### Как воспроизвести

```sql
SELECT state,provider,model,feature,"reservedMicros","settledMicros","actualMicros","releaseReason"
FROM "SpendReservation"
WHERE state='SETTLED_MAX' AND "createdAt" > now() - interval '30 minutes';
```

### Контекст

Найдено платной пробой посадки l115o (`68e25d8df5ae8263c5ac5466353631f57a17cfcc`, артефакт `cd2773f6…`). Сама проба прошла: резервирование `resv_047ece9d…` → `SETTLED_ACTUAL` 1017 микро, конечное событие `tse_afa11729…` → `APPLIED`, ровно одно на операцию, зависших резервирований 0.

Связано: WEB-632 / CAP-05 (заглушка провайдера через настоящий платный путь), WEB-439 (единая дверь к платным провайдерам).
Лента
2026-09-15T02:13:19.104Z · coordinator
[15.09 02:13Z координатор] ## 15.09 04:20Z — волна 3959 сдала `VERDICT=GO`. Виноваты МЫ, не провайдер. Плюс дефект шире, чем расшифровка речи.

### Диагноз (доказан прогоном волны)

OpenAI **отчитывается о потреблении в каждом ответе расшифровки**, просто в двух форматах: для новых моделей — в токенах, для whisper — в секундах звука. Наш код искал секунды **ровно в одном месте** — в поле, которое появляется только если специально запросить подробный ответ. Боевой путь подробного ответа не просит. Секунд нет → калькулятор расшифровки умеет считать только по секундам → честно говорит «не знаю» → по правилам это «списать потолок».

**Токены при этом в базу приезжают.** Это и есть отпечаток дефекта.

### Проверено мной на проде — отпечаток подтверждается

За текущий месяц, все 289 строк `media_transcribe` / `gpt-4o-mini-transcribe`:

```
строк ......................... 289
с inputTokens ................. 289   (всего 79 217 токенов)
с outputTokens ................ 289   (всего 29 228)
с audioMillis ..................  0
с actualMicros .................  0
списано по потолку ....... 777 655 микро = $0.78
```

Провайдер отдал потребление **в каждой из 289 строк**. Мы его не прочитали ни разу.

### 🔴 Дефект шире, чем расшифровка. Волна этого не охватила.

Тот же `usage_missing` за месяц бьёт и по **чату**:

| фича | модель | строк | микро | долл |
|---|---|---:|---:|---:|
| factcheck_judge | gpt-5.4-mini | 9 | 374 446 | 0.37 |
| lens | gpt-5.4-mini | 3 | 129 563 | 0.13 |
| factcheck_claims | gpt-5.4-mini | 1 | 52 533 | 0.05 |
| meeting_room_summary | gpt-5.4-mini | 2 | 7 102 | 0.01 |

У всех пятнадцати строк `inputTokens` и `outputTokens` **заполнены** (18 790 и 5 810), а списано по потолку. То есть это **не особенность калькулятора расшифровки**: тот же класс живёт на обычном чат-пути минимум у четырёх фич. Объяснение волны («калькулятор умеет только секунды») этих строк не объясняет — их надо разобрать отдельно.

**Итого `usage_missing` за месяц: 304 строки, 1 341 299 микро = $1.34.** Деньги маленькие, но механизм означает, что учёт неверен, а замеры ёмкости, опирающиеся на цифры расхода, наследуют ошибку.

### Что можно починить сейчас, а что требует решения владельца

- **whisper-класс — уже сделано в ветке волны**: считается по сообщённым секундам.
- **боевая `gpt-4o-mini-transcribe` — можно, но нужна цена**: она тарифицируется в токенах, а **токен-ставок этой модели в нашем прайсбуке нет**. Образец такого перехода в коде уже есть — так устроена озвучка (`TOKEN_BILLED_TTS`). Волна правильно **не стала выдумывать цифры**: нужны ставки из прайс-листа OpenAI, это решение владельца.
- **пятнадцать чат-строк** — отдельный разбор, причина пока не названа.
- **озвучка подкастов** — волна называет тот же класс: ответ OpenAI на синтез речи это двоичный аудиофайл, отчёта о потреблении в нём нет **вообще**, факт не считается никогда. Отдельный тикет.

### Про размер переплаты — меньше, чем кажется

Потолок расшифровки строится из размера файла по нижней границе битрейта 16 кбит/с — ровно столько весит голосовое в Telegram, поэтому на наблюдённых строках потолок почти совпал с фактом. Реальные потери в двух хвостах: загрузки в плотных форматах (mp3, WAV — потолок завышен в разы) и вызовы, до которых не доехал размер файла — там резервируется и списывается **30 минут звука, около девяти центов за вызов**.

Средняя восстановленная из резерва длительность по 289 строкам — **53.8 секунды** (подразумеваемый размер 107 КБ), суммарно 15 553 секунды. Ни одной строки с 30-минутным потолком в этом месяце не видно.

Сдача: отчёт и доказательства на M4 (`~/waves/3959USAGE-REPORT.md`, `3959USAGE-evidence/` — свой красный-зелёный, typecheck, выписка из типов SDK OpenAI 6.10.0, готовый SQL). Бандл волна ещё дописывает.
2026-09-19T18:07:18.528Z · coordinator
[19.09 18:07Z координатор] ✅ Сдача 4307 (Neo) = `VERDICT=GO`. Ветка `wave/4307-transcribe-actual` = `feff671f0` поверх `cc278e181`, в зеркале A1 как `refs/waves/4307/…`.

**Причина найдена точно, и она не та, что предполагал тикет.** Провайдер величину **отдаёт** — на обычном пути `json` она приходит в `Transcription.usage`, это объединение с разделителем `type`: `{type:'duration', seconds}` либо `{type:'tokens', input_tokens, …}`. А шлюз читал **только** верхнеуровневый `duration`, который существует исключительно у типов `verbose_json`. `usage.seconds` не читался никогда — поэтому каждая расшифровка на обычном пути доходила до списания без величины и закрывалась по потолку.

Починка в `extractOpenAIUsage`: `usage.seconds` переносится в `audioSeconds` со строгой проверкой. Ответ с длительностью → `SETTLED_ACTUAL`; ответ без величины → по-прежнему `SETTLED_MAX` / `usage_missing`. Ничего не выводится из другой единицы.

Доказано на настоящем PostgreSQL 16 фикстурами вместо провайдера: **8/8 зелёных, та же связка 3 красных на непочиненном дереве**, и красный контроль краснеет, если подсадить запрещённую подмену.

Попутно волна исправила моё же предложение из брифа: перейти на `verbose_json` **нельзя** — SDK прямо говорит, что для `gpt-4o-transcribe` и `gpt-4o-mini-transcribe` единственный поддерживаемый формат `json`.

## Честный отказ волны — и он важнее самой починки

Те четыре боевые пробы шли на `gpt-4o-mini-transcribe`, а он отдаёт **токенную** разновидность, не длительность. Волна доказала это, а не предположила: непустой `providerReceiptHash` на тех терминалах возможен только если `extractOpenAIUsage` вернул чек, а на том пути это требует токенов. Фикстура без `usage` даёт пустой хеш, фикстура с токенами воспроизводит непустой — боевые строки совпали со второй.

Списать их по факту волна отказалась, потому что **в дереве нет цены, которая честно превращает эти токены в деньги**:

1. 🔴 В `MODEL_PRICING_MATRIX` **нет строки** для `gpt-4o-mini-transcribe`. `resolveExactTokenPrice` возвращает `null`. Модель вообще допускается к платному пути только потому, что `resolveModelPricing` сохраняет legacy-**префиксный** запасной путь, молча наследующий **чатовую** ставку `gpt-4o-mini` ($0.15 / $0.60). Это ставка другого продукта.
2. Заверенный снимок `spend-estimator-registry-b2att-20260818.json` рядом с посекундной строкой называет `inputUsdPer1mTokens: 1.25`, но не даёт перевода токенов в секунды, и числа сами по себе не сходятся.
3. `input_token_details` делит вход на `audio_tokens` и `text_tokens` с **разными** ставками, а `TokenPrice` в продукте — одна входная ставка, то есть структурно не может оценить такой чек.

Это отдельный дефект ценообразования, и он шире расшифровки: любая модель, попавшая под префиксный запасной путь, тарифицируется по ставке чужого продукта. Завожу отдельной карточкой.

Статус двигаю `todo` → `in_progress`: починка есть, но не посажена, и токенная половина сознательно оставлена открытой.
2026-09-22T18:27:24.017Z · triage-neo
РЕШЕНИЕ=in_progress
ОСНОВАНИЕ=Комментарий 5659 от 2026-09-19T18:07Z: волна 4307 дала GO, ветка wave/4307-transcribe-actual на коммите feff671f0; исправление доказано, но не посажено, токенная часть открыта.
ЧТО НУЖНО=Посадить feff671f0, получить утверждённую цену токенов gpt-4o-mini-transcribe и проверить остальные usage_missing-пути; triage-neo 4613
2026-09-23T12:16:47.360Z · coordinator
[23.09 12:16Z координатор] ## 4709 (M1, Luna) — в работе: проверка, отдаёт ли провайдер usage для расшифровки; если нет — settlement по длительности аудио (`audioMillis`) по посекундной/поминутной ставке (строки WEB-674) → `SETTLED_ACTUAL`/`usage_from_duration`, fail-closed при отсутствии длительности; SELECT-only скрипт оценки переплаты за N дней. Поверх патчей WEB-674.
2026-09-23T12:35:34.339Z · coordinator
[23.09 12:35Z координатор] ## 4709 (M1, GO, 5/5) — принято: settlement расшифровки при отсутствии usage от провайдера считается по длительности аудио (`audioMillis`) по ставкам строк WEB-674 → `SETTLED_ACTUAL` (`usage_from_duration`), при отсутствии и длительности — прежний `SETTLED_MAX` (fail-closed); репродукторы + SELECT-only отчёт переплаты. Ветка A2 `l115s-web666-transcribe-settle` = 7 коммитов WEB-674 + 2. Независимая приёмка (деньги) — 4714 вместе с WEB-670. Статус ← review.
2026-09-23T12:50:14.505Z · coordinator
[23.09 12:50Z координатор] ## 4714 (M1, независимая приёмка) — **WEB-666 = NO-GO, 2 critical**: (4) клиентское поле `duration` из формы попадает в доверенный `audioMillis` settlement (`api/transcribe/route.ts:53-89`) — длительность должна браться сервером из аудио/ответа провайдера; (5) огромная положительная длительность принимается, переполнение поднимает исключение вместо fail-closed (`transcriptionSettlement.ts:19-24,67-75`). Арифметика, приоритет usage провайдера, идемпотентность, SELECT-only отчёт — PASS. Починка — 4720 (M1). Статус ← in_progress.
2026-09-23T13:06:27.894Z · coordinator
[23.09 13:06Z координатор] ## 4720 (M1, Luna) — GO: длительность расшифровки берётся ТОЛЬКО из серверных фактов провайдера (`response.duration` / `usage.duration|audio_seconds`); клиентская `durationSeconds` из формы в биллинг больше не попадает; потолок 4 ч (`MAX_TRANSCRIPTION_DURATION_MS=14400000`), невалидные/переполненные значения → `SETTLED_MAX` с `releaseReason=duration_invalid` (fail-closed, без падения на BigInt). Тесты биллинга 46/46, изолированный typecheck 0. Патчи 0005/0006 наложены на A2 `l115s-web666-transcribe-settle` (tip 3ac6d97d). Статус → в ревью; ждёт повторного adversarial-ревью и попадёт в следующее сводное дерево (all6).
2026-09-23T13:37:23.922Z · coordinator
[23.09 13:37Z координатор] ## 4723 (M1, независимое повторное ревью) — **GO** по WEB-666: клиентская длительность до биллинга не доходит, без фактов провайдера — fail-closed, границы/единицы верны, `releaseReason` доходит до terminal `usageJson`. Набор готов в сводное дерево all6; статус остаётся «ревью» до посадки.
2026-09-23T20:04:20.254Z · coordinator
[23.09 20:04Z координатор] ## СОСТОЯНИЕ НА 23.09 20:2xZ (для нулевого агента)
- Что это: расшифровка речи всегда списывала потолок резерва; теперь серверная длительность, потолок 4 ч, fail-closed, settle по факту.
- Где код: A2 ветка `l115s-web666-transcribe-settle` = 3ac6d97d8 (11 коммитов над 1a5822a8); р2 (4720 GO, 13:06Z) вошёл в all8. Статус review = принят, ждёт посадки (all8 → стенд → l115s).
2026-09-25T08:42:15.161Z · coordinator
[25.09 08:42Z координатор] # Ready board comment — WEB-666

2026-09-25 08:13Z — POST-QA all9 (`879094713e`, `/Users/poolpooly/wt-l115s-c`): **CLOSE**.

Что сделано: транскрипция больше не обязана списывать потолок. Если провайдер прислал usage, сохраняем его; если нет — берём только доверенную server duration; клиентскую duration не принимаем, невалидную/длиннее 4 часов закрываем fail-closed. Механика: `src/lib/billing/transcriptionSettlement.ts:65-96`, `src/lib/media/transcribe.ts:93`.

Коммиты all9: `2110ed23f`, `3195cdea9`, тесты `4fd6a28fb`, `55970dcf2`. Проверка: `pnpm vitest run src/lib/billing/__tests__/web666TranscriptionSettlement.test.ts scripts/__tests__/transcribe-overpay-report.test.ts src/lib/billing/__tests__/web4714Adversarial.test.ts` (в этом checkout текущий config отфильтровал пути: RC=1, 0 collected; см. `/Users/poolpooly/waves/4982POSTQAALL9BILLINGPRODUCTTICKETS-evidence/tests.md`).

Открытие/траблшут: старый платный пробег `l115o` показывал `usage_missing → SETTLED_MAX`; теперь это защищённый fallback, но размер исторической переплаты нельзя доказать кодом. Осталось владельцу/координатору: выполнить 30-дневный aggregate по `SETTLED_MAX` в разрешённом окружении и решить по экономике. Начало для нового агента: прочитать WEB-449, затем этот файл, resolver и три target-теста; бой/Telegram/секреты не трогать.


Проверка координатора (09:5xZ, all9 879094713e, M1): перезапуск тестов 4982 правильным раннером (node --import tsx --test, Node 22): тесты тикета на all9 зелёные (4982 гонял vitest, который их не собирает). CLOSE подтверждён.
Воркер
не проверен web666-4307 coordinator движение в панели: неизвестно
Подключиться и смотреть/перехватить руками. Колесо мыши листает; клавишами — Ctrl-b затем [, выход из прокрутки q. Отсоединиться — Ctrl-b затем d:
Прочитать историю панели без подключения — листается и ищется (/ поиск, q выход), воркеру не помешает:
Обновлён
2026-09-25T08:42:39.927Z