События (SSE)

GET /v1/events — поток server-sent events, по которому приходит каждая смена статуса ваших генераций. Отправили генерацию — держите поток открытым и узнаёте о её продвижении, ничего не спрашивая. Маршруту нужна область generations:read, та же, что и чтению генераций.

Подключение

Поток открывается обычным GET с ключом в Authorization и отвечает 200 с Content-Type: text/event-stream. Соединение остаётся открытым, а события приходят по мере того, как они происходят. Поток открывается с текущего момента: прошлые события новое подключение не пересказывает. Поэтому открывайте поток до первой отправки генерации, а если подключились позже — один раз запросите POST /v1/generations/status по отправленным генерациям, у которых ещё нет финального статуса.

curl -N https://api.coremind.ru/v1/events \
  -H "Authorization: Bearer cmk_..." \
  -H "Accept: text/event-stream"

Каждое событие — кадр generation.status_changed с полями event:, id: и data:. В data пять полей: id генерации, type, новый status, признак deleted и updated_at — когда переход записан. Строка, начинающаяся с двоеточия, — пульс, о нём ниже.

event: generation.status_changed
id: 128
data: {"id":"<generation_id>","type":"video","status":"RUNNING","deleted":false,"updated_at":"2026-09-24T09:15:02.418Z"}

: ping

Фильтр по типу

?types= принимает опубликованные типы генераций через запятую — те же значения, что type в записи генерации. В поток попадут только события этих типов. Неизвестный тип — отказ 400 с кодом invalid_request, а не тихо пустой поток: иначе каждое переподключение повторяло бы ту же ошибку.

curl -N "https://api.coremind.ru/v1/events?types=video,image" \
  -H "Authorization: Bearer cmk_..." \
  -H "Accept: text/event-stream"

Переподключение без потерь

У каждого кадра, кроме пульса, есть id:. Это непрозрачный курсор вашего аккаунта: сравнивать, разбирать и собирать его не нужно — только сохранить последний полученный. Соединение оборвалось — подключитесь снова и передайте его в заголовке Last-Event-ID: поток продолжится сразу после этого события, ничего не потеряв и ничего не повторив, на каком бы сервере ни открылось новое соединение.

curl -N https://api.coremind.ru/v1/events \
  -H "Authorization: Bearer cmk_..." \
  -H "Accept: text/event-stream" \
  -H "Last-Event-ID: 128"

Обрывы долгих соединений — обычный режим, а не авария: на сетях, где долгие соединения режут, клиент будет переподключаться постоянно. Поэтому переподключение по Last-Event-ID — основной путь, и строить клиента нужно вокруг него.

Браузерный EventSource сам запоминает последний id: и переподключается, но не умеет отправить заголовок Authorization, а ключ из браузера не принимается вовсе — CORS для этого канала закрыт. Держите поток на своём сервере. Пример клиента на Node.js: он читает поток через fetch, запоминает последний id: и возвращает его в Last-Event-ID при каждом переподключении.

const EVENTS_URL = 'https://api.coremind.ru/v1/events';
let lastEventId;

async function follow() {
  for (;;) {
    const headers = {
      Authorization: `Bearer ${process.env.COREMIND_API_KEY}`,
      Accept: 'text/event-stream',
    };
    if (lastEventId !== undefined) headers['Last-Event-ID'] = lastEventId;

    try {
      const res = await fetch(EVENTS_URL, { headers });
      if (res.status === 429) {
        await pause(Number(res.headers.get('retry-after') ?? 5));
        continue;
      }
      if (!res.ok) {
        // Отказ — обычный JSON с кодом ошибки: переподключение его не исправит.
        const { error } = await res.json();
        console.error('stream refused:', error.code);
        return;
      }
      await readFrames(res.body);
    } catch (err) {
      console.error('stream dropped:', err);
    }
    await pause(1);
  }
}

async function readFrames(body) {
  const decoder = new TextDecoder();
  let buffer = '';
  for await (const chunk of body) {
    buffer += decoder.decode(chunk, { stream: true });
    let end;
    while ((end = buffer.indexOf('\n\n')) !== -1) {
      handleFrame(buffer.slice(0, end));
      buffer = buffer.slice(end + 2);
    }
  }
}

