# Farvater API — машиночитаемая спецификация (OpenAPI 3.1).
#
# Файл ведётся ВРУЧНУЮ (автогенерации из кода шлюза нет). Правило синхронизации:
# спека и справочник /docs/api-reference правятся в ОДНОЙ задаче — изменение API
# без правки этого файла не принимается.
#
# Линт-ворота: npm run lint:openapi (redocly lint, конфиг redocly.yaml).
#
# Канонические источники контента: V&S-gateway-core-2026-09-28-v1,
# V&S-billing-credits-2026-09-28-v1, V&S-b2b-teams-keys-edi-2026-09-28-v1,
# V&S-dashboard-lk-2026-09-28-v1 (GET /v1/usage/series + код usage_series_unavailable),
# V&S-invoices-list-endpoint-2026-09-29-v1 (GET /v1/payments/invoices),
# V&S-gateway-accounts-auth-2026-09-29-v1 (auth-контур /v1/auth/*),
# V&S-keys-self-service-2026-09-29-v1 (личные ключи /v1/keys*, автовыдача
# первого ключа в ответе /v1/auth/verify-email),
# V&S-org-invites-2026-10-01-v1 (приглашения и участники организации:
# /v1/orgs/current/invites*, /v1/orgs/current/members*, /v1/orgs/invites/*),
# V&S-notifications-engine-2026-10-01-v1 (настройки e-mail уведомлений
# /v1/notifications/settings, session-only контур),
# V&S-key-spend-limits-2026-10-01-v1 (месячные лимиты расхода на API-ключ:
# monthly_limit_credits/monthly_spent_credits на личных и org-ключах,
# 429 key_spending_limit_exceeded, событие key_spend_limit_reached в настройках
# уведомлений),
# V&S-b2b-org-subscription-2026-10-01-v1 (org-подписка: общие окна на организацию,
# /v1/orgs/current/subscription*, поля purpose/plan_id в Invoice),
# V&S-gateway-batch-api-2026-10-25-v1 (batch-контуры обоих вендорных семейств:
# /v1/files purpose=batch, /v1/batches*, /v1/messages/batches*, cabinet
# /v1/billing/batches*; окно 24h, −50% к цене единиц, жизненный цикл C-STATUS).
# V&S-gwc-multimodal-catalog-2026-10-25-v1 (мультимодальные контуры: vision-вход
# в chat, генерация изображений, TTS, ASR, эмбеддинги; карточка каталога D2,
# снапшот GET /models.json, единицы модальностей в сетлменте биллинга).
# V&S-gateway-anthropic-messages-2026-10-25-v1 (формат Anthropic Messages:
# POST /v1/messages, dual-accept x-api-key/Bearer, обязательный
# anthropic-version, заголовки Ruslo-Usage-Cost/Ruslo-Usage-Path на обоих
# семействах).
# V&S-gwo-observability-2026-10-26-v1 (оконные заголовки подписочного контура
# Ruslo-Window-Short-*/Ruslo-Window-Month-* и deprecated read-only близнецы
# X-Farvater-Window-*/X-Farvater-Usage-Path — read-only до следующего
# release-тега; аудит error-конвертов обоих семейств и каталога модальностей).
# Запрещено добавлять эндпоинты, которых нет в этих документах (личные
# подписки, BYOK и т.п. — в разработке, не публикуются; из usage-контура публикуется
# только GET /v1/usage/series; из payments-контура — только
# GET /v1/payments/invoices).
# Исключение из правила ручного ведения: top-level блок x-error-codes в конце
# файла — ГЕНЕРИРУЕМЫЙ (scripts/docs-error-codes.mjs из errors.go шлюза,
# маркеры GENERATED BEGIN/END); ручные правки блока запрещены. Это реестр кодов
# ошибок всех контуров (включая непубличные) — эндпоинты непубличных контуров
# по-прежнему добавлять запрещено.

openapi: 3.1.0

info:
  title: Farvater API
  version: 1.1.0+2026-10-26
  description: |
    OpenAI-совместимый API Фарватера — российского LLM-шлюза с оплатой в рублях.

    Аутентификация — заголовок `Authorization: Bearer fv-sk-…` на эндпоинтах
    `/v1/*` (формат ключа: префикс `fv-sk-` + 43 символа base64url), кроме контура
    аккаунтов `/v1/auth/*`: там сессионная cookie `farvater_session` (см. тег `auth`)
    либо публичный доступ. Health-эндпоинты `/healthz` и `/readyz` аутентификации
    не требуют.

    Второй нативный формат — Anthropic Messages: эндпоинт `POST /v1/messages`
    (тег `messages`). Base URL для Anthropic-клиентов задаётся БЕЗ `/v1`
    (SDK добавляет путь сам). Аутентификация — тот же ключ Фарватера
    (`ruslo-sk-…`/`fv-sk-…`) в заголовке `x-api-key` либо
    `Authorization: Bearer` (один ключ на оба формата) плюс обязательный
    заголовок `anthropic-version`. Ваш код на OpenAI SDK или Anthropic SDK
    заработает без переписывания — поменяйте base_url и ключ.

    Денежная модель: 1 кредит = 1 копейка (`credits_per_rub = 100`). Денежные суммы
    передаются строками с фиксированной точностью; цены — в ₽ за 1M токенов раздельно
    для ввода и вывода.

    Пакетная обработка: асинхронные batch-задачи двух вендорных семейств с окном
    24h и скидкой −50% к цене единиц — OpenAI-семейство (`/v1/files`
    purpose=batch, `/v1/batches*`) и Anthropic-семейство
    (`/v1/messages/batches*`, dual-accept `Authorization: Bearer` и `x-api-key`);
    результаты — JSONL-файлы и JSONL-поток. Cabinet-контракт batch-задач
    (данные экрана K6): `/v1/billing/batches*`.

    Человекочитаемая документация: https://farvater.ru/docs

    Редакция 1.1.0 (2026-10-25): мультимодальные контуры (vision-вход в chat,
    генерация изображений, TTS, ASR, эмбеддинги), карточка модели каталога
    (ключ `card` в GET /v1/models, снапшот GET /models.json) и единицы
    модальностей в проводках биллинга.

    Редакция 1.1.0+2026-10-26: оконные заголовки подписочного контура
    Ruslo-Window-Short-*/Ruslo-Window-Month-* и deprecated read-only близнецы
    X-Farvater-Window-*/X-Farvater-Usage-Path (сохраняются до следующего
    release-тега); аудит error-конвертов обоих семейств (аддитивные поля
    window/other_window OpenAI-конверта) и каталога модальностей.

# Актуальный публичный origin задаётся деплоем; здесь — каноническое значение по умолчанию
# (совпадает с NEXT_PUBLIC_GATEWAY_URL в farvater-front).
servers:
  - url: https://api.farvater.ru
    description: Продакшн

tags:
  - name: auth
    description: Аккаунты email+пароль и cookie-сессии (farvater_session)
  - name: keys
    description: Личные API-ключи аккаунта (управление — только по session-cookie)
  - name: notifications
    description: Настройки e-mail уведомлений аккаунта (управление — только по session-cookie)
  - name: chat
    description: Генерация (OpenAI-совместимая)
  - name: messages
    description: Генерация (формат Anthropic Messages)
  - name: models
    description: Каталог моделей
  - name: files
    description: Файлы пакетной обработки — JSONL-входы и сгенерированные файлы результатов/ошибок (только purpose=batch)
  - name: batches
    description: Асинхронные batch-задачи обоих семейств (окно 24h, −50% к цене единиц) — OpenAI /v1/batches*, Anthropic /v1/messages/batches*
  - name: multimodal
    description: Мультимодальные контуры — генерация изображений, TTS, ASR, эмбеддинги
  - name: billing
    description: Баланс, история списаний и batch-задачи кабинета (K6)
  - name: payments
    description: Счета на оплату (B2B)
  - name: usage
    description: Агрегаты использования для графиков (ClickHouse)
  - name: team
    description: Организации — профиль юрлица, ключи команды с ролями, приглашения и участники
  - name: documents
    description: Закрывающие документы (УПД) и статусы ЭДО
  - name: health
    description: Служебные проверки состояния

