# Разработчикам

Раздел справки Строфи целиком, статьи в порядке чтения. Источник: https://strophe.app/docs/product/developers

# REST API Строфи

> Программный доступ к генерации Строфи по HTTP: кому он открыт, из чего состоит, чем отличается от подключения агента по MCP и с чего начать интеграцию.

Источник: https://strophe.app/docs/product/developers/overview
Обновлено: 2026-08-26

REST API даёт вашей программе тот же каталог моделей и тот же счёт кредитов, что и редактор. Запрос уходит по HTTP, генерация встаёт в общую очередь, результат забирается ссылкой — холст при этом не участвует.

## Кому открыт

Доступ идёт вместе с командным планом, и распоряжается им владелец команды или её администратор: ключ работает от лица всей команды и тратит общий пул, поэтому выпускать его и подписываться на события вправе тот, кто за этот пул отвечает. Рядовому участнику остаётся подключение агента по MCP — оно открыто любой действующей подписке и тратит личный баланс. Как устроены команды и роли в них, описано в статье [Команда](https://strophe.app/docs/product/account/teams).

## Чем это отличается от подключения агента

Подключение по MCP рассчитано на чужого AI-агента: он читает описания моделей словами, сам выбирает подходящую и запускает генерацию по вашей просьбе в переписке. REST рассчитан на программу: тот же каталог отдаётся машинным JSON, выбор модели делаете вы, а ответ построен на кодах, а не на объяснениях.

Оба пути ведут к одному каталогу и к одному счёту. Генерации, запущенные через REST, видны в истории вместе с теми, что запустил агент.

## Из чего состоит

Базовый адрес — `https://strophe.app/api/v1`. Все запросы идут под ключом в заголовке `Authorization`, ответы всегда в JSON.

Ручек шесть групп:

- каталог моделей — `GET /families` и `GET /families/{id}`;
- генерации — запуск, состояние, история, отмена, предварительная оценка стоимости;
- файлы — `POST /files` превращает ваш файл в идентификатор входа;
- счёт — `GET /account` отвечает, сколько можно потратить прямо сейчас;
- вебхуки — уведомление о завершении генерации приходит на ваш адрес;
- машинное описание — `GET /openapi.json` отдаёт спеку OpenAPI, тоже под ключом.

## Порядок работы

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

1. [Ключ и права](https://strophe.app/docs/product/developers/authentication) — выпуск ключа, права, потолки траты.
2. [Запуск генерации](https://strophe.app/docs/product/developers/generation) — каталог, оценка, запуск, ожидание, результат.
3. [Вебхуки](https://strophe.app/docs/product/developers/webhooks) — исход приходит сам, без опроса.
4. [Ошибки и лимиты](https://strophe.app/docs/product/developers/errors-and-limits) — коды отказа, частота запросов, потолки размеров.

## Версия и совместимость

Версия стоит в пути: `/api/v1`. Внутри версии контракт не ломается — поля добавляются, но не исчезают и не меняют смысл, а набор кодов ошибок пополняется только новыми значениями. Ломающая правка означала бы `/api/v2`, и первая версия продолжила бы работать рядом.

Отсюда практическое правило для клиента: незнакомое поле в ответе игнорируется, незнакомый код ошибки обрабатывается как отказ своего класса — по числовому статусу HTTP и признаку `retryable`.

---

# Ключ и права

> Выпуск ключа для REST API Строфи, набор прав, потолки траты на ключ, передача ключа в запросе, отзыв и проверка состояния счёта.

Источник: https://strophe.app/docs/product/developers/authentication
Обновлено: 2026-08-02

Каждый запрос к API предъявляет ключ. Ключ выпускается в настройках, живёт до отзыва и несёт при себе набор прав и потолки траты — то есть определяет не только «можно ли», но и «на сколько».

## Выпуск ключа

Ключи лежат в настройках, раздел «Интеграции». Как открыть настройки, описано в статье [Настройки](https://strophe.app/docs/product/account/settings).

При выпуске задаются три вещи:

- **Название** — по нему ключ отличают в списке. В запросах не участвует.
- **Права** — что этим ключом разрешено делать.
- **Потолки траты** — сколько кредитов ключ вправе потратить.

Полное значение ключа показывается один раз, сразу после выпуска. Дальше в списке видна только начальная часть — по ней ключ узнают, но восстановить из неё ничего нельзя. Потеряли — выпускайте новый и отзывайте старый.

## Права

Право выдаётся под задачу, а не «на всё»: ключ витрины, показывающей баланс, не должен уметь тратить деньги.

- `catalog:read` — читать каталог моделей и машинную спеку.
- `generations:read` — смотреть состояние генерации, историю и предварительную оценку стоимости.
- `generations:write` — запускать и отменять генерации.
- `files:write` — загружать входные файлы.
- `account:read` — читать состояние счёта.
- `mcp` — подключение агента по протоколу MCP. К REST отношения не имеет и его ручек не открывает.

Запрос с ключом, у которого нужного права нет, получает отказ `insufficient_scope` со статусом 403. Права ключа видны в ответе `GET /api/v1/account`.

## Потолки траты

У ключа два независимых потолка, оба необязательные.

**Суточный** ограничивает трату за календарные сутки по UTC. Счётчик считает и уже списанное, и то, что удерживают идущие генерации; обнуляется он сам.

**Разовый** ограничивает стоимость одной генерации. Он отсекает не расход за день, а ошибку в параметрах: запрос дороже этого числа не начинается вовсе.

Оба потолка меняются в настройках ключа. Запрос, упёршийся в потолок, получает отказ `spend_limit_exceeded`; поле `details.limitKind` называет, какой именно потолок задет, а у суточного там же лежит момент обнуления.

Потолки — не то же самое, что баланс. Их назначение — ограничить ущерб от ключа, отданного чужой программе: без них ошибка в её коде стоит ровно столько, сколько лежит на счету.

## Передача ключа

Ключ передаётся заголовком `Authorization` в схеме Bearer:

```bash
curl https://strophe.app/api/v1/families \
  -H "Authorization: Bearer <ваш ключ>"
```

Ключ — это пароль к вашему счёту. Он не должен попадать в клиентский код, в адресную строку, в логи и в репозиторий: любой, кто его прочитал, тратит ваши кредиты.

Запрос без ключа получает 401 `unauthorized`, с отозванным или несуществующим — 401 `invalid_token`. В обоих случаях в ответе есть заголовок `WWW-Authenticate` — по нему клиент понимает, что чинить нужно именно учётные данные, а не запрос.

## Состояние счёта

Сколько можно потратить прямо сейчас, отвечает `GET /api/v1/account` под правом `account:read`:

```bash
curl https://strophe.app/api/v1/account \
  -H "Authorization: Bearer <ваш ключ>"
```

В ответе — доступные кредиты, состояние команды и потолки предъявленного ключа вместе с потраченным за текущие сутки. Именно предъявленного: у каждого ключа потолки свои, и программа видит те, под которыми работает сама.

Отдельного «баланса» в ответе нет намеренно. Полностью занятый идущими генерациями счёт выглядел бы полным, и программа запустила бы работу, которой не на что выполниться.

## Отзыв

Ключ отзывается в том же разделе настроек. Отзыв мгновенный: следующий запрос с этим ключом получит 401.

Отзыв не отменяет уже запущенные генерации — они дойдут до конца и спишут кредиты. Чтобы остановить работу, генерации отменяют отдельно, до отзыва ключа.

Ссылки на результаты, выданные этим ключом, перестают работать вместе с ним. Это относится и к ссылкам, полученным раньше: они живут ровно столько, сколько живёт ключ, которым были выданы.

---

# Запуск генерации

> Полный путь через REST API Строфи: каталог моделей, загрузка входных файлов, оценка стоимости, запуск с защитой от повтора, ожидание исхода, результат и отмена.

Источник: https://strophe.app/docs/product/developers/generation
Обновлено: 2026-08-26

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

## Каталог моделей

Список семейств отдаёт `GET /api/v1/families` под правом `catalog:read`. Фильтры — `task` (что делает модель) и `outputType` (что получается на выходе):

```bash
curl "https://strophe.app/api/v1/families?outputType=image" \
  -H "Authorization: Bearer <ваш ключ>"
```

Подробности одного семейства — `GET /api/v1/families/{id}`. В ответе варианты модели с их задачами и медиа-входами, настраиваемые параметры с типами и допустимыми значениями, цены и ожидаемое время работы.

Там же оси выбора варианта — `axes`: чем семейство отличает свои варианты помимо формы входа. Чаще всего это уровень качества (`version`), реже размер (`size`), тир (`tier`), режим (`mode`) или формат вывода (`format`). У каждой оси перечислены допустимые значения и значение по умолчанию, а у каждого варианта — его значения осей: по ним видно, во что обходится каждый уровень.

Недоступные вам семейства из выдачи не исчезают: они приходят с `available: false` и причиной в `lockReason`. Пропустить их молча значило бы соврать, что такой модели нет.

Неизвестное имя параметра запроса — отказ 400, а не полная выдача. Опечатка вроде `?tasks=` иначе выглядела бы как применённый фильтр.

Общее устройство каталога описано в статье [Каталог моделей](https://strophe.app/docs/product/generation/model-catalog).

## Входные файлы

Медиа задаётся только нашими идентификаторами. Внешняя ссылка в генерацию не попадает: путь чужого файла внутрь один — `POST /api/v1/files` под правом `files:write`.

Файл принимается тремя способами: телом `multipart/form-data` (поле `file`), ссылкой или строкой base64.

```bash
curl https://strophe.app/api/v1/files \
  -H "Authorization: Bearer <ваш ключ>" \
  -F file=@reference.png
```

```bash
curl https://strophe.app/api/v1/files \
  -H "Authorization: Bearer <ваш ключ>" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/reference.png"}'
```

Предел файла — 25 МБ, одинаково для всех трёх способов.

Тип решает подпись содержимого, а не заявление вызывающего: `mimeType` в запросе можно передать, но если он противоречит содержимому, файл будет отвергнут.

В ответе — `fileId`, который и подставляется во входы генерации. Состояние `processing` не отказ: идентификатор уже действителен, а тяжёлое видео дообработается само. Запуск с ещё не готовым файлом отвечает `file_not_ready` — тот же запрос через несколько секунд пройдёт.

Результат прошлой генерации годится входом наравне с загруженным файлом: у готовой генерации есть поле `reusableAs` со списком полей запроса, куда её можно подать, а значением служит `generationId`.

## Оценка стоимости

Сколько будет стоить запрос, отвечает `POST /api/v1/generations/estimate` под правом `generations:read`. Тело — то же, что у запуска. Ничего не создаётся и не тратится:

```bash
curl https://strophe.app/api/v1/generations/estimate \
  -H "Authorization: Bearer <ваш ключ>" \
  -H "Content-Type: application/json" \
  -d '{"familyId": "nano-banana-pro", "prompt": "a red bicycle on a wet street"}'
```

В ответе — стоимость в кредитах, вариант модели, который будет запущен, доступные кредиты и признак `sufficient`: хватает ли счёта на эту работу прямо сейчас. Оценка считается так же, как окончательное списание, поэтому расхождения между ними нет.

## Запуск

`POST /api/v1/generations` под правом `generations:write`:

```bash
curl https://strophe.app/api/v1/generations \
  -H "Authorization: Bearer <ваш ключ>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821-render-1" \
  -d '{
        "familyId": "nano-banana-pro",
        "prompt": "a red bicycle on a wet street, evening light",
        "imageIds": ["3f2a…"],
        "maxCredits": 40
      }'
```

Обязателен только `familyId`. Подо что берётся вход, сервер читает по форме запроса. Уровень качества он угадать не может: его задают объектом `axes`, например `{"version": "pro"}`; пропущенная ось берёт значение по умолчанию. Параметры семейства идут объектом `parameters`, медиа — списками идентификаторов (`imageIds`, `videoId`, `videoIds`, `model3dIds`, `audioIds`), и порядок в списках значим: входы моделей позиционные.

`modelId` называет вариант целиком — и сценарий, и уровень; его передают, когда вариант нужен конкретный. Вместе с `axes` он не задаётся: два разных выбора в одном запросе — отказ, а не приоритет одного над другим. Имя оси или значение, которых у семейства нет, тоже отказ; значение, которого нет ни у одного варианта под эту форму входа, — отдельный отказ со списком доступных здесь.

`maxCredits` — потолок этого запроса: дороже — отказ до траты денег. Он необязателен, потому что точную цену даёт оценка, но в автоматической работе его стоит ставить всегда.

Тело проверяется строго: неизвестное поле — отказ, а не молчаливое отбрасывание. Опечатка в имени параметра иначе запустила бы не то, что вы просили, а счёт пришёл бы настоящий. То же внутри `parameters`: имя, которого нет в схеме семейства, — отказ, и медиа туда не кладут, даже если параметр модели называется ссылкой. Входы задаются только списками идентификаторов.

Ответ — 201 с идентификатором, состоянием `queued`, удержанной суммой и заголовком `Location` на ручку состояния.

## Защита от повтора

Заголовок `Idempotency-Key` делает запуск безопасным при обрыве связи. Повтор с тем же ключом и тем же телом не запускает вторую генерацию: приходит тот же ответ 201, но с `replayed: true`.

Тот же ключ с другими параметрами — отказ `idempotency_conflict`. Повтор, пока первый запрос ещё выполняется, — `idempotency_in_progress`: дождитесь его, а не запускайте новый.

Без заголовка повтор ничем не защищён. Оборвавшийся ответ в этом случае означает, что генерация могла запуститься, — и деньги за неё будут списаны.

## Ожидание исхода

Состояние генерации отдаёт `GET /api/v1/generations/{id}` под правом `generations:read`. Параметр `wait` до 60 секунд задерживает ответ до конца работы или до истечения этого времени:

```bash
curl "https://strophe.app/api/v1/generations/<id>?wait=30" \
  -H "Authorization: Bearer <ваш ключ>"
```

Состояний шесть: `queued`, `running`, `finalizing` и три конечных — `ready`, `failed`, `cancelled`. У незавершённой генерации есть `pollAfterSeconds` — через сколько имеет смысл спросить снова; у конечных его нет, потому что спрашивать больше не о чем.

Опрос — не единственный способ: исход умеет приходить сам. Как это настроить, описано в статье [Вебхуки](https://strophe.app/docs/product/developers/webhooks).

Провалившаяся генерация — это ответ 200, а не отказ запроса: состояние `failed`, а причина лежит в `error` — класс `code`, причина внутри класса `reason`, признак `retryable`, подсказка `hint` и разобранные числа в `details`. Как устроен этот разбор — в статье [Ошибки и лимиты](https://strophe.app/docs/product/developers/errors-and-limits).

## Результат

У готовой генерации в `result` лежит ссылка на файл, его тип и размеры, а у видео и аудио — длительность. Ссылка ведёт на нашу выдачу, а не в хранилище: она ничего не раскрывает и живёт не меньше суток, но умирает вместе с ключом, которым выдана. Если ключ отозвали, выданные им ссылки перестают работать.

Поле `text` в результате занято текстовой генерацией. Каталог внешнего API таких семейств не предлагает — текст через него не запускается, — но в истории они встречаются.

## История

`GET /api/v1/generations` возвращает историю страницами: до 50 записей за раз, метка следующей страницы — в `nextCursor`. Фильтр `status` принимает `active`, `ready` и `failed`.

Курсор ключевой, а не смещение: генерации, запущенные во время обхода, не сдвигают окно и не приводят к пропускам.

История общая с подключением агента по MCP: это один внешний доступ с двумя способами обращения, и делить их незачем.

## Отмена

`POST /api/v1/generations/{id}/cancel` под правом `generations:write`. В ответе — состояние, признак `cancelled` (отменил ли генерацию именно этот вызов) и `creditsCharged`.

Деньги зависят от момента: отмена до старта возвращает удержанное целиком, отмена уже начатой работы её оплачивает.

---

# Вебхуки

> Уведомления о завершении генерации на ваш адрес: подписка, три события, форма тела, проверка подписи, повторные попытки, журнал доставок и требования к приёмнику.

Источник: https://strophe.app/docs/product/developers/webhooks
Обновлено: 2026-08-02

Вебхук избавляет от опроса: как только генерация завершилась, мы сами шлём POST-запрос на ваш адрес с её итоговым состоянием. Опрос при этом остаётся рабочим способом — вебхук его дополняет, а не заменяет. Как генерация запускается и что возвращает опрос, описано в статье [Запуск генерации](https://strophe.app/docs/product/developers/generation).

## Подписка

Подписки заводятся в настройках, раздел «Интеграции». У подписки два поля:

- **Адрес** — куда слать. Только публичный адрес; внутренние и приватные отвергаются при сохранении. Он же называет подписку в списке.
- **События** — какие исходы вам нужны.

При создании выдаётся секрет подписи. Он показывается один раз — как и [ключ API](https://strophe.app/docs/product/developers/authentication), дальше он хранится только у вас.

Адрес проверяется в момент сохранения: если имя не разрешается в публичный адрес, подписка не создастся.

## События

Событий три, по числу конечных состояний генерации:

- `generation.completed` — работа закончена, результат готов;
- `generation.failed` — работа провалилась;
- `generation.cancelled` — генерация отменена.

Промежуточных переходов (постановка в очередь, начало работы) в рассылке нет намеренно: интегратору важен исход, а поток всех переходов пришлось бы фильтровать на своей стороне.

## Тело запроса

Приходит JSON:

```json
{
  "id": "9f1c…",
  "event": "generation.completed",
  "createdAt": "2026-08-02T10:15:31.204Z",
  "data": { "generation": { "generationId": "3f2a…", "state": "ready", "…": "…" } }
}
```

Генерация внутри `data` — в той же форме, что и ответ `GET /api/v1/generations/{id}`. Одна и та же работа не должна выглядеть по-разному в зависимости от того, пришла она сама или её спросили.

Поле `id` — идентификатор доставки. Он одинаков у всех попыток одного события: по нему приёмник глушит повторы.

`createdAt` — момент события, а не момент попытки. У повторной доставки он тот же, что у первой.

## Проверка подписи

Каждый запрос несёт заголовок `X-Strophe-Signature`:

```http
X-Strophe-Signature: t=1785974131,v1=6b1f…
```

`t` — момент подписи в секундах Unix, `v1` — HMAC-SHA256 от строки `t.тело` с вашим секретом, в шестнадцатеричном виде. Проверка на стороне приёмника:

```python
import hashlib, hmac, time

def valid(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts["v1"])
```

Считать подпись нужно от сырого тела запроса, до разбора JSON: повторная сборка объекта в строку даёт другие пробелы и другой порядок ключей, а значит другую подпись.

Момент `t` стоит сравнивать с текущим временем и отвергать слишком старые запросы: без этого перехваченный когда-то запрос можно проиграть заново.

Запрос без действительной подписи обрабатывать нельзя. Адрес приёмника рано или поздно становится известен, и подпись — единственное, что отличает наше уведомление от чужого.

## Требования к приёмнику

**Успех — только код 2xx.** Всё остальное считается неудачей и приводит к повтору. Редиректы не выполняются: приёмник обязан отвечать по тому адресу, на который подписался.

**Ответить нужно за 10 секунд.** Долгая работа по событию выносится в свою очередь: сначала подтвердите приём, потом обрабатывайте.

**Повторы возможны.** Сеть не гарантирует однократной доставки: приёмник должен переживать повтор того же `id` без последствий.

**Порядок не гарантирован.** Событие по одной генерации может обогнать событие по другой; опираться нужно на содержимое, а не на очерёдность прихода.

## Повторные попытки

Неудачная доставка повторяется до шести раз с удваивающейся паузой, начиная с десяти секунд, — это около двадцати минут на приёмник, упавший под нагрузкой. Исчерпав попытки, доставка закрывается как неудачная.

Первые несколько байт ответа приёмника мы сохраняем: если он объяснил отказ текстом, этот текст будет виден в журнале.

## Журнал доставок

У каждой подписки есть журнал: строка на событие с временем, номером события, числом попыток, кодом ответа и текстом отказа. Он открывается из меню подписки в настройках и отвечает на вопрос «почему мне не пришло» без обращения в поддержку.

Журнал хранится 30 дней — хвост чистится сам.

В строке подписки в списке видны день последней доставки и её исход: так поломка заметна раньше, чем о ней сообщит журнал.

---

# Ошибки и лимиты

> Форма отказа REST API Строфи, коды ошибок и их статусы, признак повторяемости, частота запросов и одновременность, потолки размеров и машинное описание API.

Источник: https://strophe.app/docs/product/developers/errors-and-limits
Обновлено: 2026-08-26

Отказ приходит в одной и той же форме на всех ручках, а по заголовкам ответа видно, сколько запросов осталось и когда повторять. Эта статья описывает то и другое.

## Форма отказа

```json
{
  "error": {
    "code": "cost_above_limit",
    "message": "The generation costs more than the maxCredits ceiling in the request.",
    "retryable": false,
    "details": { "credits": 62, "maxCredits": 40 }
  }
}
```

`code` — машинный идентификатор, единственное, на что стоит программировать. `message` написан для человека, читающего лог, и может меняться. `retryable` отвечает, имеет ли смысл повторить тот же запрос без изменений. `details` есть не всегда: там лежит машинная диагностика — перечень проблем валидации, незакрытые входы, числа.

Локализации нет: ответ читает программа, а не человек в интерфейсе.

Каждый ответ несёт `X-Request-Id`. Он же записан в наших логах, поэтому разбор происшествия начинается с одной строки — приложите его к обращению в поддержку.

## Коды и статусы

Список закрыт: код, которого нет в таблице, наружу не уходит. Новые значения могут появляться — незнакомый код обрабатывайте по числовому статусу и признаку `retryable` из ответа.

| Код                         | HTTP | Когда приходит                                                               |
| --------------------------- | ---- | ---------------------------------------------------------------------------- |
| `access_denied`             | 403  | Внешний доступ этому счёту закрыт                                            |
| `account_blocked`           | 403  | Аккаунт заблокирован                                                         |
| `cancel_conflict`           | 409  | Состояние генерации изменилось во время отмены                               |
| `cost_above_limit`          | 402  | Запрос дороже переданного `maxCredits`                                       |
| `credit_reservation_failed` | 402  | Резерв кредитов не удался                                                    |
| `family_not_found`          | 400  | Такого семейства в каталоге нет                                              |
| `family_unavailable`        | 503  | Семейство временно недоступно                                                |
| `file_fetch_failed`         | 502  | Файл по ссылке не забрался                                                   |
| `file_metadata_missing`     | 400  | Длительность входного видео ещё не измерена, а модель считается посекундно   |
| `file_not_found`            | 404  | Такого идентификатора файла или генерации нет                                |
| `file_not_ready`            | 409  | Файл ещё обрабатывается                                                      |
| `file_processing_failed`    | 422  | Обработка файла не удалась                                                   |
| `generation_not_found`      | 404  | Генерации с таким идентификатором у вас нет                                  |
| `idempotency_conflict`      | 409  | `Idempotency-Key` уже использован с другими параметрами                      |
| `idempotency_in_progress`   | 409  | Запрос с этим `Idempotency-Key` ещё выполняется                              |
| `insufficient_credits`      | 402  | Кредитов не хватает — на балансе или после вычета занятого другими запусками |
| `insufficient_scope`        | 403  | У ключа нет нужного права                                                    |
| `internal_error`            | 500  | Внутренний сбой сервиса                                                      |
| `invalid_cursor`            | 400  | Метка страницы не из предыдущего ответа                                      |
| `invalid_parameters`        | 400  | Параметры не подходят выбранному варианту                                    |
| `invalid_request`           | 400  | Тело или параметр не прошли проверку; перечень проблем в `details.issues`    |
| `invalid_token`             | 401  | Ключ отозван или не существует                                               |
| `not_cancellable`           | 409  | Генерация запущена в редакторе — такую отменяют там                          |
| `payload_too_large`         | 413  | Тело запроса больше допустимого                                              |
| `price_unavailable`         | 400  | Стоимость для таких параметров не складывается                               |
| `queue_unavailable`         | 500  | Очередь генерации недоступна                                                 |
| `rate_limited`              | 429  | Превышена частота запросов                                                   |
| `service_unavailable`       | 503  | Сервис временно недоступен                                                   |
| `spend_limit_exceeded`      | 403  | Задет потолок ключа; какой именно — в `details.limitKind`                    |
| `subscription_required`     | 403  | Семейство требует действующей подписки                                       |
| `team_expired`              | 403  | Срок плана команды истёк                                                     |
| `team_required`             | 403  | Доступ идёт вместе с командой; чего не хватает — в тексте ошибки             |
| `too_many_concurrent`       | 429  | Слишком много запросов в работе одновременно                                 |
| `unauthorized`              | 401  | Ключа в запросе нет                                                          |
| `unsupported_content_type`  | 415  | Такой тип содержимого ручка не принимает                                     |
| `unsupported_file_format`   | 415  | Содержимое не опознано или такой формат не принимается                       |
| `variant_not_resolved`      | 400  | Ни один вариант не подходит к такому набору входов                           |

Повторять или нет, говорит `retryable` в самом ответе: у части кодов это зависит от причины, а не от кода. У всех отказов по частоте и временной недоступности есть заголовок `Retry-After`.

## Отказ самой генерации

Провалившаяся генерация — это ответ 200 с состоянием `failed`, а не отказ запроса: запрос выполнен, не удалась работа модели. Причина лежит внутри генерации и устроена иначе, чем отказ ручки:

```json
{
  "generationId": "8f14e45f-ceea-467a-9a3e-4b1c2d5e7f80",
  "state": "failed",
  "error": {
    "code": "prompt_validation",
    "reason": "prompt_too_long",
    "message": "The prompt is longer than this model accepts.",
    "retryable": false,
    "hint": "The model could not work with this prompt. Rewrite it, or pick another family.",
    "details": { "limit": 8000 }
  }
}
```

`code` — класс отказа, список закрыт. `hint` называет действие, а не диагноз: что менять перед следующей попыткой. `details` появляется там, где из отказа удалось достать числа — например предел длины промпта у конкретной модели.

| Класс                  | Что случилось                                 | Что делать                                                    |
| ---------------------- | --------------------------------------------- | ------------------------------------------------------------- |
| `content_policy`       | Модель отказала по содержанию                 | Менять промпт или входные файлы: тот же запрос откажет так же |
| `prompt_validation`    | Модель не приняла промпт                      | Переписать промпт или взять другую модель                     |
| `parameter_validation` | Параметр или вход не подходит модели          | Свериться со схемой семейства и поправить запрос              |
| `file_size`            | Входной файл не укладывается в пределы модели | Взять файл другого размера                                    |
| `image_size`           | Размер входной картинки не подходит модели    | Взять картинку другого размера                                |
| `image_format`         | Файл не прочитался как изображение            | Загрузить заново в поддерживаемом формате                     |
| `feature_limitation`   | Модель не умеет того, о чём просили           | Выбрать другое семейство                                      |
| `model_unavailable`    | Модель недоступна                             | Взять другое семейство или повторить позже                    |
| `insufficient_credits` | Кредиты кончились до старта                   | Проверить баланс перед повтором                               |
| `authentication`       | Доступ к модели временно закрыт               | Повторить позже; запрос ни при чём                            |
| `timeout`              | Модель не ответила вовремя                    | Повторить: тот же запрос может пройти                         |
| `provider_error`       | Временный сбой модели                         | Повторить: тот же запрос может пройти                         |
| `internal_error`       | Внутренний сбой сервиса                       | Повторить: тот же запрос может пройти                         |
| `user_cancellation`    | Генерацию отменили                            | Ничего — это ответ на отмену                                  |

`reason` — причина внутри класса, и программировать разное поведение стоит по ней. Один класс покрывает несколько разных отказов: `prompt_too_long`, `prompt_failed` и `prompt_images_failed` — всё это `prompt_validation`, но первый чинится обрезкой промпта, а последний — заменой входных изображений. Список причин, в отличие от списка классов, открыт и пополняется. Опубликованную причину мы не переименовываем, а незнакомую обрабатывайте по `code`.

Дословную формулировку модели мы наружу не отдаём — в `message` приходит наш текст по причине отказа. Чтобы подняли оригинал, назовите `generationId` в обращении в поддержку.

Причина `unclassified` при классе `internal_error` означает, что отказ не удалось отнести ни к одной известной причине. Обрабатывайте его по `code`; со временем такие отказы получают точную причину.

## Частота и одновременность

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

**Частота.** Сейчас это 600 запросов в минуту. Превышение — 429 `rate_limited` с заголовком `Retry-After`.

**Одновременность.** До 32 запросов в работе одновременно. Потолок рассчитан на ожидание через `wait`, где запрос висит до минуты; больше тридцати двух открытых ожиданий — повод перейти на историю или на вебхуки. Превышение — 429 `too_many_concurrent`.

Точные числа приходят в заголовках каждого ответа, и опираться стоит на них, а не на значения в тексте:

```http
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587
X-RateLimit-Reset: 1785974160
```

`X-RateLimit-Reset` — момент обнуления счётчика в секундах Unix.

Отдельно от этого действуют потолки траты самого ключа — суточный и разовый. Они описаны в статье [Ключ и права](https://strophe.app/docs/product/developers/authentication).

## Потолки размеров

| Что                     | Предел                                                       | При превышении                      |
| ----------------------- | ------------------------------------------------------------ | ----------------------------------- |
| Тело запроса            | 256 КиБ                                                      | 413 `payload_too_large`             |
| Файл                    | 25 МБ — одинаково для `multipart/form-data`, ссылки и base64 | Отказ приёма файла                  |
| Промпт                  | 20 000 символов                                              | Отказ проверки запроса              |
| Медиа-входов в запросе  | 20                                                           | Подбор варианта отказывает          |
| Страница истории        | 50 записей                                                   | Значение больше не принимается      |
| Ожидание внутри запроса | 60 секунд                                                    | Ответ приходит с `pollAfterSeconds` |

Две последние границы — заслон транспорта, а не пределы моделей: те заметно строже. Сколько входов принимает вариант, видно в подробностях семейства (`inputs.images.max` и соседние поля), а предел длины промпта — в описании параметра, если модель его объявляет. Лишний файл отвергается подбором варианта с кодом `variant_not_resolved`, слишком длинный промпт — самой моделью с классом `prompt_validation`, и там же в `details` приходит настоящее число.

## Машинное описание

`GET /api/v1/openapi.json` отдаёт спеку OpenAPI: пути, схемы запросов и ответов, коды отказа. Она собирается из тех же схем, которыми проверяется вход, поэтому разойтись с поведением не может.

Спека закрыта тем же ключом, что и остальные ручки, и требует права `catalog:read`: документ описывает ручки, но не даёт ими пользоваться.