function handleFrame(frame) {
  const fields = { data: '' };
  for (const line of frame.split('\n')) {
    if (line.startsWith(':')) continue; // пульс
    const colon = line.indexOf(':');
    const name = line.slice(0, colon);
    const value = line.slice(colon + 1).replace(/^ /, '');
    if (name === 'data') fields.data += value;
    else fields[name] = value;
  }
  if (fields.id !== undefined) lastEventId = fields.id;
  if (!fields.event) return;

  const data = JSON.parse(fields.data);
  if (fields.event === 'generation.status_changed') onStatusChanged(data);
  if (fields.event === 'reset') catchUp(); // POST /v1/generations/status
  if (fields.event === 'terminated') console.error('stream ended:', data.error.code);
}

const pause = (seconds) => new Promise((r) => setTimeout(r, seconds * 1000));

Кадр reset

Если присланный Last-Event-ID продолжить нельзя, поток всё равно отвечает 200, первым кадром присылает reset и дальше идёт как обычно. Причин две:

  • cursor_expired — события после этого курсора уже удалены: поток хранит историю за окно в днях, равное 7;
  • cursor_unknown — курсор испорчен, не выдавался или принадлежит не этому потоку.

У reset есть свежий id: — сохраните его, как любой другой, иначе следующее переподключение упрётся в тот же разрыв. Пропущенное между старым курсором и этим кадром нужно добрать отдельно: поле catch_up указывает на POST /v1/generations/status — пакетное чтение статусов из быстрого старта, которому передают генерации, чей исход ещё не известен.

event: reset
id: 131
data: {"reason":"cursor_expired","catch_up":"/v1/generations/status"}

Кодом ошибки reset не является намеренно: на ответ не 200 браузерный EventSource закрывает поток навсегда и больше не переподключается.

Кадр terminated

Доступ проверяется не только при открытии, но и пока поток открыт. Если ключ отозвали, у аккаунта забрали доступ к API или API выключен, поток присылает кадр terminated и закрывается. В его data — обычное тело ошибки с кодом причины, тот же конверт, что у любого отказа. Автоматическое переподключение после этого получит тот же отказ уже обычным ответом с HTTP-статусом — дальше переподключаться незачем. Полный словарь кодов — на странице кодов ошибок.

event: terminated
id: 137
data: {"error":{"code":"api_key_revoked","message":"This API key has been revoked.","user_message":"Этот API-ключ отозван.","request_id":"8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11"}}

Что попадает в поток

  • Каждая смена статуса, включая промежуточные: генерация, созданная отправкой, тоже приходит событием.
  • Удаление — событие с deleted: true, даже если статус при этом не изменился: удалённая готовая генерация приходит со своим прежним status.
  • Отмена — это status: "FAILED" вместе с deleted: true, одним событием. Отдельного статуса «отменено» нет, а сама запись после отмены отвечает 404.
  • FAILED не всегда последнее слово: если результат пришёл с опозданием, та же генерация перейдёт в DONE, и этот переход придёт отдельным событием.

Событие сообщает, какая генерация сдвинулась, но не несёт её данных. Результат, ссылки и возврат кредитов перечитывайте через GET /v1/generations/{id} — или пакетом через POST /v1/generations/status, если событий пришло сразу много.

Лимиты

  • Пульс — строка-комментарий, начинающаяся с двоеточия; интервал в секундах — 15. EventSource его пропускает сам, собственный разборщик должен пропускать тоже. Долгая тишина без пульса означает мёртвое соединение: его стоит закрыть и открыть снова.
  • Одновременных потоков на один ключ — 10, на всех серверах вместе. Следующий получит 429 с кодом too_many_streams, пока один из открытых не закроется.
  • Каждое открытие и каждое переподключение расходует один запрос из окна частоты, как любой вызов /v1; кадры открытого потока ничего не стоят. Исчерпанное окно — 429 с кодом rate_limited и Retry-After. Окна описаны на странице лимитов.
HTTP/1.1 429 Too Many Requests

{
  "error": {
    "code": "too_many_streams",
    "message": "This key already holds the maximum number of open event streams. Close one before opening another.",
    "user_message": "Для этого ключа уже открыто максимальное число потоков событий. Закройте один из них, прежде чем открывать новый.",
    "request_id": "8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11"
  }
}