paths:
  /v1/auth/register:
    post:
      tags: [auth]
      operationId: registerAccount
      summary: Зарегистрировать аккаунт (email + пароль)
      description: |
        Создаёт аккаунт и отправляет письмо со ссылкой подтверждения
        (`/verify-email?token=…` на публичном фронте, TTL токена 24 часа).
        Enumeration-safe: для уже существующего email ответ идентичен успешной
        регистрации — наличие аккаунта по ответу определить нельзя.
        Политика пароля — 10..128 символов (иначе `weak_password`).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthRegisterRequest'
      responses:
        '200':
          description: Запрос принят (одинаков для нового и существующего email)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthStatusOk'
        '400':
          description: Невалидный email/тело (invalid_request) или пароль вне политики (weak_password)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Password must be at least 10 characters
                  type: invalid_request_error
                  code: weak_password
        '429':
          description: Превышен лимит запросов; заголовок Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Too many requests
                  type: rate_limit_error
                  code: rate_limited

  /v1/auth/verify-email:
    post:
      tags: [auth]
      operationId: verifyEmail
      summary: Подтвердить email по токену из письма
      description: |
        Подтверждает адрес одноразовым токеном из ссылки письма. Ошибка
        `invalid_token` едина для неизвестного, истёкшего и уже использованного
        токена — различать их по ответу нельзя. При первом подтверждении
        аккаунту автоматически выдаётся первый личный ключ «Первый ключ»
        (идемпотентно, в той же транзакции): его plaintext присутствует в
        ответе ровно один раз — в поле `key`; при повторных вызовах и когда
        активный ключ уже есть поле отсутствует.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthTokenRequest'
      responses:
        '200':
          description: Email подтверждён; `key` — только при реальной автовыдаче первого ключа
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthVerifyEmailResponse'
        '400':
          description: Токен неизвестен, истёк или уже использован (invalid_token)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid or expired token
                  type: invalid_request_error
                  code: invalid_token

  /v1/auth/resend-confirmation:
    post:
      tags: [auth]
      operationId: resendConfirmation
      summary: Повторно отправить письмо подтверждения
      description: |
        Enumeration-safe: ответ одинаков независимо от того, существует ли
        аккаунт с таким email. Повторная отправка генерирует новый токен
        (старый становится недействительным).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthEmailRequest'
      responses:
        '200':
          description: Запрос принят (одинаков для существующего и несуществующего email)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthStatusOk'
        '429':
          description: Превышен лимит запросов; заголовок Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Too many requests
                  type: rate_limit_error
                  code: rate_limited

  /v1/auth/login:
    post:
      tags: [auth]
      operationId: loginAccount
      summary: Войти по email + паролю (выставляет session-cookie)
      description: |
        При успехе выставляет session-cookie `farvater_session` (HttpOnly;
        Secure по TLS шлюза; SameSite=Lax; Path=/; Max-Age = sliding TTL сессии).
        Сессия sliding: TTL продлевается при активности (7 дней по умолчанию),
        с абсолютным потолком от момента входа (30 дней по умолчанию).
        Ошибка `invalid_credentials` едина для неверного email и неверного
        пароля. Вход неподтверждённым аккаунтам (`verified: false`) разрешён.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthLoginRequest'
      responses:
        '200':
          description: Вход выполнен; аккаунт + Set-Cookie farvater_session
          headers:
            Set-Cookie:
              description: |
                Session-cookie: `farvater_session=<token>; HttpOnly; Secure (по TLS);
                SameSite=Lax; Path=/; Max-Age=<sliding TTL сессии>`
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthLoginResponse'
        '401':
          description: Неверный email или пароль (единая формулировка)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid email or password
                  type: authentication_error
                  code: invalid_credentials
        '403':
          description: Аккаунт отключён (disabled)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Account is disabled
                  type: authentication_error
                  code: account_disabled
        '429':
          description: Превышен лимит запросов; заголовок Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Too many requests
                  type: rate_limit_error
                  code: rate_limited

  /v1/auth/logout:
    post:
      tags: [auth]
      operationId: logoutAccount
      summary: Выйти (отзывает текущую сессию)
      description: |
        Отзывает сессию текущей cookie и очищает cookie в ответе
        (`farvater_session=; Max-Age<0; Path=/; HttpOnly; SameSite=Lax`).
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Сессия отозвана; cookie очищается
          headers:
            Set-Cookie:
              description: Очистка session-cookie (Max-Age < 0)
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthStatusOk'
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required

  /v1/auth/me:
    get:
      tags: [auth]
      operationId: getCurrentAccount
      summary: Текущий аккаунт сессии
      description: Единственный эндпоинт, возвращающий `created_at` аккаунта.
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Аккаунт текущей сессии
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthMeResponse'
        '401':
          description: Нет/недействительна session-cookie (истёкшая — тоже session_required)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required

  /v1/auth/password-reset:
    post:
      tags: [auth]
      operationId: requestPasswordReset
      summary: Запросить сброс пароля (письмо со ссылкой)
      description: |
        Отправляет письмо со ссылкой сброса (`/reset-password?token=…` на
        публичном фронте, TTL токена 1 час). Enumeration-safe: ответ одинаков
        независимо от существования аккаунта.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthEmailRequest'
      responses:
        '200':
          description: Запрос принят (одинаков для существующего и несуществующего email)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthStatusOk'
        '429':
          description: Превышен лимит запросов; заголовок Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Too many requests
                  type: rate_limit_error
                  code: rate_limited

  /v1/auth/password-reset/confirm:
    post:
      tags: [auth]
      operationId: confirmPasswordReset
      summary: Установить новый пароль по токену сброса
      description: |
        Устанавливает новый пароль одноразовым токеном из ссылки письма.
        При успехе ВСЕ сессии аккаунта отзываются — нужен повторный вход.
        `invalid_token` един для неизвестного, истёкшего и использованного токена.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthPasswordResetConfirmRequest'
      responses:
        '200':
          description: Пароль изменён; все сессии аккаунта отозваны
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthStatusOk'
        '400':
          description: |
            Токен недействителен (invalid_token) или новый пароль вне политики
            10..128 символов (weak_password)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid or expired token
                  type: invalid_request_error
                  code: invalid_token

  /v1/auth/password/change:
    post:
      tags: [auth]
      operationId: changePassword
      summary: Сменить пароль (по текущему паролю и сессии)
      description: |
        Меняет пароль при знании текущего. При успехе ВСЕ сессии аккаунта,
        включая текущую, отзываются, а cookie очищается
        (`farvater_session=; Max-Age<0`) — нужен повторный вход.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthPasswordChangeRequest'
      responses:
        '200':
          description: Пароль изменён; все сессии отозваны, cookie очищается
          headers:
            Set-Cookie:
              description: Очистка session-cookie (Max-Age < 0)
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthStatusOk'
        '400':
          description: Новый пароль вне политики 10..128 символов (weak_password)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Password must be at least 10 characters
                  type: invalid_request_error
                  code: weak_password
        '401':
          description: |
            Нет/недействительна session-cookie (session_required) или неверный
            текущий пароль (invalid_credentials)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid current password
                  type: authentication_error
                  code: invalid_credentials

  /v1/keys:
    get:
      tags: [keys]
      operationId: listPersonalKeys
      summary: Список личных ключей аккаунта
      description: |
        Только личные standalone-ключи аккаунта текущей сессии (ключи
        организаций и системного аккаунта не включаются), сортировка по `id`
        ASC. Key material в ответе отсутствует — только префикс.
        Управление ключами — session-only: Bearer-ключ не принимается.
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Личные ключи аккаунта (без значений — plaintext не хранится)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonalKeysList'
        '401':
          description: Нет/недействительна session-cookie (истёкшая — тоже session_required)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '403':
          description: Email аккаунта не подтверждён
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Email is not verified
                  type: authentication_error
                  code: email_not_verified
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags: [keys]
      operationId: createPersonalKey
      summary: Выпустить личный ключ
      description: |
        Имя — trim, 1..100 символов (иначе `invalid_request`); уникальность не
        требуется. Лимит — 10 активных ключей на аккаунт (`key_limit_exceeded`;
        отозванные не считаются). Опциональный `monthly_limit_credits` —
        месячный лимит расхода ключа в кредитах (decimal-строка строго > 0;
        absent|null — без лимита): расход текущего календарного месяца (UTC) +
        резерв под запрос больше лимита — отказ 429
        `key_spending_limit_exceeded`. Plaintext `api_key` возвращается один раз,
        только в этом ответе; далее в БД хранится только SHA-256.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePersonalKeyRequest'
      responses:
        '200':
          description: Ключ выпущен; `api_key` показывается один раз
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedPersonalKey'
        '400':
          description: Невалидное имя или лимит (invalid_request) или превышен лимит ключей (key_limit_exceeded)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Active key limit exceeded
                  type: invalid_request_error
                  code: key_limit_exceeded
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '403':
          description: Email аккаунта не подтверждён
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Email is not verified
                  type: authentication_error
                  code: email_not_verified
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/keys/{id}:
    patch:
      tags: [keys]
      operationId: updatePersonalKey
      summary: Изменить личный ключ (имя и/или месячный лимит расхода)
      description: |
        Частичное обновление: имя (trim, 1..100 символов) и/или
        `monthly_limit_credits`. Хотя бы одно поле обязательно — пустое тело
        `{}` даёт 400 `invalid_request`. Поле `monthly_limit_credits` —
        трёхсостояние: absent — не менять, null — снять лимит, decimal-строка
        строго > 0 — установить; снятие/снижение лимита не блокируется текущим
        расходом месяца. Разрешено и для отозванных ключей (метка для истории).
        Чужой и несуществующий ключ — одинаковый 404 `key_not_found`, без
        раскрытия.
      security:
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Идентификатор личного ключа
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchPersonalKeyRequest'
      responses:
        '200':
          description: Обновлённый ключ (без `api_key` — plaintext не хранится)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonalKey'
        '400':
          description: Пустое тело, невалидное имя или лимит (invalid_request)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '403':
          description: Email аккаунта не подтверждён
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Email is not verified
                  type: authentication_error
                  code: email_not_verified
        '404':
          description: Ключ не найден (чужой и несуществующий — одинаково)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Key not found
                  type: invalid_request_error
                  code: key_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [keys]
      operationId: revokePersonalKey
      summary: Отозвать личный ключ
      description: |
        Идемпотентно: повторный отзыв своего ключа — 200 `{status:"ok"}`.
        Чужой/несуществующий ключ — 404 `key_not_found` (одинаково, без
        раскрытия). Отзыв последнего активного ключа разрешён: новый ключ
        выпускается из кабинета без существующего ключа. Отозванный ключ
        остаётся валидным на hot path до истечения TTL key-кэша шлюза.
      security:
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Идентификатор личного ключа
          schema:
            type: integer
      responses:
        '200':
          description: Ключ отозван (повторный отзыв — тоже 200)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthStatusOk'
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '403':
          description: Email аккаунта не подтверждён
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Email is not verified
                  type: authentication_error
                  code: email_not_verified
        '404':
          description: Ключ не найден (чужой и несуществующий — одинаково)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Key not found
                  type: invalid_request_error
                  code: key_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/notifications/settings:
    get:
      tags: [notifications]
      operationId: getNotificationSettings
      summary: Получить настройки уведомлений аккаунта
      description: |
        Session-only контур: только session-cookie `farvater_session`, Bearer-ключ
        не принимается; подтверждение email (RequireVerified) не требуется.
        Аккаунту без сохранённой строки в БД возвращаются кодовые дефолты
        (канал e-mail включён, все 7 типов событий включены, порог баланса
        200.00 ₽, процентные пороги 80) — строка материализуется лениво
        при первом PATCH.
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Эффективные настройки (дефолты — если ещё не сохранялись)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationSettings'
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    patch:
      tags: [notifications]
      operationId: updateNotificationSettings
      summary: Обновить настройки уведомлений (per-event merge)
      description: |
        Все поля опциональны; применяются только присутствующие ключи
        (per-event merge). Ответ — полные эффективные настройки (форма GET).
        Валидация (нарушение — 400 `invalid_notification_settings`, do-not-retry):
        `threshold_rub` — decimal-строка строго > 0, до 2 знаков после точки
        (только у `balance_below_threshold`; нормализация к 2 знакам на шлюзе);
        `percent` — целое 1..99 (только у трёх percent-событий); параметры
        у `monthly_cap_100pct`/`renewal_reminder`, неизвестные ключи и типы
        событий — тоже 400. Битый JSON — 400 `invalid_request`.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationSettingsPatch'
      responses:
        '200':
          description: Полные эффективные настройки после merge
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationSettings'
        '400':
          description: Битый JSON (invalid_request) или невалидные значения (invalid_notification_settings — do-not-retry)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: percent must be between 1 and 99
                  type: invalid_request_error
                  code: invalid_notification_settings
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '429':
          description: Превышен лимит запросов; заголовок Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Too many requests
                  type: rate_limit_error
                  code: rate_limited
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/chat/completions:
    post:
      tags: [chat]
      operationId: createChatCompletion
      summary: Создать chat completion
      description: |
        OpenAI-форма запроса. Поддерживаемые поля: `model`, `messages`, `stream`,
        `stream_options.include_usage`, `max_tokens`, `temperature`, `top_p`, `stop`,
        `tools`, `tool_choice`, `response_format`, а также OpenRouter-расширения
        `models` (упорядоченный список фолбэков) и `provider` — они проксируются
        в апстрим без изменений. Неизвестные поля проксируются как есть: шлюз
        сознательно не валидирует схему тела. Лимит тела — 4 МБ.

        Стриминг: при `stream: true` ответ — `text/event-stream`, чанки `data: …`
        завершаются `data: [DONE]`; при `stream_options.include_usage: true` перед
        `[DONE]` приходит финальный usage-чанк.

        Failover: retry шлюза (до 2 попыток, backoff 200 мс → 800 мс) при
        transport-ошибках и HTTP 429/502/503/504 возможен только до отправки клиенту
        первого байта (pre-stream). Mid-stream обрыв — без ретрая никогда.
        Межпровайдерный failover делегирован OpenRouter (поля `models`/`provider`).

        Vision-вход (редакция 1.1.0): `messages[].content` — строка либо массив частей
        `{"type":"text"}` / `{"type":"image_url"}`. Источник изображения — https-URL или
        data-URI `data:image/{png,jpeg,webp,gif};base64,…`; fetch https — timeout ≤ 5 с
        и размер ≤ FARVATER_VISION_FETCH_MAX_BYTES (default 8 MiB), data-URI — тот же
        лимит байтов; нарушение — 400 invalid_request (тело не проксируется).
        Тарификация — input-токены по tiles: tiles = ceil(w/512) × ceil(h/512),
        image_tokens = 85 + 170 × tiles; добавляются к hold-оценке запроса.
        Settle — токенами (usage.prompt_tokens апстрима / 1M), при обрыве — floor
        по hold-оценке.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChatCompletionRequest'
      responses:
        '200':
          description: |
            Успешный ответ. Non-stream — тело OpenAI-формы (`application/json`).
            Stream — SSE-поток чанков (`text/event-stream`).
          headers:
            # Оконные заголовки — присутствуют только на подписочном пути
            # биллинга (Ruslo-Usage-Path: subscription), emit pre-stream.
            Ruslo-Window-Short-Limit:
              $ref: '#/components/headers/Ruslo-Window-Short-Limit'
            Ruslo-Window-Short-Remaining:
              $ref: '#/components/headers/Ruslo-Window-Short-Remaining'
            Ruslo-Window-Short-Reset:
              $ref: '#/components/headers/Ruslo-Window-Short-Reset'
            Ruslo-Window-Month-Limit:
              $ref: '#/components/headers/Ruslo-Window-Month-Limit'
            Ruslo-Window-Month-Remaining:
              $ref: '#/components/headers/Ruslo-Window-Month-Remaining'
            Ruslo-Window-Month-Reset:
              $ref: '#/components/headers/Ruslo-Window-Month-Reset'
            X-Farvater-Window-Short-Limit:
              $ref: '#/components/headers/X-Farvater-Window-Short-Limit'
            X-Farvater-Window-Short-Remaining:
              $ref: '#/components/headers/X-Farvater-Window-Short-Remaining'
            X-Farvater-Window-Short-Reset:
              $ref: '#/components/headers/X-Farvater-Window-Short-Reset'
            X-Farvater-Window-Month-Limit:
              $ref: '#/components/headers/X-Farvater-Window-Month-Limit'
            X-Farvater-Window-Month-Remaining:
              $ref: '#/components/headers/X-Farvater-Window-Month-Remaining'
            X-Farvater-Window-Month-Reset:
              $ref: '#/components/headers/X-Farvater-Window-Month-Reset'
            Ruslo-Usage-Cost:
              $ref: '#/components/headers/Ruslo-Usage-Cost'
            Ruslo-Usage-Path:
              $ref: '#/components/headers/Ruslo-Usage-Path'
            X-Farvater-Usage-Path:
              $ref: '#/components/headers/X-Farvater-Usage-Path'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChatCompletionResponse'
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE-поток: `data: {<chat.completion.chunk>}\n\n` … `data: [DONE]\n\n`.
                  При `stream_options.include_usage: true` финальный чанк перед `[DONE]`
                  несёт объект `usage`.
        '400':
          description: |
            Невалидный запрос (нет model/messages, тело не JSON) либо нарушение
            vision-входа: невалидный источник image_url (scheme не https, MIME вне
            allowlist png/jpeg/webp/gif, превышен лимит байтов) — тело не проксируется
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid request body
                  type: invalid_request_error
                  code: invalid_request
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missingApiKey:
                  summary: Нет заголовка Authorization
                  value:
                    error:
                      message: Missing API key
                      type: authentication_error
                      code: missing_api_key
                invalidApiKey:
                  summary: Ключ не найден или отозван
                  value:
                    error:
                      message: Invalid API key
                      type: authentication_error
                      code: invalid_api_key
        '402':
          description: Недостаточно средств для резерва под запрос
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient funds
                  type: billing_error
                  code: insufficient_funds
        '404':
          description: Модель не найдена в каталоге Фарватера
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Model 'gpt-99' not found in catalog
                  type: invalid_request_error
                  code: model_not_found
        '413':
          description: Тело запроса больше 4 МБ
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Request body too large
                  type: invalid_request_error
                  code: request_too_large
        '429':
          description: |
            Апстрим перегружен (`upstream_rate_limited`; Retry-After проксируется
            от апстрима) либо исчерпан месячный лимит расхода API-ключа
            (`key_spending_limit_exceeded`; Retry-After — секунды до 1-го числа
            следующего UTC-месяца) либо исчерпано окно подписки
            (`subscription_window_exhausted`; envelope аддитивно содержит `window`,
            `retry_after`, `resets_at` и `other_window` — состояние второго окна).
            Для `key_spending_limit_exceeded` envelope
            аддитивно содержит `retry_after` (секунды до сброса) и `resets_at`
            (момент сброса, RFC3339), message — лимит и расход месяца. Семантика
            ретрая `key_spending_limit_exceeded`: do-not-retry до сброса месяца
            или повышения лимита; отказ чист (ни hold-строки, ни ledger-записи),
            остальные ключи аккаунта/организации не затронуты.
          headers:
            Retry-After:
              description: Через сколько секунд повторять (от апстрима либо до сброса месячного лимита ключа)
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                upstreamRateLimited:
                  summary: Апстрим перегружен
                  value:
                    error:
                      message: Upstream rate limited
                      type: rate_limit_error
                      code: upstream_rate_limited
                keySpendingLimitExceeded:
                  summary: Исчерпан месячный лимит расхода ключа
                  value:
                    error:
                      message: >-
                        monthly spending limit exceeded: limit 5000 credits,
                        spent 5123.456789 credits in 2026-10 (UTC); the limit
                        resets at 2026-11-01T00:00:00Z or can be raised in the
                        key settings
                      type: rate_limit_error
                      code: key_spending_limit_exceeded
                      retry_after: 1234567
                      resets_at: '2026-11-01T00:00:00Z'
                subscriptionWindowExhausted:
                  summary: Исчерпано окно подписки
                  value:
                    error:
                      message: >-
                        subscription window exhausted: month window limit
                        1000000 tokens reached; the window resets at
                        2026-11-01T00:00:00Z
                      type: rate_limit_error
                      code: subscription_window_exhausted
                      window: month
                      retry_after: 1234567
                      resets_at: '2026-11-01T00:00:00Z'
                      other_window:
                        window: short
                        remaining_tokens: 75000
                        limit_tokens: 100000
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Internal error
                  type: server_error
                  code: internal_error
        '502':
          description: Апстрим вернул ошибку после исчерпания попыток — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Upstream error
                  type: server_error
                  code: upstream_error
        '503':
          description: У модели ещё не вычислена рублёвая цена — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Pricing unavailable
                  type: server_error
                  code: pricing_unavailable
        '504':
          description: Таймаут апстрима — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Upstream timeout
                  type: server_error
                  code: upstream_timeout

  /v1/messages:
    post:
      tags: [messages]
      operationId: createMessage
      summary: Создать message (формат Anthropic Messages)
      description: |
        Запрос в форме Anthropic Messages. Обязательные поля: `model`,
        `max_tokens`, `messages`. `system` — top-level поле (НЕ элемент
        `messages`); проксируется в апстрим без изменений, как и блоки
        `content`, `tools`, `thinking` и любые неизвестные поля тела (шлюз
        не валидирует схему тела сверх обязательных полей). Лимит тела — 4 МБ.

        Обязательный заголовок `anthropic-version`: синтаксис
        `^\d{4}-\d{2}-\d{2}$`, whitelist версий нет (форвард-совместимость с
        дефолтами SDK); отсутствие или несоответствие синтаксису — `400`
        с `error.type = invalid_request_error` в Anthropic-конверте.
        Аутентификация — dual-accept: `x-api-key` либо `Authorization: Bearer`
        тем же ключом Фарватера; при подаче обоих заголовков приоритет у
        `x-api-key`. Заголовок `anthropic-beta` проксируется без валидации.

        Стриминг: при `stream: true` ответ — `text/event-stream` с официальным
        порядком событий Anthropic (см. description медиа-типа);
        терминация — закрытием потока после `message_stop` (`data: [DONE]` не
        существует). Ошибка до первого байта — HTTP JSON-конверт Anthropic;
        обрыв апстрима mid-stream — клиенту досылается `event: error` с
        `overloaded_error` (HTTP 529) и поток закрывается; client-abort — без
        error-события.

        Failover: retry шлюза (до 2 попыток, backoff 200 мс → 800 мс) при
        transport-ошибках и HTTP 429/502/503/504 возможен только до отправки
        клиенту первого байта (pre-stream). Mid-stream обрыв — без ретрая
        никогда.

        Тело успешного ответа проксируется от нативного Anthropic-эндпоинта
        апстрима verbatim. Ошибки Anthropic-семейства возвращаются в конверте
        `{"type":"error","error":{"type","message"}}` без машиночитаемого
        `code` (в отличие от OpenAI-семейства); тела ошибок апстрима на этой
        семье ремaппятся в конверт шлюза, verbatim не проксируются.
      parameters:
        - name: anthropic-version
          in: header
          required: true
          description: |
            Версия wire-формата Anthropic: синтаксис `^\d{4}-\d{2}-\d{2}$`,
            whitelist версий нет. Отсутствие или несоответствие синтаксису —
            `400` с `error.type = invalid_request_error`.
          schema:
            type: string
            pattern: ^\d{4}-\d{2}-\d{2}$
            example: '2023-06-01'
        - name: anthropic-beta
          in: header
          required: false
          description: Бета-флаги Anthropic; проксируются в апстрим без валидации
          schema:
            type: string
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessagesRequest'
      responses:
        '200':
          description: |
            Успешный ответ. Non-stream — тело Anthropic Messages-формы
            (`application/json`). Stream — SSE-поток событий
            (`text/event-stream`).
          headers:
            Ruslo-Window-Short-Limit:
              $ref: '#/components/headers/Ruslo-Window-Short-Limit'
            Ruslo-Window-Short-Remaining:
              $ref: '#/components/headers/Ruslo-Window-Short-Remaining'
            Ruslo-Window-Short-Reset:
              $ref: '#/components/headers/Ruslo-Window-Short-Reset'
            Ruslo-Window-Month-Limit:
              $ref: '#/components/headers/Ruslo-Window-Month-Limit'
            Ruslo-Window-Month-Remaining:
              $ref: '#/components/headers/Ruslo-Window-Month-Remaining'
            Ruslo-Window-Month-Reset:
              $ref: '#/components/headers/Ruslo-Window-Month-Reset'
            X-Farvater-Window-Short-Limit:
              $ref: '#/components/headers/X-Farvater-Window-Short-Limit'
            X-Farvater-Window-Short-Remaining:
              $ref: '#/components/headers/X-Farvater-Window-Short-Remaining'
            X-Farvater-Window-Short-Reset:
              $ref: '#/components/headers/X-Farvater-Window-Short-Reset'
            X-Farvater-Window-Month-Limit:
              $ref: '#/components/headers/X-Farvater-Window-Month-Limit'
            X-Farvater-Window-Month-Remaining:
              $ref: '#/components/headers/X-Farvater-Window-Month-Remaining'
            X-Farvater-Window-Month-Reset:
              $ref: '#/components/headers/X-Farvater-Window-Month-Reset'
            Ruslo-Usage-Cost:
              $ref: '#/components/headers/Ruslo-Usage-Cost'
            Ruslo-Usage-Path:
              $ref: '#/components/headers/Ruslo-Usage-Path'
            X-Farvater-Usage-Path:
              $ref: '#/components/headers/X-Farvater-Usage-Path'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessagesResponse'
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE-поток событий Anthropic в официальном порядке:
                  `message_start` → `content_block_start` →
                  `content_block_delta`* → `content_block_stop` → (повтор
                  `content_block_start` … `content_block_stop` на каждый
                  следующий блок) → `message_delta` → `message_stop`.
                  Сервисное событие `ping` допустимо в любой момент потока.
                  Терминация — закрытием потока после `message_stop`
                  (`data: [DONE]` НЕ существует). Usage для биллинга:
                  `input_tokens` из `message_start.usage`, `output_tokens` из
                  `message_delta.usage`. Обрыв апстрима mid-stream — клиенту
                  досылается `event: error` с конвертом `overloaded_error`
                  (HTTP 529), после чего поток закрывается; при client-abort
                  error-событие не досылается.
        '400':
          description: |
            Невалидный запрос: отсутствие или несоответствие синтаксису
            `anthropic-version`, отсутствие `model`/`max_tokens`/`messages`,
            тело не JSON, либо тело больше 4 МБ (`request_too_large`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicError'
              examples:
                invalidRequest:
                  summary: Нет обязательного поля или заголовка anthropic-version
                  value:
                    type: error
                    error:
                      type: invalid_request_error
                      message: max_tokens is required
                requestTooLarge:
                  summary: Тело запроса больше 4 МБ
                  value:
                    type: error
                    error:
                      type: invalid_request_error
                      message: Request body too large
        '401':
          description: |
            Нет ни `x-api-key`, ни `Authorization: Bearer`, либо ключ не найден/
            отозван/вне синтаксического гейта префиксов
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicError'
              examples:
                missingApiKey:
                  summary: Ключ не подан ни в одном из двух заголовков
                  value:
                    type: error
                    error:
                      type: authentication_error
                      message: Missing API key
                invalidApiKey:
                  summary: Ключ не найден, отозван или префикс вне гейта
                  value:
                    type: error
                    error:
                      type: authentication_error
                      message: Invalid API key
        '402':
          description: Недостаточно средств для резерва под запрос
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicError'
              example:
                type: error
                error:
                  type: billing_error
                  message: Insufficient funds
        '404':
          description: Модель не найдена в каталоге Фарватера
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicError'
              example:
                type: error
                error:
                  type: model_not_found
                  message: Model 'gpt-99' not found in catalog
        '429':
          description: |
            Апстрим перегружен (`upstream_rate_limited`; Retry-After
            проксируется от апстрима), превышен лимит запросов
            (`rate_limited`), исчерпан месячный лимит расхода API-ключа
            (`key_spending_limit_exceeded`; Retry-After — секунды до 1-го
            числа следующего UTC-месяца) либо исчерпано окно подписки
            (`subscription_window_exhausted`).
          headers:
            Retry-After:
              description: Через сколько секунд повторять (от апстрима либо до сброса лимита)
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicError'
              examples:
                upstreamRateLimited:
                  summary: Апстрим перегружен
                  value:
                    type: error
                    error:
                      type: rate_limit_error
                      message: Upstream rate limited
                keySpendingLimitExceeded:
                  summary: Исчерпан месячный лимит расхода ключа
                  value:
                    type: error
                    error:
                      type: rate_limit_error
                      message: >-
                        monthly spending limit exceeded: limit 5000 credits,
                        spent 5123.456789 credits in 2026-10 (UTC); the limit
                        resets at 2026-11-01T00:00:00Z or can be raised in the
                        key settings
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicError'
              example:
                type: error
                error:
                  type: api_error
                  message: Internal error
        '529':
          description: |
            Апстрим недоступен или перегружен (`upstream_error`,
            `upstream_timeout`), цена модели ещё не вычислена
            (`pricing_unavailable`) либо авария лимитера
            (`limiter_unavailable`) — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicError'
              example:
                type: error
                error:
                  type: overloaded_error
                  message: Upstream error

  /v1/models:
    get:
      tags: [models]
      operationId: listModels
      summary: Список доступных моделей
      description: |
        Каталог моделей в форме OpenAI list. Поле `pricing` — цены апстрима в USD
        за 1K токенов (строки, форма OpenRouter). Поле `pricing_rub` — цены Фарватера
        в ₽ за 1M токенов (строки); отсутствует, пока рублёвая цена не вычислена.
        Публичный эндпоинт: аутентификация не требуется, API-ключ не нужен.
        Поле `pricing_rub` опционально и может отсутствовать у отдельных моделей —
        клиенты должны обрабатывать такие записи (например, скрывать их или показывать
        без цены), а не считать ответ ошибочным.

        С редакции 1.1.0 каждый элемент `data` аддитивно содержит ключ `card` —
        карточку модели (модальности, единицы тарификации, unit-цены ₽, лимиты);
        базовые OpenAI-поля неизменны. `card` байт-в-байт равна элементу `data`
        снапшота GET /models.json и price-snapshot биллинг-сетлмента.
      security: []
      responses:
        '200':
          description: Каталог моделей
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelsList'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/files:
    post:
      tags: [files]
      operationId: uploadBatchFile
      summary: Загрузить JSONL-файл входных данных batch-задачи
      description: |
        multipart/form-data: поле `file` — байты JSONL-файла (строки входа
        batch-задачи OpenAI-семейства), поле `purpose` — единственное значение
        `batch` (иное → 400 `invalid_request`). Ограничение размера —
        100 MiB: превышение → 413 `request_too_large`. Файл хранится в
        PostgreSQL шлюза и доступен только аккаунту, загрузившему его.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/FileUploadForm'
      responses:
        '200':
          description: Файл принят
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileObject'
        '400':
          description: Нет полей file/purpose или purpose ≠ batch (invalid_request)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Unsupported purpose
                  type: invalid_request_error
                  code: invalid_request
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: Файл больше 100 MiB (request_too_large)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Request too large
                  type: invalid_request_error
                  code: request_too_large
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/files/{fileId}:
    get:
      tags: [files]
      operationId: getBatchFile
      summary: Метаданные batch-файла
      description: |
        Метаданные входного или сгенерированного (результаты/ошибки) файла
        аккаунта. Чужой и несуществующий id — одинаковый 404 `file_not_found`,
        без раскрытия.
      security:
        - bearerAuth: []
      parameters:
        - name: fileId
          in: path
          required: true
          description: Идентификатор файла (`file_<hex>`)
          schema:
            type: string
      responses:
        '200':
          description: Метаданные файла
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileObject'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Файл не найден (чужой и несуществующий — одинаково)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: File not found
                  type: invalid_request_error
                  code: file_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/files/{fileId}/content:
    get:
      tags: [files]
      operationId: downloadBatchFileContent
      summary: Сырые байты batch-файла (JSONL)
      description: |
        Отдаёт байты файла как есть, Content-Type `application/x-ndjson`.
        Схема строк зависит от роли файла: вход — `BatchInputLineOpenAI`,
        сгенерированные результаты — `BatchOutputLineOpenAI`, сгенерированные
        ошибки — `BatchErrorLineOpenAI`.
      security:
        - bearerAuth: []
      parameters:
        - name: fileId
          in: path
          required: true
          description: Идентификатор файла (`file_<hex>`)
          schema:
            type: string
      responses:
        '200':
          description: Байты файла (JSONL)
          content:
            application/x-ndjson:
              schema:
                type: string
                format: binary
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Файл не найден (чужой и несуществующий — одинаково)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: File not found
                  type: invalid_request_error
                  code: file_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/batches:
    post:
      tags: [batches]
      operationId: createBatch
      summary: Создать batch-задачу (OpenAI-семейство)
      description: |
        Задача по JSONL-файлу, загруженному через POST /v1/files (purpose=batch).
        File-level нарушения (не JSONL, более 50 000 строк) → 400
        `invalid_request` без создания задачи. Построчная валидация
        асинхронна: задача создаётся со статусом `validating`; бракованные
        строки становятся errored — не исполняются, не тарифицируются и
        попадают в error-файл; 0 валидных строк → job `failed`.

        Биллинг: при постановке резервируется hold суммы оценок всех валидных
        строк по batch-цене (−50% к интерактивной); недостаток баланса → 402
        `insufficient_funds` без создания задачи; ключ BYOK/подписочный → 400
        `batch_requires_credit_key`. Принимаются только `endpoint`
        `/v1/chat/completions` и `completion_window` `24h`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBatchRequest'
      responses:
        '200':
          description: Задача создана и принята на валидацию (status `validating`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobOpenAI'
        '400':
          description: |
            Невалидные endpoint/completion_window/файл (invalid_request) или
            ключ без кредитного учёта (batch_requires_credit_key)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Batch requires a credit-billed key
                  type: invalid_request_error
                  code: batch_requires_credit_key
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Недостаток баланса под полный резерв (insufficient_funds) — задача не создана
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient funds
                  type: billing_error
                  code: insufficient_funds
        '404':
          description: Входной файл не найден (file_not_found); job-level неизвестная модель (model_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: File not found
                  type: invalid_request_error
                  code: file_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    get:
      tags: [batches]
      operationId: listBatches
      summary: Список batch-задач аккаунта (OpenAI-семейство)
      description: |
        Задачи аккаунта principal (account-scoped, не по ключу), новые первыми.
        Keyset-пагинация: `after` — курсор предыдущей страницы; `next_cursor`
        ответа null на последней странице.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Размер страницы (1..100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: after
          in: query
          description: Курсор предыдущей страницы (keyset)
          schema:
            type: string
      responses:
        '200':
          description: Страница задач
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchListOpenAI'
        '400':
          description: Невалидные limit/after (invalid_request)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/batches/{batchId}:
    get:
      tags: [batches]
      operationId: getBatch
      summary: Batch-задача по идентификатору (OpenAI-семейство)
      description: |
        Объект задачи аккаунта. Чужая и несуществующая — одинаковый 404
        `batch_not_found`, без раскрытия.
      security:
        - bearerAuth: []
      parameters:
        - name: batchId
          in: path
          required: true
          description: Идентификатор задачи (`batch_<hex>`)
          schema:
            type: string
      responses:
        '200':
          description: Объект задачи
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobOpenAI'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Задача не найдена (чужая и несуществующая — одинаково)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Batch not found
                  type: invalid_request_error
                  code: batch_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [batches]
      operationId: deleteBatch
      summary: Удалить завершённую задачу и сгенерированные файлы
      description: |
        Только из терминальных состояний (completed/failed/expired/cancelled),
        иначе 409 `batch_state_conflict`. Удаляет задачу и СГЕНЕРИРОВАННЫЕ
        output/error-файлы; входной файл и ledger-записи остаются
        (append-only).
      security:
        - bearerAuth: []
      parameters:
        - name: batchId
          in: path
          required: true
          description: Идентификатор задачи (`batch_<hex>`)
          schema:
            type: string
      responses:
        '200':
          description: Задача удалена
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchDeleteResponse'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Задача не найдена (batch_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Batch not found
                  type: invalid_request_error
                  code: batch_not_found
        '409':
          description: Задача не в терминальном состоянии (batch_state_conflict)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Batch is not in a terminal state
                  type: invalid_request_error
                  code: batch_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/batches/{batchId}/cancel:
    post:
      tags: [batches]
      operationId: cancelBatch
      summary: Отменить batch-задачу (OpenAI-семейство)
      description: |
        Из состояний validating/queued/in_progress переводит задачу в
        `cancelling`; из терминальных — 409 `batch_state_conflict`. Когда
        running-строк не остаётся → `cancelled`, остаток резерва возвращён на
        баланс.
      security:
        - bearerAuth: []
      parameters:
        - name: batchId
          in: path
          required: true
          description: Идентификатор задачи (`batch_<hex>`)
          schema:
            type: string
      responses:
        '200':
          description: Задача в состоянии cancelling/cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobOpenAI'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Задача не найдена (batch_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Batch not found
                  type: invalid_request_error
                  code: batch_not_found
        '409':
          description: Задача в терминальном состоянии (batch_state_conflict)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Batch is already finished
                  type: invalid_request_error
                  code: batch_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/messages/batches:
    post:
      tags: [batches]
      operationId: createMessageBatch
      summary: Создать batch-задачу (Anthropic-семейство)
      description: |
        Запросы передаются inline-массивом в теле (до 100 000; сериализованный
        payload > 100 MiB → 400 `invalid_request_error`). Каждый запрос
        нормализуется к OpenAI-форме chat completions (system → system-сообщение,
        текстовые блоки content → content-партии) и исполняется через общий
        upstream; stream не включается никогда.

        Биллинг и ограничения ключа — как у OpenAI-семейства: резерв оценки всех
        валидных запросов по batch-цене (−50%), 402 `billing_error` при
        недостатке баланса, 400 `batch_requires_credit_key` для BYOK/подписки.

        Аутентификация — dual-accept: `Authorization: Bearer fv-sk-…` ИЛИ
        заголовок `x-api-key` с тем же ключом. Заголовок `anthropic-version`
        обязателен (pass-through хранения).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/XApiKeyHeader'
        - $ref: '#/components/parameters/AnthropicVersionHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMessageBatchRequest'
      responses:
        '200':
          description: Задача создана и принята на валидацию (processing_status `in_progress`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobAnthropic'
        '400':
          description: Payload-нарушения (invalid_request_error) или ключ без кредитного учёта (batch_requires_credit_key → invalid_request_error)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
              example:
                type: error
                error:
                  type: invalid_request_error
                  message: Batch requires a credit-billed key
        '401':
          description: Нет/неверный ключ (Authorization или x-api-key) — authentication_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
              example:
                type: error
                error:
                  type: authentication_error
                  message: Invalid API key
        '402':
          description: Недостаток баланса под полный резерв — billing_error (задача не создана)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
              example:
                type: error
                error:
                  type: billing_error
                  message: Insufficient funds for batch reserve
        '404':
          description: Job-level неизвестная модель (model_not_found → invalid_request_error)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
              example:
                type: error
                error:
                  type: invalid_request_error
                  message: Model not found
        '500':
          description: Внутренняя ошибка шлюза — api_error, ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
              example:
                type: error
                error:
                  type: api_error
                  message: Internal error
    get:
      tags: [batches]
      operationId: listMessageBatches
      summary: Список batch-задач аккаунта (Anthropic-семейство)
      description: |
        Задачи аккаунта principal, новые первыми. Keyset-пагинация: `limit`
        (default 20, max 100) и `after` (курсор); `next_cursor` ответа null на
        последней странице.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/XApiKeyHeader'
        - $ref: '#/components/parameters/AnthropicVersionHeader'
        - name: limit
          in: query
          description: Размер страницы (1..100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: after
          in: query
          description: Курсор предыдущей страницы (keyset)
          schema:
            type: string
      responses:
        '200':
          description: Страница задач
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchListAnthropic'
        '400':
          description: Невалидные limit/after — invalid_request_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
        '401':
          description: Нет/неверный ключ (Authorization или x-api-key) — authentication_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
        '500':
          description: Внутренняя ошибка шлюза — api_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'

  /v1/messages/batches/{batchId}:
    get:
      tags: [batches]
      operationId: getMessageBatch
      summary: Batch-задача по идентификатору (Anthropic-семейство)
      description: |
        Объект задачи аккаунта. Чужая и несуществующая — одинаковый 404
        (batch_not_found → invalid_request_error).
      security:
        - bearerAuth: []
      parameters:
        - name: batchId
          in: path
          required: true
          description: Идентификатор задачи (`batch_<hex>`)
          schema:
            type: string
        - $ref: '#/components/parameters/XApiKeyHeader'
        - $ref: '#/components/parameters/AnthropicVersionHeader'
      responses:
        '200':
          description: Объект задачи
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobAnthropic'
        '401':
          description: Нет/неверный ключ (Authorization или x-api-key) — authentication_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
        '404':
          description: Задача не найдена (batch_not_found → invalid_request_error)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
              example:
                type: error
                error:
                  type: invalid_request_error
                  message: Batch not found
        '500':
          description: Внутренняя ошибка шлюза — api_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'

  /v1/messages/batches/{batchId}/cancel:
    post:
      tags: [batches]
      operationId: cancelMessageBatch
      summary: Отменить batch-задачу (Anthropic-семейство)
      description: |
        Переводит задачу в `canceling`, по завершении отмены — `canceled`
        (остаток резерва возвращён); из терминальных состояний — 409
        (batch_state_conflict → invalid_request_error).
      security:
        - bearerAuth: []
      parameters:
        - name: batchId
          in: path
          required: true
          description: Идентификатор задачи (`batch_<hex>`)
          schema:
            type: string
        - $ref: '#/components/parameters/XApiKeyHeader'
        - $ref: '#/components/parameters/AnthropicVersionHeader'
      responses:
        '200':
          description: Задача в состоянии canceling/canceled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchJobAnthropic'
        '401':
          description: Нет/неверный ключ (Authorization или x-api-key) — authentication_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
        '404':
          description: Задача не найдена (batch_not_found → invalid_request_error)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
        '409':
          description: Задача в терминальном состоянии (batch_state_conflict → invalid_request_error)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
              example:
                type: error
                error:
                  type: invalid_request_error
                  message: Batch is already finished
        '500':
          description: Внутренняя ошибка шлюза — api_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'

  /v1/messages/batches/{batchId}/results:
    get:
      tags: [batches]
      operationId: getMessageBatchResults
      summary: JSONL-поток результатов строк (Anthropic-семейство)
      description: |
        Поток (Content-Type `application/x-ndjson`) строк с терминальным исходом
        в порядке idx: succeeded / errored / canceled / expired. Доступен и до
        ended задачи — частичный поток завершённых строк. Построчные ошибки
        исполнения не тарифицируются.
      security:
        - bearerAuth: []
      parameters:
        - name: batchId
          in: path
          required: true
          description: Идентификатор задачи (`batch_<hex>`)
          schema:
            type: string
        - $ref: '#/components/parameters/XApiKeyHeader'
        - $ref: '#/components/parameters/AnthropicVersionHeader'
      responses:
        '200':
          description: JSONL-поток — каждый объект строка результата (BatchResultLineAnthropic)
          content:
            application/x-ndjson:
              schema:
                $ref: '#/components/schemas/BatchResultLineAnthropic'
        '401':
          description: Нет/неверный ключ (Authorization или x-api-key) — authentication_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
        '404':
          description: Задача не найдена (batch_not_found → invalid_request_error)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'
        '500':
          description: Внутренняя ошибка шлюза — api_error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnthropicErrorEnvelope'

  /models.json:
    get:
      tags: [models, multimodal]
      operationId: listModelsSnapshot
      summary: Машиночитаемый снапшот карточек каталога
      description: |
        Публичный эндпоинт (без аутентификации, как /v1/models): снапшот карточек
        каталога схемы D2 из того же in-memory индекса и тех же price-строк, что
        читает биллинг. Каждый элемент `data` байт-в-байт равен ключу `card`
        соответствующей модели GET /v1/models и price-snapshot биллинг-сетлмента
        (price_rub и счётчики единиц — вербатим, decimal-строки как есть).
        Множество значений modality ограничено шестью контурами этапа 1; каталог
        расширяем на новые модальности без миграции и без ломки контракта.
      security: []
      responses:
        '200':
          description: Снапшот карточек каталога
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelsSnapshot'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/images/generations:
    post:
      tags: [multimodal]
      operationId: createImageGeneration
      summary: Генерация изображений (OpenAI-форма)
      description: |
        Единица сетлмента — изображение (output_units=images карточки). Ценовой
        тиер по разрешению: если запрошенный `size` есть в `limits.image_tiers`
        карточки — цена тиера, иначе базовый тиер (минимальный ключ tiers); цены
        читаются из price-snapshot карточки (decimal-строки, ₽ за изображение).
        `units_output` — фактическое число изображений в ответе; hold-оценка —
        `n` × цена тиера. Контур опционален: не смонтирован у шлюза (Deps c
        env-гейтом) — путь отвечает 404.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ImagesGenerationsRequest'
      responses:
        '200':
          description: Сгенерированные изображения
          headers:
            Ruslo-Usage-Cost:
              $ref: '#/components/headers/Ruslo-Usage-Cost'
            Ruslo-Usage-Path:
              $ref: '#/components/headers/Ruslo-Usage-Path'
            X-Farvater-Usage-Path:
              $ref: '#/components/headers/X-Farvater-Usage-Path'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImagesGenerationsResponse'
        '400':
          description: Невалидный запрос (invalid_request) или модель не найдена в каталоге (model_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalidRequest:
                  summary: n вне диапазона 1..4
                  value:
                    error:
                      message: n must be between 1 and 4
                      type: invalid_request_error
                      code: invalid_request
                modelNotFound:
                  summary: Модель не найдена в каталоге
                  value:
                    error:
                      message: Model 'unknown/image' not found in catalog
                      type: invalid_request_error
                      code: model_not_found
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Недостаточно средств для резерва под запрос (код `insufficient_balance`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Контур не смонтирован у шлюза (опциональные Deps с env-гейтом)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Апстрим перегружен; Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Upstream rate limited
                  type: rate_limit_error
                  code: upstream_rate_limited
        '503':
          description: Upstream-контур недоступен (код `upstream_unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/audio/speech:
    post:
      tags: [multimodal]
      operationId: createAudioSpeech
      summary: Синтез речи (TTS, OpenAI-форма)
      description: |
        Единица сетлмента — `chars-1k`: символы считаются рунами входного текста,
        единица дробная (runes/1000). Hold-оценка точная (input известен до
        запроса), settle — факт по тому же runes/1000. Ответ — аудио-байты с
        Content-Type `audio/<response_format>`. Контур опционален: не смонтирован
        у шлюза (Deps c env-гейтом) — путь отвечает 404.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AudioSpeechRequest'
      responses:
        '200':
          description: Аудио-байты синтезированной речи
          headers:
            Ruslo-Usage-Cost:
              $ref: '#/components/headers/Ruslo-Usage-Cost'
            Ruslo-Usage-Path:
              $ref: '#/components/headers/Ruslo-Usage-Path'
            X-Farvater-Usage-Path:
              $ref: '#/components/headers/X-Farvater-Usage-Path'
          content:
            audio/*:
              schema:
                type: string
                format: binary
                description: |
                  Аудио-байты; конкретный Content-Type — audio/<response_format>
                  (mp3, opus, aac, flac, wav, pcm)
        '400':
          description: Невалидный запрос (invalid_request — пустой input, превышен FARVATER_TTS_MAX_RUNES) или модель не найдена (model_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalidRequest:
                  summary: Превышен лимит рун input
                  value:
                    error:
                      message: input exceeds max runes limit
                      type: invalid_request_error
                      code: invalid_request
                modelNotFound:
                  summary: Модель не найдена в каталоге
                  value:
                    error:
                      message: Model 'unknown/tts' not found in catalog
                      type: invalid_request_error
                      code: model_not_found
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Недостаточно средств для резерва под запрос (код `insufficient_balance`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Контур не смонтирован у шлюза (опциональные Deps с env-гейтом)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Апстрим перегружен; Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Upstream rate limited
                  type: rate_limit_error
                  code: upstream_rate_limited
        '503':
          description: Upstream-контур недоступен (код `upstream_unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/audio/transcriptions:
    post:
      tags: [multimodal]
      operationId: createAudioTranscription
      summary: Распознавание речи (ASR, OpenAI-форма, multipart)
      description: |
        Единица сетлмента — `minutes`: секунды аудио / 60, дробные. Источник секунд
        по приоритету: (1) usage.seconds апстрима; (2) duration-проба контейнера
        загруженного файла (wav/mp3); (3) hold-оценка с estimated=true.
        Hold-оценка до запроса — проба контейнера, при неудаче —
        FARVATER_ASR_HOLD_MINUTES_CAP (default 10 минут). Файл: MIME audio/*,
        размер ≤ FARVATER_ASR_MAX_BYTES (default 25 MiB). Контур опционален: не
        смонтирован у шлюза (Deps c env-гейтом) — путь отвечает 404.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/AudioTranscriptionRequest'
      responses:
        '200':
          description: Распознанный текст
          headers:
            Ruslo-Usage-Cost:
              $ref: '#/components/headers/Ruslo-Usage-Cost'
            Ruslo-Usage-Path:
              $ref: '#/components/headers/Ruslo-Usage-Path'
            X-Farvater-Usage-Path:
              $ref: '#/components/headers/X-Farvater-Usage-Path'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TranscriptionResponse'
        '400':
          description: Невалидный multipart (invalid_request — нет файла, превышен размер, MIME не audio/*) или модель не найдена (model_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missingFile:
                  summary: Нет аудиофайла в multipart
                  value:
                    error:
                      message: multipart form must contain an audio file
                      type: invalid_request_error
                      code: invalid_request
                modelNotFound:
                  summary: Модель не найдена в каталоге
                  value:
                    error:
                      message: Model 'unknown/asr' not found in catalog
                      type: invalid_request_error
                      code: model_not_found
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Недостаточно средств для резерва под запрос (код `insufficient_balance`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Контур не смонтирован у шлюза (опциональные Deps с env-гейтом)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Апстрим перегружен; Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Upstream rate limited
                  type: rate_limit_error
                  code: upstream_rate_limited
        '503':
          description: Upstream-контур недоступен (код `upstream_unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/embeddings:
    post:
      tags: [multimodal]
      operationId: createEmbeddings
      summary: Эмбеддинги (OpenAI-форма)
      description: |
        Единица сетлмента — `tokens-1m`: units_input = usage.prompt_tokens / 1M;
        hold-оценка — токенизатор по input (существующий паттерн PromptTokensEst).
        Контур опционален: не смонтирован у шлюза (Deps c env-гейтом) — путь
        отвечает 404.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmbeddingsRequest'
      responses:
        '200':
          description: Векторы эмбеддингов
          headers:
            Ruslo-Usage-Cost:
              $ref: '#/components/headers/Ruslo-Usage-Cost'
            Ruslo-Usage-Path:
              $ref: '#/components/headers/Ruslo-Usage-Path'
            X-Farvater-Usage-Path:
              $ref: '#/components/headers/X-Farvater-Usage-Path'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmbeddingsResponse'
        '400':
          description: Невалидный запрос (invalid_request) или модель не найдена в каталоге (model_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalidRequest:
                  summary: Пустой input
                  value:
                    error:
                      message: input must be a string or an array of strings
                      type: invalid_request_error
                      code: invalid_request
                modelNotFound:
                  summary: Модель не найдена в каталоге
                  value:
                    error:
                      message: Model 'unknown/embed' not found in catalog
                      type: invalid_request_error
                      code: model_not_found
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Недостаточно средств для резерва под запрос (код `insufficient_balance`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Контур не смонтирован у шлюза (опциональные Deps с env-гейтом)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Апстрим перегружен; Retry-After проксируется
          headers:
            Retry-After:
              description: Через сколько секунд повторять
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Upstream rate limited
                  type: rate_limit_error
                  code: upstream_rate_limited
        '503':
          description: Upstream-контур недоступен (код `upstream_unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/billing/balance:
    get:
      tags: [billing]
      operationId: getBillingBalance
      summary: Баланс аккаунта
      description: |
        Баланс аккаунта, к которому привязан ключ. Денежная модель:
        1 кредит = 1 копейка (`credits_per_rub = 100`); `available = balance − held`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Текущий баланс
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Balance'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/billing/transactions:
    get:
      tags: [billing]
      operationId: listBillingTransactions
      summary: История проводок аккаунта
      description: |
        Проводки (hold/settle/release/topup/adjustment), новые первыми (`id` DESC).
        Пагинация курсором: `cursor` — последний `id` предыдущей страницы (выборка
        строго меньше); поле `next_cursor` в ответе отсутствует на последней странице.
        Каждая settle-запись объясняет списание: модель, токены, цены на момент
        списания, итог. Settle-записи мультимодальных запросов аддитивно несут
        `modality`, `tier`, `units` и `price_rub` — вербатим из price-snapshot
        карточки каталога, защёлкнутого hold'ом запроса.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Размер страницы (1..200)
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          description: Последний id предыдущей страницы (выборка строго меньше)
          schema:
            type: integer
        - name: type
          in: query
          description: Фильтр по типу проводки
          schema:
            type: string
            enum: [hold, settle, release, topup, adjustment]
        - name: key_id
          in: query
          description: Фильтр по ключу аккаунта (раздельный учёт по ключам)
          schema:
            type: integer
      responses:
        '200':
          description: Страница проводок
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionsPage'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/billing/batches:
    get:
      tags: [billing]
      operationId: listBillingBatches
      summary: Batch-задачи аккаунта (cabinet-контракт, экран K6)
      description: |
        Cabinet-контракт данных batch-экрана: задачи аккаунта вызывающего ключа
        обоих семейств, новые первыми. Keyset-пагинация: `limit` (default 20,
        max 100) и `after` (курсор); `next_cursor` ответа null на последней
        странице. Статус — внутренний enum жизненного цикла (`queued` различим,
        в отличие от API-контуров); деньги — decimal-строки ₽ с двумя знаками.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Размер страницы (1..100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: after
          in: query
          description: Курсор предыдущей страницы (keyset)
          schema:
            type: string
      responses:
        '200':
          description: Страница batch-задач кабинета
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CabinetBatchList'
        '400':
          description: Невалидные limit/after (invalid_request)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/billing/batches/{batchId}:
    get:
      tags: [billing]
      operationId: getBillingBatch
      summary: Batch-задача по идентификатору (cabinet-контракт)
      description: |
        Детализация задачи кабинета: статус внутреннего enum, счётчики строк и
        пара «списано / было бы» decimal-строками. Чужая и несуществующая —
        одинаковый 404 `batch_not_found`.
      security:
        - bearerAuth: []
      parameters:
        - name: batchId
          in: path
          required: true
          description: Идентификатор задачи (`batch_<hex>`)
          schema:
            type: string
      responses:
        '200':
          description: Объект задачи кабинета
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CabinetBatch'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Задача не найдена (batch_not_found)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Batch not found
                  type: invalid_request_error
                  code: batch_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/payments/invoices:
    get:
      tags: [payments]
      operationId: listPaymentInvoices
      summary: Список выставленных счетов
      description: |
        Счета вызывающего ключа, новые первыми (`id` DESC). Пагинация курсором:
        `cursor` — последний `id` предыдущей страницы (выборка строго меньше);
        поле `next_cursor` в ответе отсутствует на последней странице. Каждый
        элемент включает реквизиты покупателя (`buyer`).

        Маршрут смонтирован только при сконфигурированном payments-модуле шлюза;
        иначе роутер отвечает 404.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Размер страницы (1..200)
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          description: Последний id предыдущей страницы (выборка строго меньше)
          schema:
            type: integer
        - name: status
          in: query
          description: Фильтр по статусу счёта; значение вне списка — 400 invalid_payment
          schema:
            type: string
            enum: [issued, paid, canceled]
      responses:
        '200':
          description: Страница счетов
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvoicesPage'
        '400':
          description: Невалидные limit/cursor/status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid status
                  type: invalid_request_error
                  code: invalid_payment
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/usage/series:
    get:
      tags: [usage]
      operationId: getUsageSeries
      summary: Агрегаты использования для графиков (ClickHouse)
      description: |
        Временные ряды использования из ClickHouse (`billing_events`): расход ₽,
        токены и запросы в разрезе бакета времени × модели × типа трафика.
        Скоуп данных — billing-аккаунт вызывающего ключа (все его ключи, как в
        `/v1/billing/transactions`); `key_id` сужает до одного ключа этого аккаунта.

        Источник — события `billing.settle` (трафик `payg`/`byok`: `spend_rub` —
        фактически списанные ₽, для BYOK это комиссия) и `subscription.usage`
        (трафик `subscription`: `spend_rub = 0`, справочный ₽-эквивалент —
        `rub_equivalent`, это НЕ списание). Списания pay-as-you-go по включённой
        fallback-опции помечены `subscription_fallback: true`.

        Маршрут монтируется только при наличии конфигурации ClickHouse на шлюзе
        (`FARVATER_CLICKHOUSE_*`); без неё роутер отвечает 404.
      security:
        - bearerAuth: []
      parameters:
        - name: from
          in: query
          description: |
            Начало диапазона (UTC): `YYYY-MM-DD` или RFC3339.
            По умолчанию — 14 дней назад; диапазон `from`..`to` ≤ 92 дней.
          schema:
            type: string
            example: '2026-09-14'
        - name: to
          in: query
          description: >-
            Конец диапазона (UTC): `YYYY-MM-DD` или RFC3339. По умолчанию — сейчас.
          schema:
            type: string
            example: '2026-09-28'
        - name: bucket
          in: query
          description: Шаг агрегации; `hour` допустим только при диапазоне ≤ 7 дней
          schema:
            type: string
            enum: [day, hour]
            default: day
        - name: traffic
          in: query
          description: Фильтр по типу трафика
          schema:
            type: string
            enum: [all, payg, subscription, byok]
            default: all
        - name: key_id
          in: query
          description: Сузить до одного ключа ЭТОГО аккаунта (чужой ключ — 400)
          schema:
            type: integer
        - name: model
          in: query
          description: Фильтр по id модели (точное совпадение)
          schema:
            type: string
            example: moonshotai/kimi-k2
      responses:
        '200':
          description: Ряды и итоги за диапазон (пустой диапазон — пустой `series`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageSeries'
        '400':
          description: |
            Невалидные параметры: `from`/`to`, `bucket` (`hour` при диапазоне > 7 дней),
            `traffic`, `key_id` не из аккаунта вызывающего, диапазон > 92 дней
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Range exceeds 92 days
                  type: invalid_request_error
                  code: invalid_request
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Ошибка запроса к ClickHouse — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Usage series temporarily unavailable
                  type: server_error
                  code: usage_series_unavailable

  /v1/orgs:
    post:
      tags: [team]
      operationId: createOrg
      summary: Создать организацию (bootstrap первого владельца)
      description: |
        Создаёт организацию из standalone-ключа: ключ получает `org_id` и роль
        `owner`. Если у ключа уже есть billing-аккаунт с балансом — организация
        принимает его, иначе создаётся новый в той же транзакции. Все ключи
        организации работают на общий баланс этого billing-аккаунта.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrgRequest'
      responses:
        '200':
          description: Организация создана; вызывающий ключ — владелец
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedOrg'
        '400':
          description: Невалидные поля (формат ИНН/КПП, пустые поля)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid buyer inn
                  type: invalid_request_error
                  code: invalid_request
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Ключ уже привязан к организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Key already belongs to an organization
                  type: invalid_request_error
                  code: org_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current:
    get:
      tags: [team]
      operationId: getCurrentOrg
      summary: Профиль организации вызывающего ключа
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Профиль организации и роль вызывающего ключа в ней
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgProfile'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Ключ standalone (не привязан к организации)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    patch:
      tags: [team]
      operationId: updateCurrentOrg
      summary: Обновить название/реквизиты организации (owner|admin)
      description: Поле `buyer` заменяется целиком; неуказанные поля не меняются.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOrgRequest'
      responses:
        '200':
          description: Обновлённый профиль организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgProfile'
        '400':
          description: Невалидные поля (формат ИНН/КПП)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию (нужна owner|admin)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/keys:
    get:
      tags: [team]
      operationId: listOrgKeys
      summary: Список ключей организации (owner|admin)
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Ключи организации (без значений — plaintext не хранится)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgKeysList'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags: [team]
      operationId: createOrgKey
      summary: Выпустить ключ организации
      description: |
        Owner создаёт ключи любых ролей, admin — только `member`. Новый ключ
        сразу привязан к общему billing-аккаунту организации.
        `monthly_limit_credits` — месячный лимит расхода ключа в кредитах
        (decimal-строка строго > 0; absent|null — без лимита). Лимит применяется
        на hot path: расход текущего календарного месяца (UTC) + резерв под
        запрос больше лимита — отказ 429 `key_spending_limit_exceeded`.
        Plaintext ключа возвращается один раз, только в этом ответе.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrgKeyRequest'
      responses:
        '200':
          description: Ключ выпущен; `api_key` показывается один раз
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedOrgKey'
        '400':
          description: Невалидные поля (имя, роль, лимит)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права (admin не может выпускать owner/admin)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/keys/{keyId}:
    patch:
      tags: [team]
      operationId: updateOrgKey
      summary: Изменить ключ организации (роль и/или месячный лимит расхода)
      description: |
        Хотя бы одно поле обязательно — пустое тело `{}` даёт 400
        `invalid_request`. Смена роли — только owner; PATCH только
        `monthly_limit_credits` — owner|admin. Поле `monthly_limit_credits` —
        трёхсостояние: absent — не менять, null — снять лимит, decimal-строка
        строго > 0 — установить; снятие/снижение лимита не блокируется текущим
        расходом месяца. Понижение последнего активного owner запрещено (409).
      security:
        - bearerAuth: []
      parameters:
        - name: keyId
          in: path
          required: true
          description: Идентификатор ключа организации
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchOrgKeyRequest'
      responses:
        '200':
          description: Обновлённый ключ (форма объектов GET /v1/orgs/current/keys)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgKey'
        '400':
          description: Пустое тело, невалидная роль или лимит
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Смена роли — только owner; изменение лимита — owner|admin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ не найден в этой организации (или вызывающий standalone)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '409':
          description: Нельзя понизить последнего активного владельца
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Last active owner cannot be demoted
                  type: invalid_request_error
                  code: org_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [team]
      operationId: revokeOrgKey
      summary: Отозвать ключ организации
      description: |
        Owner отзывает любой ключ (кроме последнего активного owner), admin —
        только member-ключи. Отзыв = `status: revoked`; действует в пределах
        TTL кэша ключей шлюза.
      security:
        - bearerAuth: []
      parameters:
        - name: keyId
          in: path
          required: true
          description: Идентификатор ключа организации
          schema:
            type: integer
      responses:
        '200':
          description: Ключ отозван
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    example: 12
                  status:
                    type: string
                    enum: [revoked]
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на отзыв этого ключа
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ не найден в этой организации (или вызывающий standalone)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '409':
          description: Нельзя отозвать последнего активного владельца
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Last active owner cannot be revoked
                  type: invalid_request_error
                  code: org_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/invites:
    get:
      tags: [team]
      operationId: listOrgInvites
      summary: Список pending-приглашений организации (owner|admin)
      description: Только активные (pending) приглашения, id ASC.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Pending-приглашения организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgInvitesList'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags: [team]
      operationId: createOrgInvite
      summary: Пригласить участника по e-mail (owner|admin)
      description: |
        E-mail нормализуется (trim+lower). Если на (org, email) уже есть
        pending-приглашение — РОТАЦИЯ: старое отзывается, выдаётся новое,
        письмо переотправляется (ответ тот же 200). На приглашённый e-mail
        уходит письмо со ссылкой `/team/invite?token=…` (TTL токена 72 часа).
        Лимит — 20 pending-приглашений на организацию
        (`invite_limit_exceeded`).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrgInviteRequest'
      responses:
        '200':
          description: Приглашение создано (или ротировано); письмо отправлено
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgInvite'
        '400':
          description: Невалидные поля (формат e-mail/роли) или лимит pending-приглашений
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Too many pending invites
                  type: invalid_request_error
                  code: invite_limit_exceeded
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '409':
          description: Аккаунт с этим e-mail уже член организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Account is already a member
                  type: invalid_request_error
                  code: org_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/invites/{inviteId}:
    delete:
      tags: [team]
      operationId: revokeOrgInvite
      summary: Отозвать pending-приглашение (owner|admin)
      description: |
        Отзыв переводит приглашение в `revoked`; ссылка из письма перестаёт
        работать. Чужое ИЛИ уже не-pending приглашение — одинаковый 404
        (uniform, анти-enumeration).
      security:
        - bearerAuth: []
      parameters:
        - name: inviteId
          in: path
          required: true
          description: Идентификатор приглашения
          schema:
            type: integer
      responses:
        '200':
          description: Приглашение отозвано
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    example: 5
                  status:
                    type: string
                    enum: [revoked]
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Приглашение не найдено в этой организации или уже не pending
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/members:
    get:
      tags: [team]
      operationId: listOrgMembers
      summary: Список участников организации (owner|admin)
      description: Участники (org_members — источник истины о членстве), id ASC.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Участники организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgMembersList'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/members/{memberId}:
    delete:
      tags: [team]
      operationId: removeOrgMember
      summary: Исключить участника из организации
      description: |
        Owner исключает любого участника, admin — только с ролью `member`.
        В одной транзакции с исключением отзываются все активные org-ключи
        аккаунта в этой организации (их id возвращаются в `revoked_key_ids`).
        Исключение последнего owner-участника запрещено (409). Сессии
        аккаунта исключённого не отзываются — доступ в организацию идёт
        через ключи.
      security:
        - bearerAuth: []
      parameters:
        - name: memberId
          in: path
          required: true
          description: Идентификатор участника (org_members.id)
          schema:
            type: integer
      responses:
        '200':
          description: Участник исключён; его org-ключи отозваны
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    example: 3
                  status:
                    type: string
                    enum: [removed]
                  revoked_key_ids:
                    type: array
                    items:
                      type: integer
                    example: [11, 12]
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права (admin может исключать только member)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Участник не найден в этой организации (или вызывающий standalone)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '409':
          description: Нельзя исключить последнего владельца организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Last owner cannot be removed
                  type: invalid_request_error
                  code: org_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/invites/accept:
    post:
      tags: [team]
      operationId: acceptOrgInvite
      summary: Принять приглашение в организацию (session-auth)
      description: |
        Принятие приглашения по одноразовому токену из письма. Токен — только
        в JSON-теле (секрет не должен попадать в access-логи). Аутентификация
        — только session-cookie аккаунта, Bearer-ключи не принимаются. E-mail
        аккаунта сессии обязан совпасть с e-mail приглашения (после
        нормализации), иначе 403 `invite_email_mismatch`. При успехе аккаунт
        становится участником с ролью приглашения и получает ровно один
        org-ключ на общий баланс организации: plaintext `api_key`
        возвращается один раз, только в этом ответе. Подтверждённый e-mail
        аккаунта не требуется: владение токеном из письма доказывает доступ
        к почтовому ящику. `invalid_token` един для неизвестного, истёкшего,
        принятого, отклонённого и отозванного токена (анти-enumeration).
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrgInviteTokenRequest'
      responses:
        '200':
          description: Приглашение принято; `api_key` показывается один раз
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptOrgInviteResponse'
        '400':
          description: Токен неизвестен, истёк или уже использован (uniform)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid token
                  type: invalid_request_error
                  code: invalid_token
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '403':
          description: E-mail аккаунта сессии не совпадает с e-mail приглашения
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invite was sent to a different email
                  type: invalid_request_error
                  code: invite_email_mismatch
        '409':
          description: Аккаунт уже член этой организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Account is already a member
                  type: invalid_request_error
                  code: org_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/invites/decline:
    post:
      tags: [team]
      operationId: declineOrgInvite
      summary: Отклонить приглашение в организацию (session-auth)
      description: |
        Отклонение приглашения по одноразовому токену (кнопка на странице
        принятия; отдельной ссылки в письме нет). Та же проверка совпадения
        e-mail, что у accept; тот же uniform `invalid_token`.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrgInviteTokenRequest'
      responses:
        '200':
          description: Приглашение отклонено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [declined]
        '400':
          description: Токен неизвестен, истёк или уже использован (uniform)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invalid token
                  type: invalid_request_error
                  code: invalid_token
        '401':
          description: Нет/недействительна session-cookie
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Session required
                  type: authentication_error
                  code: session_required
        '403':
          description: E-mail аккаунта сессии не совпадает с e-mail приглашения
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Invite was sent to a different email
                  type: invalid_request_error
                  code: invite_email_mismatch
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/documents:
    get:
      tags: [documents]
      operationId: listEdiDocuments
      summary: Список закрывающих документов (УПД) организации (owner|admin)
      description: |
        Новые первыми (`id` DESC). Пагинация курсором: `cursor` — последний
        `id` предыдущей страницы; поле `next_cursor` отсутствует на последней
        странице (как в GET /v1/billing/transactions).
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Размер страницы (1..200)
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          description: Последний id предыдущей страницы (выборка строго меньше)
          schema:
            type: integer
      responses:
        '200':
          description: Страница документов со статусами ЭДО
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EdiDocumentsPage'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags: [documents]
      operationId: createEdiDocument
      summary: Сформировать УПД по оплаченным счетам (owner|admin)
      description: |
        Ровно один режим: `{invoice_id}` — УПД по конкретному оплаченному счёту
        организации, либо `{period: 'YYYY-MM'}` — консолидированный УПД по всем
        оплаченным счетам за календарный месяц (будущий период запрещён;
        текущий допустим — в него входят уже оплаченные счета). Документ
        ставится в очередь на отправку контрагенту через ЭДО (Диадок);
        автогенерации по расписанию нет. Идемпотентность: повторный запрос для
        того же счёта/периода возвращает существующий документ с
        `duplicated: true`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateEdiDocumentRequest'
      responses:
        '200':
          description: Документ сформирован (или уже существовал — `duplicated`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedEdiDocument'
        '400':
          description: |
            Невалидные параметры: оба/ни одного режима; счёт не оплачен, чужой
            или уже закрыт документом; в периоде нет оплаченных счетов; период
            в будущем
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: No paid invoices in period
                  type: invalid_request_error
                  code: invalid_document_request
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/documents/{documentId}:
    get:
      tags: [documents]
      operationId: getEdiDocument
      summary: Деталь документа (реквизиты, счета, позиции, ошибка) (owner|admin)
      security:
        - bearerAuth: []
      parameters:
        - name: documentId
          in: path
          required: true
          description: Публичный идентификатор документа
          schema:
            type: string
      responses:
        '200':
          description: Документ с позициями и снапшотом реквизитов покупателя
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EdiDocumentDetail'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Документ не найден у этой организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Document not found
                  type: invalid_request_error
                  code: document_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/documents/{documentId}/pdf:
    get:
      tags: [documents]
      operationId: getEdiDocumentPdf
      summary: Печатная форма УПД (PDF) (owner|admin)
      description: |
        Официальная печатная форма оператора ЭДО (Диадок GeneratePrintForm),
        проксируется потоком. Доступна, когда документ отправлен (статусы
        `sent`/`delivered`); при `queued`/`error` — 409.
      security:
        - bearerAuth: []
      parameters:
        - name: documentId
          in: path
          required: true
          description: Публичный идентификатор документа
          schema:
            type: string
      responses:
        '200':
          description: Печатная форма документа
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Документ не найден у этой организации
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Document not found
                  type: invalid_request_error
                  code: document_not_found
        '409':
          description: Документ ещё не отправлен — печатная форма недоступна
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Document is not sent yet
                  type: invalid_request_error
                  code: document_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Оператор ЭДО недоступен/не настроен — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: EDI operator unavailable
                  type: server_error
                  code: edi_unavailable

  /v1/orgs/current/subscription:
    get:
      tags: [team]
      operationId: getOrgSubscription
      summary: Подписка организации и состояние общих окон (любая роль)
      description: |
        Org-scope подписка: включённые окна лимитера общие на все ключи
        организации. `subscription` — null, если активной подписки нет;
        `windows` — null без активной подписки; `pending_invoice` —
        выставленный, но не оплаченный счёт на тариф
        (`purpose='org_subscription'`), иначе null.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Состояние org-подписки, окон и ожидающего счёта
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgSubscriptionState'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Ключ standalone (не привязан к организации)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Organization not found
                  type: invalid_request_error
                  code: org_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags: [team]
      operationId: purchaseOrgSubscription
      summary: Купить тариф для организации картой (owner|admin)
      description: |
        Списание полной цены тарифа с сохранённого способа оплаты
        (`payment_method_id`). Платёж идёт с charge key
        «sub:org:{id}:{period_start}» и НЕ пополняет баланс. При успешном
        списании подписка активна, период — period_seconds плана от текущего
        момента; при неуспешном — контракт отменяется. Уже есть активная
        org-подписка — 409 `subscription_state_conflict`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchaseOrgSubscriptionRequest'
      responses:
        '200':
          description: Активная подписка и результат списания
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PurchaseOrgSubscriptionResponse'
        '400':
          description: |
            Невалидный запрос: plan_id пуст или тело не JSON
            (`invalid_request`), либо у аккаунта нет сохранённого способа
            оплаты (`payment_method_required`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Payment method is required
                  type: invalid_request_error
                  code: payment_method_required
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Списание отклонено — не ретраить до смены способа оплаты
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию (нужна owner|admin)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Тариф не найден (`plan_not_found`) или ключ standalone (`org_not_found`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Plan not found
                  type: invalid_request_error
                  code: plan_not_found
        '409':
          description: У организации уже есть активная подписка
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Subscription already exists
                  type: invalid_request_error
                  code: subscription_state_conflict
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: Ошибка платёжного провайдера — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    patch:
      tags: [team]
      operationId: patchOrgSubscription
      summary: Изменить флаги подписки (owner|admin)
      description: Хотя бы одно поле; неуказанные поля не меняются.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchOrgSubscriptionRequest'
      responses:
        '200':
          description: Обновлённая подписка
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgSubscriptionResponse'
        '400':
          description: Ни одного поля для обновления
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию (нужна owner|admin)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone (`org_not_found`) или активной подписки нет (`subscription_not_found`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [team]
      operationId: cancelOrgSubscription
      summary: Отменить автопродление (owner|admin)
      description: |
        Отменяет автопродление; подписка остаётся active до period_end.
        Идемпотентно: повторный вызов возвращает тот же 200.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Подписка с отключённым автопродлением
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgSubscriptionResponse'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию (нужна owner|admin)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Ключ standalone (`org_not_found`) или активной подписки нет (`subscription_not_found`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/subscription/upgrade:
    post:
      tags: [team]
      operationId: upgradeOrgSubscription
      summary: Смена тарифа org-подписки (owner|admin)
      description: |
        Тариф дороже — мгновенный апгрейд с проратой: доплата с привязанного
        способа оплаты (в ответе — поле `payment`). Тариф дешевле или равный
        — отложенная смена через `pending_plan_id`, применится на продлении,
        без списания. Подписка, купленная по счёту (без привязанного способа
        оплаты), — 400 `payment_method_required`: смена тарифа оформляется
        новым счётом (POST /v1/orgs/current/subscription/invoice).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrgSubscriptionUpgradeRequest'
      responses:
        '200':
          description: Обновлённая подписка (и платёж при мгновенном апгрейде)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpgradeOrgSubscriptionResponse'
        '400':
          description: Подписка куплена по счёту — привязанного способа оплаты нет
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Payment method is required
                  type: invalid_request_error
                  code: payment_method_required
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Списание отклонено — не ретраить до смены способа оплаты
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию (нужна owner|admin)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: |
            Тариф не найден (`plan_not_found`), активной подписки нет
            (`subscription_not_found`) или ключ standalone (`org_not_found`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Plan not found
                  type: invalid_request_error
                  code: plan_not_found
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: Ошибка платёжного провайдера — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/orgs/current/subscription/invoice:
    post:
      tags: [team]
      operationId: requestOrgSubscriptionInvoice
      summary: Выставить счёт юрлица на тариф (owner|admin)
      description: |
        Счёт на ПОЛНЫЙ тариф без пропорции (`invoices.purpose='org_subscription'`,
        `plan_id`). Покупка и смена тарифа по счёту единообразны: после
        подтверждения оплаты подписка становится active на выбранный план,
        период стартует от даты подтверждения, `auto_renew=false`. Зачисления
        на баланс не происходит. Счёт покрывается УПД наравне со счетами
        пополнения (см. /v1/orgs/current/documents). Уже есть выставленный
        счёт на тариф — 409 `org_subscription_invoice_pending`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrgSubscriptionInvoiceRequest'
      responses:
        '200':
          description: Выставленный счёт на тариф
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrgSubscriptionInvoiceResponse'
        '400':
          description: Невалидный запрос (plan_id пуст или тело не JSON)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Нет/неверный заголовок Authorization или ключ не найден
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Роль ключа не даёт права на операцию (нужна owner|admin)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Insufficient role
                  type: authentication_error
                  code: insufficient_role
        '404':
          description: Тариф не найден (`plan_not_found`) или ключ standalone (`org_not_found`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Plan not found
                  type: invalid_request_error
                  code: plan_not_found
        '409':
          description: Уже есть выставленный, но не оплаченный счёт на тариф
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  message: Pending subscription invoice exists
                  type: invalid_request_error
                  code: org_subscription_invoice_pending
        '500':
          description: Внутренняя ошибка шлюза — ретраить с backoff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /healthz:
    get:
      tags: [health]
      operationId: getHealthz
      summary: Liveness-проверка
      description: Процесс жив. Без аутентификации.
      security: []
      responses:
        '200':
          description: Шлюз жив
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'

  /readyz:
    get:
      tags: [health]
      operationId: getReadyz
      summary: Readiness-проверка
      description: Проверяет ping PostgreSQL. Без аутентификации.
      security: []
      responses:
        '200':
          description: Шлюз готов принимать трафик
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
        '503':
          description: Шлюз не готов (ответ — не error envelope, а JSON статуса)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotReadyStatus'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API-ключ Фарватера: `Authorization: Bearer fv-sk-…`
        (префикс `fv-sk-` + 43 символа base64url). Ключ показывается один раз
        при создании.
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        Альтернативный заголовок ключа для формата Anthropic Messages
        (`POST /v1/messages`): тот же ключ Фарватера
        (`fv-sk-…`/`ruslo-sk-…`). Допустимо подавать одновременно с
        `Authorization: Bearer` — приоритет у `x-api-key`.
    cookieAuth:
      type: apiKey
      in: cookie
      name: farvater_session
      description: |
        Session-cookie аккаунта (контур /v1/auth/*): HttpOnly, SameSite=Lax,
        Path=/, Secure по TLS шлюза. Сессия sliding: TTL продлевается при
        активности (7 дней по умолчанию), абсолютный потолок — 30 дней
        от момента входа. Выставляется POST /v1/auth/login, очищается
        (Max-Age<0) POST /v1/auth/logout и POST /v1/auth/password/change.

  parameters:
    XApiKeyHeader:
      name: x-api-key
      in: header
      required: false
      description: |
        Dual-accept (только маршруты Anthropic-семейства batch): альтернатива
        `Authorization: Bearer` — тот же ключ fv-sk-… При отсутствии заголовка
        Authorization шлюз синтезирует Bearer-аутентификацию из x-api-key; при
        наличии обоих — приоритет у Authorization.
      schema:
        type: string
        example: fv-sk-…
    AnthropicVersionHeader:
      name: anthropic-version
      in: header
      required: true
      description: Версия Anthropic API (хранится и проксируется as-is, pass-through)
      schema:
        type: string
        example: '2023-06-01'
  headers:
    Ruslo-Usage-Cost:
      description: |
        Стоимость запроса в ₽ decimal-строкой: pre-stream — оценка hold/
        резерва, после settle — точная по факту usage (на мультимодальных
        контурах совпадает с usage.cost_rub тела ответа). Non-stream — точная;
        stream — оценка (точные токены приходят после отправки заголовков).
        Источник truth по списаниям — ledger/проводки биллинга, заголовок
        информативный. Присутствует на обоих семействах (OpenAI и Anthropic),
        в stream и non-stream, включая error-ответы.
      schema:
        type: string
        pattern: '^[0-9]+(\.[0-9]+)?$'
        example: '0.012345'
    Ruslo-Usage-Path:
      description: |
        Биллингуемый путь запроса (контур тарификации): `subscription` — окна
        подписки, `credits` — кредитный hold, `byok` — собственный ключ
        клиента. Присутствует на всех исходах обоих семейств, включая
        error-ответы.
      schema:
        type: string
        enum: [subscription, credits, byok]
        example: subscription
    Ruslo-Window-Short-Limit:
      description: |
        Лимит короткого (burst) окна подписки в токенах — integer-строка.
        Присутствует на ответах генерационных контуров (/v1/chat/completions,
        /v1/messages), если запрос тарифицируется по подписочному пути
        (Ruslo-Usage-Path: subscription); эмитируется pre-stream после
        успешного резерва окон.
      schema:
        type: string
        pattern: '^\d+$'
        example: '100000'
    Ruslo-Window-Short-Remaining:
      description: |
        Остаток токенов короткого окна после резерва запроса — integer-строка.
        Условия присутствия — как у Ruslo-Window-Short-Limit.
      schema:
        type: string
        pattern: '^\d+$'
        example: '75000'
    Ruslo-Window-Short-Reset:
      description: |
        Момент сброса короткого окна — Unix-секунды, integer-строка. Условия
        присутствия — как у Ruslo-Window-Short-Limit.
      schema:
        type: string
        pattern: '^\d+$'
        example: '1764547200'
    Ruslo-Window-Month-Limit:
      description: |
        Лимит месячного окна подписки в токенах — integer-строка. Условия
        присутствия — как у Ruslo-Window-Short-Limit.
      schema:
        type: string
        pattern: '^\d+$'
        example: '1000000'
    Ruslo-Window-Month-Remaining:
      description: |
        Остаток токенов месячного окна после резерва запроса — integer-строка.
        Условия присутствия — как у Ruslo-Window-Short-Limit.
      schema:
        type: string
        pattern: '^\d+$'
        example: '980000'
    Ruslo-Window-Month-Reset:
      description: |
        Момент сброса месячного окна — Unix-секунды, integer-строка (1-е
        число следующего UTC-месяца). Условия присутствия — как у
        Ruslo-Window-Short-Limit.
      schema:
        type: string
        pattern: '^\d+$'
        example: '1767225600'
    X-Farvater-Window-Short-Limit:
      deprecated: true
      description: |
        DEPRECATED read-only близнец Ruslo-Window-Short-Limit (цикл ребренда:
        держится до следующего release-тега стека, значение то же). Миграция —
        на Ruslo-Window-Short-Limit.
      schema:
        type: string
        pattern: '^\d+$'
        example: '100000'
    X-Farvater-Window-Short-Remaining:
      deprecated: true
      description: |
        DEPRECATED read-only близнец Ruslo-Window-Short-Remaining (цикл
        ребренда: держится до следующего release-тега стека, значение то же).
        Миграция — на Ruslo-Window-Short-Remaining.
      schema:
        type: string
        pattern: '^\d+$'
        example: '75000'
    X-Farvater-Window-Short-Reset:
      deprecated: true
      description: |
        DEPRECATED read-only близнец Ruslo-Window-Short-Reset (цикл ребренда:
        держится до следующего release-тега стека, значение то же). Миграция —
        на Ruslo-Window-Short-Reset.
      schema:
        type: string
        pattern: '^\d+$'
        example: '1764547200'
    X-Farvater-Window-Month-Limit:
      deprecated: true
      description: |
        DEPRECATED read-only близнец Ruslo-Window-Month-Limit (цикл ребренда:
        держится до следующего release-тега стека, значение то же). Миграция —
        на Ruslo-Window-Month-Limit.
      schema:
        type: string
        pattern: '^\d+$'
        example: '1000000'
    X-Farvater-Window-Month-Remaining:
      deprecated: true
      description: |
        DEPRECATED read-only близнец Ruslo-Window-Month-Remaining (цикл
        ребренда: держится до следующего release-тега стека, значение то же).
        Миграция — на Ruslo-Window-Month-Remaining.
      schema:
        type: string
        pattern: '^\d+$'
        example: '980000'
    X-Farvater-Window-Month-Reset:
      deprecated: true
      description: |
        DEPRECATED read-only близнец Ruslo-Window-Month-Reset (цикл ребренда:
        держится до следующего release-тега стека, значение то же). Миграция —
        на Ruslo-Window-Month-Reset.
      schema:
        type: string
        pattern: '^\d+$'
        example: '1767225600'
    X-Farvater-Usage-Path:
      deprecated: true
      description: |
        DEPRECATED read-only близнец Ruslo-Usage-Path (цикл ребренда: держится
        до следующего release-тега стека, значение то же: subscription |
        credits | byok). Миграция — на Ruslo-Usage-Path.
      schema:
        type: string
        enum: [subscription, credits, byok]
        example: subscription

  schemas:
    AuthAccount:
      type: object
      description: Аккаунт email+пароль (ответы login/logout/me контура /v1/auth/*)
      required: [id, email, verified, role]
      properties:
        id:
          type: string
          description: Публичный идентификатор аккаунта
          example: 9f2c1a3e8b7d
        email:
          type: string
          format: email
          example: user@example.ru
        verified:
          type: boolean
          description: true — email подтверждён по ссылке из письма
        role:
          type: string
          enum: [user, admin]

    AuthAccountMe:
      allOf:
        - $ref: '#/components/schemas/AuthAccount'
        - type: object
          properties:
            created_at:
              type: string
              format: date-time
              description: Дата регистрации (только в GET /v1/auth/me)
              example: '2026-09-29T12:00:00Z'

    AuthRegisterRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          example: user@example.ru
        password:
          type: string
          minLength: 10
          maxLength: 128
          description: Пароль, 10..128 символов

    AuthLoginRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          example: user@example.ru
        password:
          type: string

    AuthEmailRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
          example: user@example.ru

    AuthTokenRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: Одноразовый токен из ссылки письма

    AuthPasswordResetConfirmRequest:
      type: object
      required: [token, new_password]
      properties:
        token:
          type: string
          description: Одноразовый токен из ссылки письма (TTL 1 час)
        new_password:
          type: string
          minLength: 10
          maxLength: 128

    AuthPasswordChangeRequest:
      type: object
      required: [current_password, new_password]
      properties:
        current_password:
          type: string
        new_password:
          type: string
          minLength: 10
          maxLength: 128

    AuthStatusOk:
      type: object
      properties:
        status:
          type: string
          enum: [ok]

    AuthVerifyEmailResponse:
      type: object
      description: |
        Ответ verify-email. Поле `key` присутствует ТОЛЬКО когда при
        подтверждении почты автовыдан первый личный ключ; во всех прочих
        случаях ответ — `{status:"ok"}`.
      properties:
        status:
          type: string
          enum: [ok]
        key:
          $ref: '#/components/schemas/CreatedPersonalKey'

    PersonalKey:
      type: object
      description: Личный ключ в листинге и после переименования — без key material
      properties:
        id:
          type: integer
          example: 7
        name:
          type: string
          example: Первый ключ
        prefix:
          type: ['string', 'null']
          description: |
            Первые 14 символов plaintext (`fv-sk-` + 8 символов); NULL у ключей,
            созданных до введения префиксов (отображать как `fv-sk-…`)
          example: fv-sk-a1b2c3d4
        status:
          type: string
          enum: [active, revoked]
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: ['string', 'null']
          format: date-time
        monthly_limit_credits:
          type: ['string', 'null']
          description: |
            Месячный лимит расхода ключа в кредитах (decimal-строка);
            null — без лимита. Применяется на hot path: расход текущего
            календарного месяца (UTC) + резерв под запрос больше лимита —
            отказ 429 key_spending_limit_exceeded.
          example: '100000.000000'
        monthly_spent_credits:
          type: string
          description: |
            Расход ключа за текущий календарный месяц (UTC), decimal-строка;
            "0" когда расхода нет. Только credit-settle (pay-as-you-go, включая
            subscription-fallback): подписочный трафик и BYOK-комиссия не
            учитываются.
          example: '5123.456789'

    CreatedPersonalKey:
      type: object
      description: |
        Свежевыпущенный личный ключ: plaintext `api_key` возвращается ровно
        один раз (POST /v1/keys или автовыдача в POST /v1/auth/verify-email);
        далее в БД хранится только SHA-256.
      properties:
        id:
          type: integer
          example: 7
        name:
          type: string
          example: Первый ключ
        prefix:
          type: string
          example: fv-sk-a1b2c3d4
        api_key:
          type: string
          example: fv-sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
        created_at:
          type: string
          format: date-time

    PersonalKeysList:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/PersonalKey'

    CreatePersonalKeyRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Отображаемое имя ключа (trim; уникальность не требуется)
        monthly_limit_credits:
          type: ['string', 'null']
          description: |
            Месячный лимит расхода ключа в кредитах (1 кредит = 1 копейка),
            decimal-строка строго > 0; absent или null — без лимита.
          example: '100000.000000'

    PatchPersonalKeyRequest:
      type: object
      description: |
        Частичное обновление личного ключа; хотя бы одно поле обязательно
        (пустое тело {} — 400 invalid_request). Поле monthly_limit_credits —
        трёхсостояние: absent — не менять, null — снять лимит, decimal-строка
        строго > 0 — установить. Снятие/снижение лимита не блокируется текущим
        расходом месяца.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Отображаемое имя ключа (trim; уникальность не требуется)
        monthly_limit_credits:
          type: ['string', 'null']
          description: Трёхсостояние — см. описание схемы
          example: '100000.000000'

    AuthLoginResponse:
      type: object
      properties:
        account:
          $ref: '#/components/schemas/AuthAccount'

    AuthMeResponse:
      type: object
      properties:
        account:
          $ref: '#/components/schemas/AuthAccountMe'

    ToggleEventSettings:
      type: object
      description: |
        Настройки события-переключателя (без порогов): `monthly_cap_100pct`,
        `renewal_reminder`, `key_spend_limit_reached`. В GET все поля присутствуют всегда; в PATCH
        применяются только переданные (per-event merge).
      properties:
        enabled:
          type: boolean
          description: Событие включено — письма этого типа отправляются

    PercentEventSettings:
      type: object
      description: |
        Настройки события с процентным порогом: `daily_spend_percent`,
        `short_window_percent`, `monthly_cap_percent`. В GET все поля
        присутствуют всегда; в PATCH применяются только переданные.
      properties:
        enabled:
          type: boolean
          description: Событие включено — письма этого типа отправляются
        percent:
          type: integer
          minimum: 1
          maximum: 99
          description: Порог срабатывания в процентах (целое 1..99)

    BalanceBelowThresholdSettings:
      type: object
      description: |
        Настройки события `balance_below_threshold` («баланс ниже порога»).
        В GET все поля присутствуют всегда; в PATCH применяются только
        переданные.
      properties:
        enabled:
          type: boolean
          description: Событие включено — письма этого типа отправляются
        threshold_rub:
          type: string
          pattern: '^\d+(\.\d{1,2})?$'
          example: '200.00'
          description: |
            Порог в рублях — decimal-строка строго > 0, до 2 знаков после
            точки; шлюз нормализует к 2 знакам.

    NotificationSettings:
      type: object
      description: |
        Полные эффективные настройки уведомлений аккаунта (форма ответа GET
        и PATCH /v1/notifications/settings). Множество типов событий
        фиксировано (7 канонических ключей).
      required: [channel_email_enabled, events]
      properties:
        channel_email_enabled:
          type: boolean
          description: Общий переключатель канала e-mail (false — полная тишина по всем типам)
        events:
          type: object
          required:
            - balance_below_threshold
            - daily_spend_percent
            - short_window_percent
            - monthly_cap_percent
            - monthly_cap_100pct
            - renewal_reminder
            - key_spend_limit_reached
          properties:
            balance_below_threshold:
              $ref: '#/components/schemas/BalanceBelowThresholdSettings'
            daily_spend_percent:
              $ref: '#/components/schemas/PercentEventSettings'
            short_window_percent:
              $ref: '#/components/schemas/PercentEventSettings'
            monthly_cap_percent:
              $ref: '#/components/schemas/PercentEventSettings'
            monthly_cap_100pct:
              $ref: '#/components/schemas/ToggleEventSettings'
            renewal_reminder:
              $ref: '#/components/schemas/ToggleEventSettings'
            key_spend_limit_reached:
              $ref: '#/components/schemas/ToggleEventSettings'

    NotificationSettingsPatch:
      type: object
      description: |
        Частичное обновление настроек (тело PATCH /v1/notifications/settings):
        та же структура, что NotificationSettings, но все поля опциональны —
        применяются только присутствующие ключи (per-event merge).
        Неизвестные ключи и типы событий — 400 `invalid_notification_settings`.
      properties:
        channel_email_enabled:
          type: boolean
        events:
          type: object
          properties:
            balance_below_threshold:
              $ref: '#/components/schemas/BalanceBelowThresholdSettings'
            daily_spend_percent:
              $ref: '#/components/schemas/PercentEventSettings'
            short_window_percent:
              $ref: '#/components/schemas/PercentEventSettings'
            monthly_cap_percent:
              $ref: '#/components/schemas/PercentEventSettings'
            monthly_cap_100pct:
              $ref: '#/components/schemas/ToggleEventSettings'
            renewal_reminder:
              $ref: '#/components/schemas/ToggleEventSettings'
            key_spend_limit_reached:
              $ref: '#/components/schemas/ToggleEventSettings'

    ChatCompletionRequest:
      type: object
      description: |
        OpenAI chat completion request. Перечисленные поля известны шлюзу; любые
        другие поля проксируются в апстрим байт-в-байт (шлюз не валидирует схему
        тела, чтобы не отставать от эволюции OpenAI/OpenRouter API).
      additionalProperties: true
      required: [model, messages]
      properties:
        model:
          type: string
          description: Идентификатор модели из GET /v1/models
          example: moonshotai/kimi-k2
        messages:
          type: array
          description: История диалога в OpenAI-форме
          items:
            type: object
            additionalProperties: true
            properties:
              role:
                type: string
                description: Роль сообщения (system | user | assistant | tool)
              content:
                description: |
                  Строка либо массив частей (text / image_url) — vision-вход;
                  изображение тарифицируется input-токенами по tile-формуле
                oneOf:
                  - type: string
                  - type: array
                    items:
                      $ref: '#/components/schemas/ChatMessageContentPart'
            example:
              role: user
              content: Привет!
        stream:
          type: boolean
          description: SSE-стриминг ответа
          default: false
        stream_options:
          type: object
          additionalProperties: true
          properties:
            include_usage:
              type: boolean
              description: Добавить финальный usage-чанк в стрим
        max_tokens:
          type: integer
        temperature:
          type: number
        top_p:
          type: number
        stop:
          description: Стоп-последовательности
          oneOf:
            - type: string
            - type: array
              items:
                type: string
        tools:
          type: array
          description: Инструменты (OpenAI tool calling)
          items:
            type: object
            additionalProperties: true
        tool_choice:
          description: Политика вызова инструментов
        response_format:
          type: object
          additionalProperties: true
        models:
          type: array
          description: OpenRouter-расширение — упорядоченный список фолбэк-моделей
          items:
            type: string
        provider:
          type: object
          additionalProperties: true
          description: OpenRouter-расширение — routing preferences

    ChatCompletionResponse:
      type: object
      description: Тело ответа OpenAI-формы (проксируется от апстрима)
      additionalProperties: true
      properties:
        id:
          type: string
        object:
          type: string
        created:
          type: integer
        model:
          type: string
        choices:
          type: array
          items:
            type: object
            additionalProperties: true
        usage:
          type: object
          additionalProperties: true

    ChatMessageContentPart:
      description: Часть мультимодального content сообщения (vision-вход)
      oneOf:
        - $ref: '#/components/schemas/ChatMessageTextPart'
        - $ref: '#/components/schemas/ChatMessageImagePart'

    ChatMessageTextPart:
      type: object
      required: [type, text]
      properties:
        type:
          type: string
          enum: [text]
        text:
          type: string
          description: Текст части сообщения
          example: Что изображено на картинке?

    ChatMessageImagePart:
      type: object
      required: [type, image_url]
      properties:
        type:
          type: string
          enum: [image_url]
        image_url:
          $ref: '#/components/schemas/ChatMessageImageURL'

    ChatMessageImageURL:
      type: object
      required: [url]
      properties:
        url:
          type: string
          pattern: '^(https://|data:image/(png|jpeg|webp|gif);base64,)'
          description: |
            Источник изображения: https-URL или data-URI
            `data:image/{png,jpeg,webp,gif};base64,<payload>`. Ограничения входа:
            fetch https — timeout ≤ 5 с, размер ≤ FARVATER_VISION_FETCH_MAX_BYTES
            (default 8 MiB); data-URI — тот же лимит байтов. Нарушение — 400
            invalid_request, тело запроса не проксируется. Тарификация —
            input-токены по tiles: tiles = ceil(w/512) × ceil(h/512),
            image_tokens = 85 + 170 × tiles.
          example: 'data:image/png;base64,iVBORw0KGgo='
        detail:
          type: string
          description: Детализация (OpenAI — auto | low | high); на tile-тарификацию не влияет

    MessagesRequest:
      type: object
      description: |
        Запрос Anthropic Messages. Перечисленные поля известны шлюзу; любые
        другие поля проксируются в апстрим байт-в-байт (шлюз не валидирует
        схему тела сверх обязательных полей, чтобы не отставать от эволюции
        Anthropic-формата).
      additionalProperties: true
      required: [model, max_tokens, messages]
      properties:
        model:
          type: string
          description: |
            Идентификатор модели из GET /v1/models — тот же каталог, что у
            POST /v1/chat/completions
          example: moonshotai/kimi-k2
        max_tokens:
          type: integer
          description: Обязательное поле; верхняя граница токенов ответа (> 0)
          example: 1024
        messages:
          type: array
          description: История диалога в форме Anthropic
          minItems: 1
          items:
            type: object
            additionalProperties: true
            required: [role, content]
            properties:
              role:
                type: string
                enum: [user, assistant]
              content:
                description: |
                  Строка либо массив блоков `{"type":"text","text":…}`; блоки
                  иных типов проксируются verbatim без гарантий семантики в
                  этой версии контракта
                oneOf:
                  - type: string
                  - type: array
                    items:
                      type: object
                      additionalProperties: true
                      properties:
                        type:
                          type: string
                          example: text
                        text:
                          type: string
            example:
              role: user
              content: Привет!
        system:
          description: |
            Системный промпт: строка либо массив блоков; top-level поле, НЕ
            элемент `messages`. Проксируется verbatim
          oneOf:
            - type: string
            - type: array
              items:
                type: object
                additionalProperties: true
        stream:
          type: boolean
          description: SSE-стриминг ответа (порядок событий — см. 200 text/event-stream)
          default: false
        temperature:
          type: number
        top_p:
          type: number
        stop_sequences:
          type: array
          description: Стоп-последовательности
          items:
            type: string
        tools:
          description: |
            Инструменты Anthropic tool calling; проксируются verbatim без
            гарантий семантики в этой версии контракта
        thinking:
          description: |
            Конфигурация extended thinking; проксируется verbatim без гарантий
            семантики в этой версии контракта

    MessagesResponse:
      type: object
      description: |
        Тело ответа Anthropic Messages-формы: проксируется от нативного
        Anthropic-эндпоинта апстрима verbatim; схема описывает наблюдаемую
        форму
      additionalProperties: true
      required: [id, type, role, model, content, usage]
      properties:
        id:
          type: string
          example: msg_01XFDUDYJgAACzvnptvVoYEL
        type:
          type: string
          const: message
        role:
          type: string
          const: assistant
        model:
          type: string
          example: moonshotai/kimi-k2
        content:
          type: array
          items:
            type: object
            additionalProperties: true
            properties:
              type:
                type: string
                example: text
              text:
                type: string
        stop_reason:
          type: ['string', 'null']
          enum: [end_turn, max_tokens, stop_sequence, null]
          description: Причина остановки генерации
        stop_sequence:
          type: ['string', 'null']
          description: Стоп-последовательность, на которой остановилась генерация
        usage:
          $ref: '#/components/schemas/AnthropicUsage'

    AnthropicUsage:
      type: object
      description: Фактический usage ответа в токенах — источник для биллинга
      required: [input_tokens, output_tokens]
      properties:
        input_tokens:
          type: integer
          example: 12
        output_tokens:
          type: integer
          example: 34

    AnthropicError:
      type: object
      description: |
        Error envelope Anthropic-формы для /v1/messages: машиночитаемый класс
        `error.type` и человекочитаемое `error.message` (на английском —
        клиенты машинные). Поля `code` нет (в отличие от OpenAI-семейства).
        На `429` проксируется заголовок Retry-After.
      required: [type, error]
      properties:
        type:
          type: string
          const: error
        error:
          type: object
          required: [type, message]
          properties:
            type:
              type: string
              description: Класс ошибки Anthropic-семейства
              enum:
                - invalid_request_error
                - authentication_error
                - billing_error
                - rate_limit_error
                - overloaded_error
                - api_error
                - model_not_found
            message:
              type: string
              description: Человекочитаемое сообщение (на английском — клиенты машинные)

    ModelPricing:
      type: object
      description: Цены апстрима в USD за 1K токенов (строки, форма OpenRouter)
      properties:
        prompt:
          type: string
          example: '0.0006'
        completion:
          type: string
          example: '0.0025'

    ModelPricingRub:
      type: object
      description: Цены Фарватера в ₽ за 1M токенов (строки)
      properties:
        prompt:
          type: string
          example: '54.00'
        completion:
          type: string
          example: '225.00'

    Model:
      type: object
      required: [id, object, created, owned_by, context_length, pricing]
      properties:
        id:
          type: string
          description: Идентификатор модели Фарватера (на старте совпадает с OpenRouter id)
          example: moonshotai/kimi-k2
        object:
          type: string
          enum: [model]
        created:
          type: integer
          example: 1720000000
        owned_by:
          type: string
          example: farvater
        context_length:
          type: integer
          example: 262144
        pricing:
          $ref: '#/components/schemas/ModelPricing'
        pricing_rub:
          $ref: '#/components/schemas/ModelPricingRub'
        card:
          $ref: '#/components/schemas/ModelCard'
          description: |
            Карточка модели (схема D2, редакция 1.1.0): байт-в-байт равна элементу
            data снапшота GET /models.json и price-snapshot биллинг-сетлмента

    ModelsList:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/Model'

    Modality:
      type: string
      description: |
        Модальность этапа 1 — ограниченное множество (то же множество у метрик и
        metering-событий шлюза). Колонка каталога расширяема на новые модальности
        без миграции; контракт текущей редакции фиксирован шестью значениями.
      enum: [text, vision-input, image-gen, audio-tts, audio-asr, embeddings]

    UnitKind:
      type: string
      description: Единица тарификации карточки каталога
      enum: [tokens-1m, images, chars-1k, minutes, requests]

    PriceRub:
      type: object
      description: |
        Unit-цены карточки в ₽ (decimal-строки). Источник: текст — pricer цен
        апстрима; новые модальности — релиз-конфигурируемый прайс-лист.
        Биллинг-сетлмент читает цены только из price-snapshot карточки.
      required: [input_per_unit, output_per_unit, batch_per_unit]
      properties:
        input_per_unit:
          type: string
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: ₽ за единицу input_units
          example: '20'
        cached_per_unit:
          type: string
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: ₽ за единицу кешированного input; ключ отсутствует, если источника цены нет
          example: '13.50'
        output_per_unit:
          type: string
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: ₽ за единицу output_units (для image-gen — базовый тиер)
          example: '4'
        batch_per_unit:
          type: string
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: Явное значение прайс-листа либо 50% от соответствующей unit-цены (правило D6); вычисляется, не хардкодится
          example: '2'

    ModelLimits:
      type: object
      additionalProperties: true
      description: |
        Контрактные данные лимитов (граница A7): runtime-enforcement модальностных
        лимитов в текущей редакции отсутствует. Ключи этапа 1 — rpm, tpm; для
        image-gen дополнительно image_tiers (ценовые тиеры по разрешению).
      properties:
        rpm:
          type: integer
          description: Запросов в минуту
          example: 60
        tpm:
          type: integer
          description: Токенов в минуту
          example: 200000
        image_tiers:
          type: object
          description: |
            Тиеры цен image-gen: разрешение (ключ вида ^[0-9]+x[0-9]+$) →
            ₽ за изображение (decimal-строка)
          additionalProperties:
            type: string
            pattern: '^[0-9]+(\.[0-9]+)?$'
          example:
            1024x1024: '4'
            1024x1792: '8'
            1792x1024: '8'

    ModelCard:
      type: object
      description: |
        Карточка модели каталога (схема D2, редакция 1.1.0). Идентична байт-в-байт
        в трёх точках: ключ `card` элемента GET /v1/models, элемент `data`
        GET /models.json, price-snapshot биллинг-сетлмента (price_rub и единицы —
        вербатим, decimal-строки как есть).
      required:
        [id, vendor, modality, input_units, output_units, price_rub, unit_label_ru, context_tokens, limits, batch_supported, status, updated_at]
      properties:
        id:
          type: string
          description: Идентификатор модели Фарватера
          example: moonshotai/kimi-k2
        vendor:
          type: string
          description: Префикс upstream_id до '/', иначе имя upstream
          example: moonshotai
        modality:
          type: array
          description: Подмножество ограниченного множества модальностей этапа 1
          items:
            $ref: '#/components/schemas/Modality'
        input_units:
          $ref: '#/components/schemas/UnitKind'
        output_units:
          $ref: '#/components/schemas/UnitKind'
        price_rub:
          $ref: '#/components/schemas/PriceRub'
        unit_label_ru:
          type: string
          enum: ['₽/1M токенов', '₽/изображение', '₽/1K символов', '₽/минута', '₽/запрос']
          description: Человекочитаемая метка единицы цены
          example: '₽/1M токенов'
        context_tokens:
          type: integer
          description: Контекст модели в токенах
          example: 262144
        limits:
          $ref: '#/components/schemas/ModelLimits'
        batch_supported:
          type: boolean
          description: Данные этапа 1 (A9); batch-исполнение в текущей редакции не реализуется
          example: false
        status:
          type: string
          enum: [active, preview, deprecated]
          description: Статус модели в каталоге
          example: active
        deprecated_at:
          type: string
          format: date-time
          description: RFC3339; присутствует только при status=deprecated
        successor_id:
          type: string
          description: Id модели-преемника; присутствует только при status=deprecated
        updated_at:
          type: string
          format: date-time
          description: Время последнего обновления карточки (RFC3339)

    ModelsSnapshot:
      type: object
      description: Снапшот карточек каталога (GET /models.json)
      required: [object, updated_at, data]
      properties:
        object:
          type: string
          enum: [models.snapshot]
        updated_at:
          type: string
          format: date-time
          description: Время формирования снапшота (RFC3339)
        data:
          type: array
          items:
            $ref: '#/components/schemas/ModelCard'

    ImagesGenerationsRequest:
      type: object
      additionalProperties: true
      required: [model, prompt]
      properties:
        model:
          type: string
          description: Id модели каталога с modality image-gen
          example: vendor/image-model
        prompt:
          type: string
          description: Промпт генерации
        n:
          type: integer
          minimum: 1
          maximum: 4
          default: 1
          description: Число изображений (1..4)
        size:
          type: string
          pattern: '^[0-9]+x[0-9]+$'
          default: 1024x1024
          description: Разрешение; тиер цены — limits.image_tiers карточки, при отсутствии ключа — базовый тиер
          example: 1024x1024
        response_format:
          type: string
          enum: [url, b64_json]
          default: url
        quality:
          type: string
          description: Опциональный параметр качества (проксируется в апстрим)
        style:
          type: string
          description: Опциональный параметр стиля (проксируется в апстрим)

    ImagesGenerationsResponse:
      type: object
      required: [created, data]
      properties:
        created:
          type: integer
          description: Unix-timestamp создания
          example: 1720000000
        data:
          type: array
          items:
            type: object
            properties:
              url:
                type: string
                description: URL изображения (response_format=url)
              b64_json:
                type: string
                description: Изображение в base64 (response_format=b64_json)
              revised_prompt:
                type: string
                description: Промпт, пересмотренный апстримом (опционально)

    AudioSpeechRequest:
      type: object
      additionalProperties: true
      required: [model, input, voice]
      properties:
        model:
          type: string
          description: Id модели каталога с modality audio-tts
          example: vendor/tts-model
        input:
          type: string
          maxLength: 4096
          description: Текст синтеза; лимит — FARVATER_TTS_MAX_RUNES (default 4096 рун)
        voice:
          type: string
          description: Идентификатор голоса (проксируется в апстрим)
          example: alloy
        response_format:
          type: string
          enum: [mp3, opus, aac, flac, wav, pcm]
          default: mp3
        speed:
          type: number
          description: Множитель скорости речи (опционально)

    AudioTranscriptionRequest:
      type: object
      required: [file, model]
      properties:
        file:
          type: string
          format: binary
          description: Аудиофайл (MIME audio/*), размер ≤ FARVATER_ASR_MAX_BYTES (default 25 MiB)
        model:
          type: string
          description: Id модели каталога с modality audio-asr
          example: vendor/asr-model
        language:
          type: string
          description: Подсказка языка (ISO-639-1), опционально
          example: ru
        prompt:
          type: string
          description: Контекстный промпт распознавания, опционально
        response_format:
          type: string
          description: Формат ответа (default json), опционально

    TranscriptionResponse:
      type: object
      required: [text, task]
      properties:
        text:
          type: string
          description: Распознанный текст
        task:
          type: string
          enum: [transcribe]
        language:
          type: string
          example: ru
        duration:
          type: number
          description: Длительность аудио в секундах (если определена апстримом или пробой контейнера)
        usage:
          type: object
          properties:
            seconds:
              type: number
              description: Факт секунд аудио от апстрима (приоритетный источник сетлмента)

    EmbeddingsRequest:
      type: object
      additionalProperties: true
      required: [model, input]
      properties:
        model:
          type: string
          description: Id модели каталога с modality embeddings
          example: vendor/embed-model
        input:
          description: Входной текст — строка или массив строк
          oneOf:
            - type: string
            - type: array
              items:
                type: string
        encoding_format:
          type: string
          enum: [float, base64]
          default: float
        dimensions:
          type: integer
          description: Размерность вектора (опционально, если поддерживается моделью)
        user:
          type: string

    EmbeddingsResponse:
      type: object
      required: [object, data, model, usage]
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            type: object
            required: [object, index, embedding]
            properties:
              object:
                type: string
                enum: [embedding]
              index:
                type: integer
              embedding:
                description: Вектор — массив чисел (encoding_format=float) или base64-строка (encoding_format=base64)
                oneOf:
                  - type: array
                    items:
                      type: number
                  - type: string
        model:
          type: string
        usage:
          type: object
          required: [prompt_tokens, total_tokens]
          properties:
            prompt_tokens:
              type: integer
              description: Источник сетлмента — units_input = prompt_tokens / 1M
            total_tokens:
              type: integer

    Balance:
      type: object
      description: Денежная модель — 1 кредит = 1 копейка (credits_per_rub = 100)
      properties:
        balance_credits:
          type: string
          description: Всего на счёте, 6 знаков после точки
          example: '1234.500000'
        held_credits:
          type: string
          description: Зарезервировано активными hold'ами, 6 знаков
          example: '10.000000'
        available_credits:
          type: string
          description: balance − held, 6 знаков
          example: '1224.500000'
        balance_rub:
          type: string
          description: Справочно — balance_credits / 100, 2 знака
          example: '12.35'
        credits_per_rub:
          type: integer
          description: Курс кредита (1 кредит = 1 копейка)
          example: 100

    Transaction:
      type: object
      properties:
        id:
          type: integer
          example: 1024
        type:
          type: string
          enum: [hold, settle, release, topup, adjustment]
        amount_credits:
          type: string
          description: Сумма в кредитах со знаком («−» списание, «+» зачисление/возврат)
          example: '-3.210000'
        amount_rub:
          type: string
          description: Та же сумма в рублях со знаком
          example: '-0.03'
        request_id:
          type: string
          description: Id запроса, породившего проводку
        key_id:
          type: integer
        model:
          type: string
        prompt_tokens:
          type: integer
        completion_tokens:
          type: integer
        price_rub_input:
          type: string
          description: Цена ввода ₽ за 1M токенов на момент списания
          example: '54.00'
        price_rub_output:
          type: string
          description: Цена вывода ₽ за 1M токенов на момент списания
          example: '225.00'
        modality:
          $ref: '#/components/schemas/Modality'
          description: Модальность запроса; legacy-записи текстовых контуров — text
        tier:
          type: string
          description: Ценовой тиер image-gen (разрешение); для прочих модальностей отсутствует
          example: 1024x1024
        units:
          $ref: '#/components/schemas/UnitUsage'
          description: Счётчики единиц записи (settle — факт, hold — оценка); для текстовой модальности — токены/1M
        price_rub:
          $ref: '#/components/schemas/PriceRub'
          description: Вербатим-объект цен карточки из price-snapshot hold'а на момент settle
        estimated:
          type: boolean
          description: true — usage посчитан токенизатором/эвристикой (обрыв стрима)
        created_at:
          type: string
          format: date-time
          example: '2026-09-28T16:00:00Z'

    TransactionsPage:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/Transaction'
        next_cursor:
          type: string
          description: Курсор следующей страницы; отсутствует на последней
          example: '1000'

    UnitUsage:
      type: object
      description: Счётчики единиц модальностей (decimal-строки, NUMERIC(20,6))
      properties:
        input:
          type: string
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: Единицы input (токены/1M, символы/1K, минуты) — факт settle или оценка hold
          example: '0.001250'
        cached:
          type: string
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: Единицы кешированного input; отсутствует, если кеширования не было
        output:
          type: string
          pattern: '^[0-9]+(\.[0-9]+)?$'
          description: Единицы output (для image-gen — число изображений)
          example: '2'

    Invoice:
      type: object
      description: Счёт на оплату (элемент GET /v1/payments/invoices)
      properties:
        id:
          type: string
          description: Публичный идентификатор счёта
          example: 9f2c1a3e8b7d
        number:
          type: string
          example: Ф-2026-000042
        amount_rub:
          type: string
          description: Сумма счёта в рублях, строка с фиксированной точностью (2 знака)
          example: '50000.00'
        vat_amount_rub:
          type: string
          description: Сумма НДС в рублях, строка с фиксированной точностью (2 знака)
          example: '8333.33'
        purpose:
          type: string
          enum: [topup, org_subscription]
          default: topup
          description: |
            Назначение счёта: topup — пополнение баланса; org_subscription —
            оплата тарифа org-подписки (зачисления на баланс НЕ происходит —
            после подтверждения оплаты активируется подписка организации)
        plan_id:
          type: ['string', 'null']
          description: Тариф org-подписки; присутствует только при purpose='org_subscription'
          example: pro
        status:
          type: string
          enum: [issued, paid, canceled]
        created_at:
          type: string
          format: date-time
          example: '2026-09-29T12:00:00Z'
        paid_at:
          type: string
          format: date-time
          description: Присутствует только у оплаченных счетов
          example: '2026-09-29T13:00:00Z'
        buyer:
          $ref: '#/components/schemas/Buyer'

    InvoicesPage:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/Invoice'
        next_cursor:
          type: string
          description: Курсор следующей страницы; отсутствует на последней
          example: '1234'

    UsageSeries:
      type: object
      description: Агрегаты использования из ClickHouse billing_events (GET /v1/usage/series)
      required: [object, range, series, totals]
      properties:
        object:
          type: string
          enum: [usage_series]
        range:
          $ref: '#/components/schemas/UsageSeriesRange'
        series:
          type: array
          description: Сортировка — bucket_start, model, traffic; пустой диапазон — []
          items:
            $ref: '#/components/schemas/UsageSeriesRow'
        totals:
          $ref: '#/components/schemas/UsageSeriesTotals'

    UsageSeriesRange:
      type: object
      description: Эффективный диапазон запроса после применения дефолтов
      required: [from, to, bucket, traffic]
      properties:
        from:
          type: string
          format: date-time
          example: '2026-09-14T00:00:00Z'
        to:
          type: string
          format: date-time
          example: '2026-09-28T00:00:00Z'
        bucket:
          type: string
          enum: [day, hour]
        traffic:
          type: string
          enum: [all, payg, subscription, byok]

    UsageSeriesRow:
      type: object
      description: Один бакет времени × модель × тип трафика
      required:
        - bucket_start
        - model
        - traffic
        - requests
        - prompt_tokens
        - completion_tokens
        - spend_rub
        - subscription_fallback
        - rub_equivalent
      properties:
        bucket_start:
          type: string
          format: date-time
          example: '2026-09-27T00:00:00Z'
        model:
          type: string
          example: moonshotai/kimi-k2
        traffic:
          type: string
          enum: [payg, subscription, byok]
        requests:
          type: integer
          example: 41
        prompt_tokens:
          type: integer
          example: 1200000
        completion_tokens:
          type: integer
          example: 300000
        spend_rub:
          type: string
          description: |
            Фактически списанные ₽ (settle; для BYOK — комиссия).
            Для traffic=subscription всегда '0' — подписка не списывает деньги.
          example: '123.45'
        subscription_fallback:
          type: boolean
          description: true — списание pay-as-you-go по включённой fallback-опции
        rub_equivalent:
          type: ['string', 'null']
          description: |
            Только traffic=subscription: справочный ₽-эквивалент использования
            по прайсу (НЕ списание). Для остальных типов трафика — null.
          example: '123.45'

    UsageSeriesTotals:
      type: object
      description: Итоги за диапазон; spend_rub — только деньги (без rub_equivalent)
      required: [requests, prompt_tokens, completion_tokens, spend_rub]
      properties:
        requests:
          type: integer
          example: 410
        prompt_tokens:
          type: integer
          example: 12000000
        completion_tokens:
          type: integer
          example: 3000000
        spend_rub:
          type: string
          example: '1531.20'

    Buyer:
      type: object
      description: Реквизиты юрлица-покупателя (КПП отсутствует у ИП)
      required: [name, inn, address]
      properties:
        name:
          type: string
          example: ООО «Ромашка»
        inn:
          type: string
          description: 10 или 12 цифр
          pattern: '^\d{10}(\d{2})?$'
          example: '7707083893'
        kpp:
          type: string
          description: 9 цифр; у ИП отсутствует
          pattern: '^\d{9}$'
          example: '770701001'
        address:
          type: string
          example: 123456, г. Москва, ул. Примерная, д. 1

    CreateOrgRequest:
      type: object
      required: [name, buyer]
      properties:
        name:
          type: string
          description: Короткое название организации (1..200)
          example: Ромашка
        buyer:
          $ref: '#/components/schemas/Buyer'

    UpdateOrgRequest:
      type: object
      description: Неуказанные поля не меняются; buyer заменяется целиком
      properties:
        name:
          type: string
        buyer:
          $ref: '#/components/schemas/Buyer'

    OrgProfile:
      type: object
      properties:
        id:
          type: string
          description: Публичный идентификатор организации
          example: 9f3a2c1e7b4d
        name:
          type: string
          example: Ромашка
        buyer:
          $ref: '#/components/schemas/Buyer'
        role:
          type: string
          description: Роль вызывающего ключа в организации
          enum: [owner, admin, member]
        created_at:
          type: string
          format: date-time
          example: '2026-09-28T19:00:00Z'

    CreatedOrg:
      allOf:
        - $ref: '#/components/schemas/OrgProfile'
        - type: object
          properties:
            billing_account_id:
              type: integer
              description: Общий billing-аккаунт организации (усыновлён или создан)
              example: 42

    OrgKey:
      type: object
      description: Ключ организации в списке; значение ключа не возвращается
      properties:
        id:
          type: integer
          example: 7
        name:
          type: string
          example: ci-bot
        role:
          type: string
          enum: [owner, admin, member]
        status:
          type: string
          enum: [active, revoked]
        monthly_limit_credits:
          type: ['string', 'null']
          description: |
            Месячный лимит расхода ключа в кредитах (decimal-строка);
            null — без лимита. Enforcement: расход текущего календарного месяца
            (UTC) + резерв под запрос больше лимита — 429
            key_spending_limit_exceeded.
          example: '100000.000000'
        monthly_spent_credits:
          type: string
          description: |
            Расход ключа за текущий календарный месяц (UTC), decimal-строка;
            "0" когда расхода нет. Только credit-settle: подписочный трафик и
            BYOK-комиссия не учитываются.
          example: '5123.456789'
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: ['string', 'null']
          format: date-time

    OrgKeysList:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/OrgKey'

    CreateOrgKeyRequest:
      type: object
      required: [name, role]
      properties:
        name:
          type: string
          example: ci-bot
        role:
          type: string
          description: owner создаёт любые роли; admin — только member
          enum: [owner, admin, member]
        monthly_limit_credits:
          type: ['string', 'null']
          description: |
            Месячный лимит расхода ключа в кредитах (decimal-строка строго > 0);
            absent|null — без лимита
          default: null

    CreatedOrgKey:
      type: object
      description: Plaintext api_key возвращается один раз — только в этом ответе
      properties:
        id:
          type: integer
          example: 12
        name:
          type: string
          example: ci-bot
        role:
          type: string
          enum: [owner, admin, member]
        api_key:
          type: string
          example: fv-sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

    PatchOrgKeyRequest:
      type: object
      description: |
        Частичное обновление ключа организации; хотя бы одно поле обязательно
        (пустое тело {} — 400 invalid_request). Смена роли — только owner;
        изменение monthly_limit_credits — owner|admin. Поле
        monthly_limit_credits — трёхсостояние: absent — не менять, null — снять
        лимит, decimal-строка строго > 0 — установить.
      properties:
        role:
          type: string
          enum: [owner, admin, member]
        monthly_limit_credits:
          type: ['string', 'null']
          description: Трёхсостояние — см. описание схемы
          example: '100000.000000'

    OrgInvite:
      type: object
      description: Pending-приглашение в организацию (в ответах — только status=pending)
      properties:
        id:
          type: integer
          example: 5
        email:
          type: string
          description: Нормализованный (trim+lower) e-mail приглашённого
          example: colleague@example.com
        role:
          type: string
          description: Роль, выдаваемая приглашением (owner пригласить нельзя)
          enum: [admin, member]
        status:
          type: string
          enum: [pending]
        expires_at:
          type: string
          format: date-time
          description: Выдача + 72 часа
        created_at:
          type: string
          format: date-time

    OrgInvitesList:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/OrgInvite'

    CreateOrgInviteRequest:
      type: object
      required: [email, role]
      properties:
        email:
          type: string
          description: E-mail приглашённого (нормализуется trim+lower)
          example: colleague@example.com
        role:
          type: string
          enum: [admin, member]

    OrgMember:
      type: object
      description: Участник организации (org_members); id — org_members.id
      properties:
        id:
          type: integer
          example: 3
        email:
          type: string
          example: colleague@example.com
        role:
          type: string
          enum: [owner, admin, member]
        created_at:
          type: string
          format: date-time

    OrgMembersList:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/OrgMember'

    OrgInviteTokenRequest:
      type: object
      description: Одноразовый токен приглашения — только в теле, никогда в URL
      required: [token]
      properties:
        token:
          type: string
          example: inv_9f3a2c1e7b4d

    AcceptOrgInviteResponse:
      type: object
      description: |
        Результат принятия приглашения: новый org-ключ аккаунта.
        Plaintext api_key возвращается один раз — только в этом ответе.
      properties:
        org:
          type: object
          properties:
            id:
              type: string
              example: 9f3a2c1e7b4d
            name:
              type: string
              example: Ромашка
        role:
          type: string
          enum: [admin, member]
        api_key:
          type: string
          example: fv-sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
        key_id:
          type: integer
          example: 12
        key_prefix:
          type: string
          example: fv-sk-abc
        created_at:
          type: string
          format: date-time

    OrgSubscription:
      type: object
      description: Org-scope подписка организации (общие окна на все ключи организации)
      properties:
        id:
          type: integer
          format: int64
          example: 17
        plan_id:
          type: string
          example: pro
        status:
          type: string
          enum: [active, past_due, canceled, expired]
        period_start:
          type: string
          format: date-time
          example: '2026-10-01T12:00:00Z'
        period_end:
          type: string
          format: date-time
          example: '2026-10-31T12:00:00Z'
        auto_renew:
          type: boolean
          description: Автопродление картой; у подписки, купленной по счёту, всегда false
        fallback_to_balance:
          type: boolean
          description: При исчерпании окон продолжать на общем балансе организации (opt-in)
        pending_plan_id:
          type: ['string', 'null']
          description: План, который применится на продлении (отложенный даунгрейд); null — нет отложенной смены
        next_charge_at:
          type: ['string', 'null']
          format: date-time
          description: Дата следующего автосписания; null при auto_renew=false
          example: '2026-10-31T12:00:00Z'
        payment_method_bound:
          type: boolean
          description: Привязан ли сохранённый способ оплаты (false — подписка куплена по счёту)

    OrgSubscriptionWindow:
      type: object
      description: Одно окно лимитера org-подписки (short или month)
      properties:
        limit_tokens:
          type: integer
          format: int64
          example: 5000000
        used_tokens:
          type: integer
          format: int64
          example: 1234567
        remaining_tokens:
          type: integer
          format: int64
          example: 3765433
        resets_at:
          type: string
          format: date-time
          example: '2026-10-01T13:00:00Z'

    OrgSubscriptionWindows:
      type: object
      description: Два окна лимитера org-подписки; null целиком, когда нет активной подписки
      properties:
        short:
          $ref: '#/components/schemas/OrgSubscriptionWindow'
        month:
          $ref: '#/components/schemas/OrgSubscriptionWindow'

    OrgSubscriptionInvoice:
      type: object
      description: Счёт юрлица на тариф org-подписки (invoices.purpose='org_subscription')
      properties:
        public_id:
          type: string
          example: 9f2c1a3e8b7d
        number:
          type: string
          example: Ф-2026-000043
        amount_rub:
          type: string
          description: Полная цена тарифа в рублях, строка с фиксированной точностью (2 знака)
          example: '20000.00'
        vat_amount_rub:
          type: string
          description: Сумма НДС в рублях, строка с фиксированной точностью (2 знака)
          example: '3333.33'
        status:
          type: string
          enum: [issued]
        plan_id:
          type: string
          example: pro
        created_at:
          type: string
          format: date-time
          example: '2026-10-01T12:00:00Z'

    OrgSubscriptionState:
      type: object
      description: Ответ GET /v1/orgs/current/subscription
      properties:
        subscription:
          $ref: '#/components/schemas/OrgSubscription'
        windows:
          $ref: '#/components/schemas/OrgSubscriptionWindows'
        pending_invoice:
          $ref: '#/components/schemas/OrgSubscriptionInvoice'

    OrgSubscriptionResponse:
      type: object
      properties:
        subscription:
          $ref: '#/components/schemas/OrgSubscription'

    PurchaseOrgSubscriptionRequest:
      type: object
      required: [plan_id, payment_method_id]
      properties:
        plan_id:
          type: string
          description: Тариф из каталога подписочных планов
          example: pro
        payment_method_id:
          type: integer
          format: int64
          description: Сохранённый способ оплаты аккаунта
          example: 42
        auto_renew:
          type: boolean
          description: Автопродление картой; по умолчанию true
        fallback_to_balance:
          type: boolean
          description: При исчерпании окон продолжать на общем балансе организации; по умолчанию false

    OrgPaymentResult:
      type: object
      description: Результат списания с сохранённого способа оплаты
      properties:
        id:
          type: string
          description: Идентификатор платежа
          example: 9f2c1a3e8b7d
        status:
          type: string
          enum: [pending, succeeded]

    PurchaseOrgSubscriptionResponse:
      type: object
      properties:
        subscription:
          $ref: '#/components/schemas/OrgSubscription'
        payment:
          $ref: '#/components/schemas/OrgPaymentResult'

    OrgSubscriptionUpgradeRequest:
      type: object
      required: [plan_id]
      properties:
        plan_id:
          type: string
          description: |
            Новый тариф: дороже — мгновенный апгрейд с проратой; дешевле или
            равный — отложенная смена (pending_plan_id) без списания
          example: business

    UpgradeOrgSubscriptionResponse:
      type: object
      properties:
        subscription:
          $ref: '#/components/schemas/OrgSubscription'
        payment:
          $ref: '#/components/schemas/OrgPaymentResult'

    OrgSubscriptionInvoiceRequest:
      type: object
      required: [plan_id]
      properties:
        plan_id:
          type: string
          description: Тариф, на который выставляется счёт (полная цена, без пропорции)
          example: pro

    OrgSubscriptionInvoiceResponse:
      type: object
      properties:
        invoice:
          $ref: '#/components/schemas/OrgSubscriptionInvoice'

    PatchOrgSubscriptionRequest:
      type: object
      description: Хотя бы одно поле обязательно; неуказанные поля не меняются
      properties:
        auto_renew:
          type: boolean
          description: Включить/выключить автопродление картой
        fallback_to_balance:
          type: boolean
          description: Включить/выключить фолбэк на общий баланс организации при исчерпании окон

    EdiDocument:
      type: object
      description: Закрывающий документ (УПД) в списке; суммы — decimal-строки
      properties:
        id:
          type: string
          description: Публичный идентификатор документа
          example: 1c4f9a2e8b3d
        number:
          type: string
          example: УПД-2026-000003
        doc_type:
          type: string
          enum: [upd]
        period:
          type: ['string', 'null']
          description: |
            'YYYY-MM' для консолидированного документа за месяц;
            null — документ по одному счёту
          example: '2026-09'
        amount_rub:
          type: string
          example: '50000.00'
        vat_amount_rub:
          type: string
          description: НДС «в том числе», снапшот ставки из счетов
          example: '9090.91'
        status:
          type: string
          description: Статус документооборота ЭДО
          enum: [queued, sent, delivered, error]
        created_at:
          type: string
          format: date-time
        sent_at:
          type: ['string', 'null']
          format: date-time
        delivered_at:
          type: ['string', 'null']
          format: date-time

    EdiLine:
      type: object
      description: Позиция УПД — один оплаченный счёт
      properties:
        title:
          type: string
          example: Доступ к API LLM-моделей по счёту Ф-2026-000017
        amount_rub:
          type: string
          example: '50000.00'
        vat_rate:
          type: string
          example: '22'
        vat_amount_rub:
          type: string
          example: '9090.91'

    EdiDocumentDetail:
      allOf:
        - $ref: '#/components/schemas/EdiDocument'
        - type: object
          properties:
            buyer:
              $ref: '#/components/schemas/Buyer'
            invoice_ids:
              type: array
              description: Публичные id счетов, вошедших в документ
              items:
                type: string
            lines:
              type: array
              items:
                $ref: '#/components/schemas/EdiLine'
            error:
              type: string
              description: Машинное описание класса сбоя при status=error; иначе пусто

    EdiDocumentsPage:
      type: object
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/EdiDocument'
        next_cursor:
          type: string
          description: Курсор следующей страницы; отсутствует на последней

    CreateEdiDocumentRequest:
      description: Ровно один режим — invoice_id XOR period
      oneOf:
        - type: object
          required: [invoice_id]
          properties:
            invoice_id:
              type: string
              description: Публичный id оплаченного счёта организации
        - type: object
          required: [period]
          properties:
            period:
              type: string
              description: Календарный месяц, не будущий
              pattern: '^\d{4}-(0[1-9]|1[0-2])$'
              example: '2026-09'

    CreatedEdiDocument:
      type: object
      properties:
        id:
          type: string
        number:
          type: string
          example: УПД-2026-000003
        doc_type:
          type: string
          enum: [upd]
        status:
          type: string
          enum: [queued, sent, delivered, error]
        amount_rub:
          type: string
          example: '50000.00'
        vat_amount_rub:
          type: string
          example: '9090.91'
        invoice_ids:
          type: array
          items:
            type: string
        period:
          type: ['string', 'null']
          example: '2026-09'
        duplicated:
          type: boolean
          description: true — документ для этого счёта/периода уже существовал
        created_at:
          type: string
          format: date-time

    FileUploadForm:
      type: object
      description: multipart/form-data загрузка batch-файла (POST /v1/files)
      required: [file, purpose]
      properties:
        file:
          type: string
          format: binary
          description: Байты JSONL-файла входных строк batch-задачи
        purpose:
          type: string
          enum: [batch]
          description: Назначение файла; в контуре принимается только `batch`

    FileObject:
      type: object
      description: Файл batch-контура (входной или сгенерированный результаты/ошибки)
      required: [id, object, bytes, created_at, filename, purpose]
      properties:
        id:
          type: string
          description: Идентификатор файла
          example: file_9f2c1a3e8b7d4c2a
        object:
          type: string
          enum: [file]
        bytes:
          type: integer
          description: Размер файла в байтах
          example: 2048
        created_at:
          type: string
          format: date-time
          example: '2026-10-25T10:00:00Z'
        filename:
          type: string
          description: Имя файла при загрузке
          example: prompts.jsonl
        purpose:
          type: string
          enum: [batch]

    CreateBatchRequest:
      type: object
      description: Тело POST /v1/batches (OpenAI-семейство)
      required: [input_file_id, endpoint, completion_window]
      properties:
        input_file_id:
          type: string
          description: id файла, загруженного через POST /v1/files (purpose=batch)
          example: file_9f2c1a3e8b7d4c2a
        endpoint:
          type: string
          enum: [/v1/chat/completions]
          description: Единственный принимаемый endpoint; иное → 400 invalid_request
        completion_window:
          type: string
          enum: [24h]
          description: Единственное принимаемое окно исполнения; иное → 400 invalid_request
        metadata:
          type: ['object', 'null']
          description: Произвольные строковые метки, возвращаемые с задачей
          additionalProperties:
            type: string

    BatchJobOpenAI:
      type: object
      description: Задача batch OpenAI-семейства (ответы /v1/batches*)
      required: [id, object, endpoint, input_file_id, completion_window, status, request_counts, created_at, expires_at]
      properties:
        id:
          type: string
          example: batch_7c3e91ab04d2
        object:
          type: string
          enum: [batch]
        endpoint:
          type: string
          example: /v1/chat/completions
        input_file_id:
          type: string
          example: file_9f2c1a3e8b7d4c2a
        completion_window:
          type: string
          enum: [24h]
        status:
          type: string
          enum: [validating, in_progress, finalizing, completed, expired, cancelling, cancelled, failed]
          description: |
            Wire-статус задачи. Внутренний `queued` на wire OpenAI-семейства не
            выходит (маппируется в `in_progress`); `queued` различим только в
            cabinet-контракте /v1/billing/batches*.
        output_file_id:
          type: ['string', 'null']
          description: Файл JSONL-результатов (появляется после completed); качается через GET /v1/files/{fileId}/content
          example: file_2b8e60c91f3a
        error_file_id:
          type: ['string', 'null']
          description: Файл JSONL-ошибок строк (если есть бракованные/errored строки)
          example: file_5d1a77e02c4b
        request_counts:
          $ref: '#/components/schemas/BatchRequestCountsOpenAI'
        created_at:
          type: string
          format: date-time
          example: '2026-10-25T10:00:00Z'
        expires_at:
          type: string
          format: date-time
          description: Конец окна 24h от создания; незавершённая к этому моменту задача → expired
          example: '2026-10-26T10:00:00Z'
        completed_at:
          type: ['string', 'null']
          format: date-time
        cancelled_at:
          type: ['string', 'null']
          format: date-time
        expired_at:
          type: ['string', 'null']
          format: date-time
        failed_at:
          type: ['string', 'null']
          format: date-time
        metadata:
          type: ['object', 'null']
          additionalProperties:
            type: string
        error:
          description: Job-level ошибка (status failed); иначе null
          oneOf:
            - $ref: '#/components/schemas/BatchJobError'
            - type: 'null'

    BatchJobError:
      type: object
      description: Job-level ошибка задачи (поле error объекта BatchJobOpenAI)
      required: [code, message]
      properties:
        code:
          type: string
          description: Машиночитаемый код (напр. invalid_request)
          example: invalid_request
        message:
          type: string
          example: No valid lines in input file

    BatchRequestCountsOpenAI:
      type: object
      description: Счётчики строк задачи в wire-форме OpenAI
      required: [total, completed, failed]
      properties:
        total:
          type: integer
          example: 100
        completed:
          type: integer
          description: Успешно исполненные строки
          example: 98
        failed:
          type: integer
          description: Строки с errored-исходом (брак, upstream-ошибки после ретраев, expired/canceled не сюда)
          example: 2

    BatchListOpenAI:
      type: object
      description: Страница списка задач OpenAI-семейства (keyset)
      required: [object, data, next_cursor]
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/BatchJobOpenAI'
        next_cursor:
          type: ['string', 'null']
          description: Курсор следующей страницы (значение `after`); null — последняя страница

    BatchDeleteResponse:
      type: object
      description: Подтверждение удаления задачи (DELETE /v1/batches/{batchId})
      required: [id, object, deleted]
      properties:
        id:
          type: string
          example: batch_7c3e91ab04d2
        object:
          type: string
          enum: [batch]
        deleted:
          type: boolean
          example: true

    BatchInputLineOpenAI:
      type: object
      description: Строка входного JSONL-файла задачи OpenAI-семейства (POST /v1/files)
      required: [custom_id, method, url, body]
      properties:
        custom_id:
          type: string
          minLength: 1
          maxLength: 256
          description: Сквозной клиентский идентификатор строки
          example: prompt-42
        method:
          type: string
          enum: [POST]
        url:
          type: string
          enum: [/v1/chat/completions]
        body:
          type: object
          description: Тело chat completion-запроса (только текстовые messages); проксируется в upstream байт-в-байт
          additionalProperties: true
          required: [model, messages]
          properties:
            model:
              type: string
              description: Идентификатор модели из GET /v1/models
              example: moonshotai/kimi-k2
            messages:
              type: array
              items:
                type: object
                additionalProperties: true
              example:
                - role: user
                  content: Привет!
            max_tokens:
              type: integer
      example:
        custom_id: prompt-42
        method: POST
        url: /v1/chat/completions
        body:
          model: moonshotai/kimi-k2
          messages:
            - role: user
              content: Привет!
          max_tokens: 256

    BatchOutputLineOpenAI:
      type: object
      description: Строка сгенерированного JSONL-файла результатов (output_file_id)
      required: [id, custom_id, response]
      properties:
        id:
          type: string
          description: Публичный id строки
          example: batch_row_3f9a2b71c8e4
        custom_id:
          type: string
          example: prompt-42
        response:
          type: object
          required: [status_code, request_id, body]
          properties:
            status_code:
              type: integer
              example: 200
            request_id:
              type: string
              example: req_8c1d44f2
            body:
              type: object
              description: OpenAI chat completion object с usage
              additionalProperties: true
      example:
        id: batch_row_3f9a2b71c8e4
        custom_id: prompt-42
        response:
          status_code: 200
          request_id: req_8c1d44f2
          body:
            id: chatcmpl-9d2f1c
            object: chat.completion
            model: moonshotai/kimi-k2
            choices:
              - index: 0
                message:
                  role: assistant
                  content: Привет!
                finish_reason: stop
            usage:
              prompt_tokens: 12
              completion_tokens: 8

    BatchErrorLineOpenAI:
      type: object
      description: Строка сгенерированного JSONL-файла ошибок (error_file_id); бракованные строки не исполняются и не тарифицируются
      required: [id, custom_id, error]
      properties:
        id:
          type: string
          example: batch_row_77aa01d5b3c9
        custom_id:
          type: string
          example: prompt-43
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [invalid_json_line, invalid_request, model_not_found, upstream_error, insufficient_funds_reserve_overrun]
            message:
              type: string
      example:
        id: batch_row_77aa01d5b3c9
        custom_id: prompt-43
        error:
          code: invalid_request
          message: 'body.messages: required'

    CreateMessageBatchRequest:
      type: object
      description: Тело POST /v1/messages/batches (Anthropic-семейство, inline-запросы)
      required: [requests]
      properties:
        requests:
          type: array
          minItems: 1
          maxItems: 100000
          description: Более 100 000 запросов или сериализованный payload > 100 MiB → 400 invalid_request_error
          items:
            $ref: '#/components/schemas/AnthropicBatchRequestLine'

    AnthropicBatchRequestLine:
      type: object
      description: Элемент inline-запроса Anthropic-семейства
      required: [custom_id, params]
      properties:
        custom_id:
          type: string
          minLength: 1
          maxLength: 256
          example: prompt-42
        params:
          $ref: '#/components/schemas/AnthropicBatchRequestParams'
      example:
        custom_id: prompt-42
        params:
          model: moonshotai/kimi-k2
          max_tokens: 256
          messages:
            - role: user
              content:
                - type: text
                  text: Привет!

    AnthropicBatchRequestParams:
      type: object
      description: |
        Параметры запроса Anthropic-формы (только текстовые messages).
        Нормализуются к OpenAI-форме: `system` → system-сообщение, массив
        text-блоков → content-партии; stream не включается никогда.
        Не перечисленные поля проксируются по смыслу вендорной схемы.
      additionalProperties: true
      required: [model, max_tokens, messages]
      properties:
        model:
          type: string
          description: Идентификатор модели из GET /v1/models
          example: moonshotai/kimi-k2
        max_tokens:
          type: integer
          minimum: 1
          example: 256
        messages:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/AnthropicInputMessage'
        system:
          description: Системный промпт — строка или массив текстовых блоков
          oneOf:
            - type: string
            - type: array
              items:
                $ref: '#/components/schemas/AnthropicTextBlock'
        temperature:
          type: number
        top_p:
          type: number
        stop_sequences:
          type: array
          items:
            type: string

    AnthropicInputMessage:
      type: object
      description: Входное сообщение Anthropic-формы (text-only)
      required: [role, content]
      properties:
        role:
          type: string
          enum: [user, assistant]
        content:
          description: Текст строкой или массив текстовых блоков
          oneOf:
            - type: string
            - type: array
              items:
                $ref: '#/components/schemas/AnthropicTextBlock'

    AnthropicTextBlock:
      type: object
      description: Текстовый блок контента Anthropic-формы
      required: [type, text]
      properties:
        type:
          type: string
          enum: [text]
        text:
          type: string
          example: Привет!

    BatchJobAnthropic:
      type: object
      description: Задача batch Anthropic-семейства (ответы /v1/messages/batches*)
      required: [id, type, created_at, processing_status, request_counts, expires_at, results_path]
      properties:
        id:
          type: string
          example: batch_7c3e91ab04d2
        type:
          type: string
          enum: [message_batch]
        created_at:
          type: string
          format: date-time
          example: '2026-10-25T10:00:00Z'
        processing_status:
          type: string
          enum: [in_progress, ended, canceling, canceled]
          description: |
            Wire-статус Anthropic-семейства: validating/queued/in_progress/
            finalizing → `in_progress`; completed/failed/expired → `ended`
            (исход различим по request_counts); cancelling → `canceling`;
            cancelled → `canceled`.
        request_counts:
          $ref: '#/components/schemas/BatchRequestCountsAnthropic'
        expires_at:
          type: string
          format: date-time
          description: Конец окна 24h от создания
          example: '2026-10-26T10:00:00Z'
        ended_at:
          type: ['string', 'null']
          format: date-time
        results_path:
          type: string
          description: Путь JSONL-потока результатов строк
          example: /v1/messages/batches/batch_7c3e91ab04d2/results

    BatchRequestCountsAnthropic:
      type: object
      description: Счётчики строк задачи в wire-форме Anthropic
      required: [processing, succeeded, errored, canceled, expired]
      properties:
        processing:
          type: integer
          description: Строки без терминального исхода
          example: 0
        succeeded:
          type: integer
          example: 98
        errored:
          type: integer
          example: 2
        canceled:
          type: integer
          example: 0
        expired:
          type: integer
          example: 0

    BatchListAnthropic:
      type: object
      description: Страница списка задач Anthropic-семейства (keyset)
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/BatchJobAnthropic'
        next_cursor:
          type: ['string', 'null']
          description: Курсор следующей страницы (значение `after`); null — последняя страница

    BatchResultLineAnthropic:
      type: object
      description: |
        Строка JSONL-потока GET /v1/messages/batches/{id}/results — терминальный
        исход соответствующей входной строки; порядок строк — по idx.
        Дискриминация исхода — поле `result.type`.
      required: [custom_id, result]
      properties:
        custom_id:
          type: string
          description: custom_id соответствующей входной строки
          example: prompt-42
        result:
          description: Терминальный исход строки
          oneOf:
            - $ref: '#/components/schemas/AnthropicBatchResultSucceeded'
            - $ref: '#/components/schemas/AnthropicBatchResultErrored'
            - $ref: '#/components/schemas/AnthropicBatchResultCanceled'
            - $ref: '#/components/schemas/AnthropicBatchResultExpired'
      example:
        custom_id: prompt-42
        result:
          type: succeeded
          message:
            id: msg_01xyz
            type: message
            role: assistant
            content:
              - type: text
                text: Привет!
            model: moonshotai/kimi-k2
            stop_reason: end_turn
            usage:
              input_tokens: 12
              output_tokens: 8

    AnthropicBatchResultSucceeded:
      type: object
      description: Успешный исход строки; тарифицируется по batch-цене (−50%)
      required: [type, message]
      properties:
        type:
          type: string
          enum: [succeeded]
        message:
          $ref: '#/components/schemas/AnthropicMessageObject'
      example:
        type: succeeded
        message:
          id: msg_01xyz
          type: message
          role: assistant
          content:
            - type: text
              text: Привет!
          model: moonshotai/kimi-k2
          stop_reason: end_turn
          usage:
            input_tokens: 12
            output_tokens: 8

    AnthropicBatchResultErrored:
      type: object
      description: Бракованный/аварийный исход строки (валидация, upstream после ретраев, перерасход резерва); не тарифицируется
      required: [type, error]
      properties:
        type:
          type: string
          enum: [errored]
        error:
          type: object
          required: [type, message]
          properties:
            type:
              type: string
              description: Вендорный тип ошибки строки
              example: invalid_request_error
            message:
              type: string
              example: 'params.messages: required'
      example:
        type: errored
        error:
          type: invalid_request_error
          message: 'params.messages: required'

    AnthropicBatchResultCanceled:
      type: object
      description: Строка отменена вместе с задачей; не тарифицируется
      required: [type]
      properties:
        type:
          type: string
          enum: [canceled]
      example:
        type: canceled

    AnthropicBatchResultExpired:
      type: object
      description: Строка не исполнена до конца окна 24h; не тарифицируется
      required: [type]
      properties:
        type:
          type: string
          enum: [expired]
      example:
        type: expired

    AnthropicMessageObject:
      type: object
      description: Сообщение-ответ Anthropic-формы (нормализовано из OpenAI-формы upstream)
      required: [id, type, role, content, model, stop_reason, usage]
      properties:
        id:
          type: string
          example: msg_01xyz
        type:
          type: string
          enum: [message]
        role:
          type: string
          enum: [assistant]
        content:
          type: array
          items:
            $ref: '#/components/schemas/AnthropicTextBlock'
        model:
          type: string
          example: moonshotai/kimi-k2
        stop_reason:
          type: string
          enum: [end_turn, max_tokens]
          description: Маппинг finish_reason — stop → end_turn, length → max_tokens
        usage:
          type: object
          required: [input_tokens, output_tokens]
          properties:
            input_tokens:
              type: integer
              example: 12
            output_tokens:
              type: integer
              example: 8

    AnthropicErrorEnvelope:
      type: object
      description: |
        Error envelope Anthropic-семейства (маршруты /v1/messages/batches*):
        HTTP-статусы из реестра кодов шлюза, тип ошибки — вендорный набор,
        расширенный `billing_error` для 402.
      required: [type, error]
      properties:
        type:
          type: string
          enum: [error]
        error:
          type: object
          required: [type, message]
          properties:
            type:
              type: string
              enum: [invalid_request_error, authentication_error, rate_limit_error, overloaded_error, api_error, billing_error]
              description: |
                Маппинг кодов шлюза: invalid_request/file_not_found/
                batch_not_found/batch_state_conflict/batch_requires_credit_key/
                model_not_found → invalid_request_error; missing_api_key/
                invalid_api_key → authentication_error; upstream_rate_limited/
                rate_limited → rate_limit_error; pricing_unavailable/
                limiter_unavailable → overloaded_error; internal_error/
                upstream_error/upstream_timeout → api_error;
                insufficient_funds → billing_error (402).
            message:
              type: string
      example:
        type: error
        error:
          type: billing_error
          message: Insufficient funds for batch reserve

    CabinetBatch:
      type: object
      description: Batch-задача в cabinet-контракте (экран K6, /v1/billing/batches*)
      required: [id, format, status, created_at, expires_at, counts, charged_rub, would_be_rub]
      properties:
        id:
          type: string
          example: batch_7c3e91ab04d2
        format:
          type: string
          enum: [openai, anthropic]
          description: Вендорное семейство задачи
        status:
          type: string
          enum: [validating, queued, in_progress, finalizing, completed, failed, expired, cancelling, cancelled]
          description: Внутренний enum жизненного цикла; `queued` различим только здесь
        created_at:
          type: string
          format: date-time
          example: '2026-10-25T10:00:00Z'
        expires_at:
          type: string
          format: date-time
          example: '2026-10-26T10:00:00Z'
        completed_at:
          type: ['string', 'null']
          format: date-time
        counts:
          $ref: '#/components/schemas/CabinetBatchCounts'
        charged_rub:
          type: string
          pattern: '^\d+\.\d{2}$'
          description: Фактически списано (batch-цена, −50%), decimal-строка ₽ с двумя знаками
          example: '0.00'
        would_be_rub:
          type: string
          pattern: '^\d+\.\d{2}$'
          description: «Было бы» по интерактивной цене тех же единиц, decimal-строка ₽ с двумя знаками
          example: '0.00'
        input_file_id:
          type: ['string', 'null']
          description: Входной файл (OpenAI-семейство)
          example: file_9f2c1a3e8b7d4c2a
        output_file_id:
          type: ['string', 'null']
          description: Файл результатов (OpenAI-семейство)
        error_file_id:
          type: ['string', 'null']
          description: Файл ошибок строк (OpenAI-семейство)
        results_path:
          type: ['string', 'null']
          description: Путь JSONL-потока результатов (Anthropic-семейство)
          example: /v1/messages/batches/batch_7c3e91ab04d2/results

    CabinetBatchCounts:
      type: object
      description: Счётчики строк в cabinet-контракте (completed = succeeded)
      required: [total, completed, errored, canceled, expired]
      properties:
        total:
          type: integer
          example: 100
        completed:
          type: integer
          example: 98
        errored:
          type: integer
          example: 2
        canceled:
          type: integer
          example: 0
        expired:
          type: integer
          example: 0

    CabinetBatchList:
      type: object
      description: Страница cabinet-списка batch-задач (keyset)
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/CabinetBatch'
        next_cursor:
          type: ['string', 'null']
          description: Курсор следующей страницы (значение `after`); null — последняя страница

    ErrorBody:
      type: object
      required: [message, type, code]
      properties:
        message:
          type: string
          description: Человекочитаемое сообщение (на английском — клиенты машинные)
        type:
          type: string
          description: |
            Класс ошибки: invalid_request_error | authentication_error |
            rate_limit_error | billing_error | server_error
        code:
          type: string
          description: |
            Машиночитаемый код: invalid_request | missing_api_key | invalid_api_key |
            model_not_found | request_too_large | upstream_rate_limited |
            internal_error | upstream_error | upstream_timeout |
            insufficient_funds | pricing_unavailable |
            insufficient_role | org_not_found | org_state_conflict |
            invalid_document_request | document_not_found |
            document_state_conflict | edi_unavailable | usage_series_unavailable |
            weak_password | rate_limited | invalid_credentials |
            account_disabled | invalid_token | session_required |
            invite_email_mismatch | invite_limit_exceeded |
            invalid_notification_settings | key_spending_limit_exceeded |
            subscription_window_exhausted | limiter_unavailable |
            file_not_found | batch_not_found | batch_state_conflict |
            batch_requires_credit_key
        retry_after:
          type: integer
          description: |
            Аддитивное поле отдельных 429-кодов (subscription_window_exhausted,
            key_spending_limit_exceeded): через сколько секунд наступает сброс;
            дублирует заголовок Retry-After.
        resets_at:
          type: string
          format: date-time
          description: |
            Аддитивное поле отдельных 429-кодов (subscription_window_exhausted,
            key_spending_limit_exceeded): момент сброса (RFC3339; для
            key_spending_limit_exceeded — 1-е число следующего UTC-месяца).
        window:
          type: string
          enum: [short, month]
          description: |
            Аддитивное поле 429 `subscription_window_exhausted`: имя
            исчерпанного окна подписки.
        other_window:
          type: object
          description: |
            Аддитивное поле 429 `subscription_window_exhausted`: состояние
            второго (не исчерпанного) окна подписки.
          required: [window, remaining_tokens, limit_tokens]
          properties:
            window:
              type: string
              enum: [short, month]
            remaining_tokens:
              type: integer
              example: 75000
            limit_tokens:
              type: integer
              example: 100000

    Error:
      type: object
      description: Единый error envelope OpenAI-формы для всех эндпоинтов /v1/*
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'

    HealthStatus:
      type: object
      properties:
        status:
          type: string
          example: ok

    NotReadyStatus:
      type: object
      properties:
        status:
          type: string
          example: not_ready
        detail:
          type: string
          example: postgres ping failed

# GENERATED BY scripts/docs-error-codes.mjs — DO NOT EDIT (BEGIN x-error-codes)
x-error-codes:
  - code: invalid_request
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: core
  - code: missing_api_key
    http: 401
    type: authentication_error
    retry: 'no'
    group: core
  - code: invalid_api_key
    http: 401
    type: authentication_error
    retry: 'no'
    group: core
  - code: model_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: core
  - code: request_too_large
    http: 413
    type: invalid_request_error
    retry: 'no'
    group: core
  - code: insufficient_funds
    http: 402
    type: billing_error
    retry: 'no'
    group: core
  - code: upstream_rate_limited
    http: 429
    type: rate_limit_error
    retry: 'retry-after'
    group: core
  - code: internal_error
    http: 500
    type: server_error
    retry: 'backoff'
    group: core
  - code: pricing_unavailable
    http: 503
    type: server_error
    retry: 'backoff'
    group: core
  - code: upstream_error
    http: 502
    type: server_error
    retry: 'backoff'
    group: core
  - code: upstream_timeout
    http: 504
    type: server_error
    retry: 'backoff'
    group: core
  - code: invalid_payment
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: payments
  - code: admin_required
    http: 403
    type: authentication_error
    retry: 'no'
    group: payments
  - code: payment_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: payments
  - code: payment_state_conflict
    http: 409
    type: invalid_request_error
    retry: 'no'
    group: payments
  - code: payment_provider_error
    http: 502
    type: server_error
    retry: 'backoff'
    group: payments
  - code: byok_validation_failed
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: byok
  - code: byok_key_invalid
    http: 401
    type: authentication_error
    retry: 'no'
    group: byok
  - code: byok_key_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: byok
  - code: subscription_window_exhausted
    http: 429
    type: rate_limit_error
    retry: 'retry-after'
    group: subscription
  - code: limiter_unavailable
    http: 503
    type: server_error
    retry: 'backoff'
    group: subscription
  - code: subscription_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: subscription
  - code: plan_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: subscription
  - code: subscription_state_conflict
    http: 409
    type: invalid_request_error
    retry: 'no'
    group: subscription
  - code: payment_method_required
    http: 400
    type: invalid_request_error
    retry: 'fix-then-retry'
    group: subscription
  - code: org_subscription_invoice_pending
    http: 409
    type: invalid_request_error
    retry: 'no'
    group: subscription
  - code: insufficient_role
    http: 403
    type: authentication_error
    retry: 'no'
    group: orgs
  - code: org_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: orgs
  - code: org_state_conflict
    http: 409
    type: invalid_request_error
    retry: 'no'
    group: orgs
  - code: usage_series_unavailable
    http: 503
    type: server_error
    retry: 'backoff'
    group: usage
  - code: invalid_document_request
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: edi
  - code: document_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: edi
  - code: document_state_conflict
    http: 409
    type: invalid_request_error
    retry: 'no'
    group: edi
  - code: edi_unavailable
    http: 503
    type: server_error
    retry: 'backoff'
    group: edi
  - code: session_required
    http: 401
    type: authentication_error
    retry: 'no'
    group: accounts
  - code: invalid_credentials
    http: 401
    type: authentication_error
    retry: 'no'
    group: accounts
  - code: account_disabled
    http: 403
    type: authentication_error
    retry: 'no'
    group: accounts
  - code: invalid_token
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: accounts
  - code: weak_password
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: accounts
  - code: rate_limited
    http: 429
    type: rate_limit_error
    retry: 'retry-after'
    group: accounts
  - code: account_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: admin
  - code: key_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: keys
  - code: email_not_verified
    http: 403
    type: authentication_error
    retry: 'no'
    group: keys
  - code: key_limit_exceeded
    http: 400
    type: invalid_request_error
    retry: 'fix-then-retry'
    group: keys
  - code: invalid_notification_settings
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: notifications
  - code: key_spending_limit_exceeded
    http: 429
    type: rate_limit_error
    retry: 'no'
    group: spend-limits
  - code: invite_email_mismatch
    http: 403
    type: invalid_request_error
    retry: 'fix-then-retry'
    group: invites
  - code: invite_limit_exceeded
    http: 400
    type: invalid_request_error
    retry: 'fix-then-retry'
    group: invites
  - code: file_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: batch
  - code: batch_not_found
    http: 404
    type: invalid_request_error
    retry: 'no'
    group: batch
  - code: batch_state_conflict
    http: 409
    type: invalid_request_error
    retry: 'no'
    group: batch
  - code: batch_requires_credit_key
    http: 400
    type: invalid_request_error
    retry: 'no'
    group: batch
# GENERATED BY scripts/docs-error-codes.mjs — DO NOT EDIT (END x-error-codes)
