SPEECH2TEXT Документация API

Speech2Text API - Полная документация

Базовый URL

http://localhost:8020

Аутентификация

Все защищенные endpoints требуют JWT токен в заголовке Authorization:

Authorization: Bearer YOUR_JWT_TOKEN

Для программного доступа в том же заголовке передаётся API-ключ:

Authorization: Bearer sk-XXXXXXXXXXXXXXXX...

Ключу можно задать секретное слово и потребовать, чтобы каждый запрос этим ключом был подписан, — см. Подпись HMAC-SHA256. По умолчанию подпись выключена, и ключ работает как раньше.


Содержание

  1. Аутентификация
  2. Транскрибация
  3. Управление пользователями
  4. API ключи
  5. Метрики
  6. Администрирование
  7. Перевод на русский (LLM)
  8. Загрузка по ссылке и обратный вызов
  9. Подпись HMAC-SHA256

1. Аутентификация

Вход в систему

POST /auth/login

Получение JWT токена для доступа к API.

Параметры запроса:

{
  "username": "string",
  "password": "string"
}

Ответ:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}

Примеры:

Python:

import requests

url = "http://localhost:8020/auth/login"
data = {
    "username": "admin",
    "password": "admin123"
}

response = requests.post(url, json=data)
token = response.json()["access_token"]
print(f"Token: {token}")

PHP:

<?php
$url = "http://localhost:8020/auth/login";
$data = array(
    "username" => "admin",
    "password" => "admin123"
);

$options = array(
    'http' => array(
        'header'  => "Content-type: application/json\r\n",
        'method'  => 'POST',
        'content' => json_encode($data)
    )
);

$context  = stream_context_create($options);
$result = file_get_contents($url, false, $context);
$response = json_decode($result, true);
$token = $response["access_token"];
echo "Token: " . $token;
?>

JavaScript:

async function login() {
    const response = await fetch('http://localhost:8020/auth/login', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            username: 'admin',
            password: 'admin123'
        })
    });

    const data = await response.json();
    const token = data.access_token;
    console.log('Token:', token);
    return token;
}

cURL:

curl -X POST "http://localhost:8020/auth/login" \
     -H "Content-Type: application/json" \
     -d '{"username":"admin","password":"admin123"}'

Регистрация пользователя

POST /auth/register

Создание нового пользователя.

Параметры запроса:

{
  "username": "string",
  "email": "user@example.com",
  "password": "string"
}

Ответ:

{
  "id": "uuid",
  "username": "string",
  "email": "user@example.com",
  "is_active": true,
  "is_admin": false,
  "created_at": "2024-01-01T00:00:00"
}

Примеры:

Python:

import requests

url = "http://localhost:8020/auth/register"
data = {
    "username": "newuser",
    "email": "newuser@example.com",
    "password": "secure_password123"
}

response = requests.post(url, json=data)
user = response.json()
print(f"Created user: {user['username']} with ID: {user['id']}")

JavaScript:

async function registerUser() {
    const response = await fetch('http://localhost:8020/auth/register', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            username: 'newuser',
            email: 'newuser@example.com',
            password: 'secure_password123'
        })
    });

    const user = await response.json();
    console.log('Created user:', user);
    return user;
}

Получить текущего пользователя

GET /auth/me

Получение информации о текущем авторизованном пользователе.

Заголовки:

Authorization: Bearer YOUR_JWT_TOKEN

Ответ:

{
  "id": "uuid",
  "username": "string",
  "email": "user@example.com",
  "is_active": true,
  "is_admin": false,
  "created_at": "2024-01-01T00:00:00"
}

Примеры:

Python:

import requests

url = "http://localhost:8020/auth/me"
headers = {
    "Authorization": f"Bearer {token}"
}

response = requests.get(url, headers=headers)
user = response.json()
print(f"Current user: {user['username']}")

2. Транскрибация

Что можно загружать: форматы, видео, размер

Принимаются расширения .mp3, .mp4, .wav, .m4a, .ogg, .flac, .mp2, .mov, .mxf, .webm. Всё, что ниже, одинаково относится и к POST /transcribe/, и к POST /transcribe/async, и к загрузке по ссылке (url).

Видеофайлы отдавайте как есть. .mov, .mxf, .webm (и .mp4) — это видеоконтейнеры; звук из них извлекает сам сервис через ffmpeg. Отдельно вырезать звуковую дорожку перед загрузкой не нужно.

Несколько звуковых дорожек сводятся в одну. Вещательный MXF обычно несёт по отдельной моно-дорожке на микрофон. Сервис смешивает все дорожки (с усреднением громкости), а не выбирает первую попавшуюся, — иначе целый микрофон, то есть целый говорящий, выпал бы из расшифровки. Одинаковые дорожки при сведении звучат ровно как исходная, перегрузки по громкости не возникает. Выбрать конкретную дорожку через API нельзя.

Размер. Лимит — MAX_FILE_SIZE_MB, по умолчанию 5000 МБ (раньше 500). Видеоконтейнер с материалом целиком — это гигабайты, тогда как звук в нём занимает проценты; раз извлечением занимается сервис, лимит пропускает исходник. Тем же лимитом ограничена и загрузка по ссылке. Превышение — 413. Исключение — ссылка в POST /transcribe/async: её файл скачивается уже после ответа 202, поэтому слишком большой файл не даёт 413, а переводит задачу в failed (см. раздел 8).

Файл без звука. Если в файле нет ни одной звуковой дорожки, задача завершается со статусом failed, а в error_message приходит: «В файле нет звуковой дорожки — распознавать нечего.»


Транскрибировать аудио

POST /transcribe/

Загрузка и транскрибация аудио- или видеофайла.

Параметры:

Ответ:

{
  "id": "uuid",
  "filename": "audio.mp3",
  "text": "Транскрибированный текст...",
  "language": "ru",
  "duration": 120.5,
  "processing_time": 15.3,
  "status": "completed",
  "created_at": "2024-01-01T00:00:00",
  "segments": [
    {
      "id": 0,
      "start": 0.0,
      "end": 5.2,
      "text": "Первый сегмент текста",
      "speaker": "SPEAKER_01",  // Только если diarize=true
      "words": [  // Только если return_timestamps=true
        {
          "word": "Первый",
          "start": 0.0,
          "end": 0.8,
          "probability": 0.95
        },
        {
          "word": "сегмент",
          "start": 0.8,
          "end": 1.5,
          "probability": 0.98
        }
      ]
    }
  ],
  "has_diarization": true,  // Только если diarize=true
  "diarization": {          // Только если diarize=true
    "num_speakers": 2,
    "speakers": [
      {"id": "SPEAKER_01", "label": "Собеседник 1", "talk_time": 75.4, "segments": 12},
      {"id": "SPEAKER_02", "label": "Собеседник 2", "talk_time": 44.1, "segments": 11}
    ],
    "text_with_speakers": "Собеседник 1: Здравствуйте!...\n\nСобеседник 2: Добрый день..."
  },
  "language_detection": null,  // Результат автоопределения — только при language=auto
  "translation": null          // Перевод (LLM) — только если запрошен translate_to
}

Поле language содержит код, с которым запись была распознана. Поля language_detection и translation присутствуют в ответе всегда и равны null, если автоопределение и перевод не запрашивались. Их формат описан в разделе 7. Перевод на русский (LLM).

В ответе (и в GET /transcriptions/{id}, и в теле обратного вызова) присутствуют ещё поля источника и доставки — они заполняются, только если запись пришла по ссылке и/или был заказан callback:

Поле Значение
source_url Ссылка, с которой скачано аудио, с вырезанными секретами (null для загрузки файлом)
callback_url Куда доставляется результат (null, если вызов не заказан)
callback_status null · pending · sending · delivered · failed
callback_attempts Сколько попыток доставки сделано
callback_error Причина последней неудачи
callback_delivered_at Когда доставлено (UTC)

Пример с диаризацией (разделение собеседников):

data = {
    "language": "ru",
    "diarize": "true",       # Включить разделение по собеседникам
    "num_speakers": "2"      # Опционально: точное число собеседников
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()

print(result["diarization"]["text_with_speakers"])  # Готовый диалог
for seg in result["segments"]:
    print(f"[{seg.get('speaker')}] {seg['start']:.1f}-{seg['end']:.1f}: {seg['text']}")

Пример с автоопределением языка и переводом на русский:

data = {
    "language": "auto",      # Определить язык записи автоматически
    "translate_to": "ru"     # После распознавания перевести текст на русский (LLM)
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()

print(result["language"])                      # Язык записи, например "en"
translation = result["translation"]
if translation and translation["status"] == "completed":
    print(translation["text"])                 # Перевод на русский

Примеры:

Python:

import requests

url = "http://localhost:8020/transcribe/"
headers = {
    "Authorization": f"Bearer {token}"
}

with open("audio.mp3", "rb") as audio_file:
    files = {"file": audio_file}
    data = {
        "language": "ru",
        "return_segments": "true",
        "return_timestamps": "true"  # Включить пословные метки времени
    }

    response = requests.post(url, headers=headers, files=files, data=data)
    result = response.json()

    print(f"Transcription ID: {result['id']}")
    print(f"Text: {result['text'][:100]}...")
    print(f"Duration: {result['duration']} seconds")

    # Обработка меток времени
    if "segments" in result:
        for segment in result["segments"]:
            print(f"\nSegment {segment['id']}: {segment['start']:.1f}s - {segment['end']:.1f}s")
            if "words" in segment:
                for word in segment["words"]:
                    print(f"  {word['word']}: {word['start']:.1f}s - {word['end']:.1f}s")

PHP:

<?php
$url = "http://localhost:8020/transcribe/";
$token = "YOUR_JWT_TOKEN";

$file = new CURLFile('audio.mp3');
$data = array(
    'file' => $file,
    'language' => 'ru',
    'return_segments' => 'false'
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    "Authorization: Bearer " . $token
));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);

$result = json_decode($response, true);
echo "Text: " . $result['text'];
?>

JavaScript:

async function transcribeAudio(file, token) {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('language', 'ru');
    formData.append('return_segments', 'false');

    const response = await fetch('http://localhost:8020/transcribe/', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${token}`
        },
        body: formData
    });

    const result = await response.json();
    console.log('Transcription:', result.text);
    return result;
}

// Использование с input файла
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async (e) => {
    const file = e.target.files[0];
    await transcribeAudio(file, token);
});

cURL:

curl -X POST "http://localhost:8020/transcribe/" \
     -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     -F "file=@audio.mp3" \
     -F "language=ru" \
     -F "return_segments=false"

⏳ Асинхронная транскрибация (очередь)

POST /transcribe/async

Ставит запись в очередь и сразу возвращает задачу со статусом pending, не удерживая соединение. Параметры — те же, что у /transcribe/, включая url и callback_url (кроме wait_translation: асинхронный вызов ничего не ждёт). Перевод, запрошенный через translate_to, выполняется в фоне после распознавания — его состояние отдаёт GET /transcriptions/{id}/translation.

Именно здесь пара url + callback_url работает как задумано: клиент отдаёт ссылку на запись и адрес приёмника, получает id и больше ничего не опрашивает — готовый текст приедет сам (см. раздел 8).

Ссылка (url) скачивается уже после ответа. До ответа 202 сервис только проверяет запрос: ровно один источник (file или url), translate_to, callback_url, границы num_speakers/min_speakers/max_speakers и саму ссылку — схема http(s), корректный адрес с хостом, хост резолвится и не ведёт в приватную сеть (FETCH_ALLOW_PRIVATE_HOSTS). Эти отказы по-прежнему приходят кодом 400/403 на сам запрос. Всё остальное — ответ источника 4xx/5xx, пустой файл, размер больше MAX_FILE_SIZE_MB, дедлайн, неподдерживаемый тип файла, редирект на запрещённый адрес — выясняется в фоне и кодом ответа уже не становится: задача переходит в failed, а причина лежит в error_message — «Не удалось скачать файл по ссылке: …» (подробно — в разделе 8). Пока файл скачивается, задача остаётся pending с progress = 0 и queue_position = null; в очередь она встаёт, когда файл получен. Загрузка файлом (file) работает как раньше.

Все загрузки (и синхронные, и асинхронные) проходят через одну персистентную очередь: задача хранится в базе вместе с параметрами запуска, поэтому перезапуск или отключение питания её не теряет — при следующем старте сервер сам возвращает прерванные задачи в очередь и доводит их до конца. Одновременно обрабатывается одна запись, остальные ждут своей очереди. Скачивание по ссылке, прерванное перезапуском, при следующем старте начинается заново, с начала (докачки нет); presigned-ссылка, истёкшая за это время, даёт failed с HTTP-ошибкой источника.

Ответ (202 Accepted):

{
  "id": "uuid",
  "filename": "audio.mp3",
  "status": "pending",
  "text": null,
  "created_at": "2026-07-27T17:31:17"
}

Для url поле filename в ответе предварительное: последний сегмент пути ссылки (или audio, если его нет). После скачивания его заменяет имя, которое отдал источник, — из Content-Disposition, иначе из пути конечного адреса после редиректов.

Жизненный цикл задачи:

pending → processing → completed | failed | cancelled

У задачи с url стадия pending начинается со скачивания: pending (файл скачивается) → pending (в очереди) → processing → completed | failed | cancelled. Неудачное скачивание — сразу pending → failed. Отмена задачи администратором или DELETE /transcriptions/{id} во время скачивания останавливают его, и задача уже не оживает. Пока файла нет, GET /transcriptions/{id}/audio отвечает 404, а GET /transcriptions/{id}/download — 400.

Прогресс и позицию в очереди отдаёт GET /transcriptions/{id}/status, готовый результат — GET /transcriptions/{id}.

Пример:

import requests, time

headers = {"Authorization": f"Bearer {token}"}

with open("audio.mp3", "rb") as f:
    job = requests.post(
        "http://localhost:8020/transcribe/async",
        headers=headers,
        files={"file": f},
        data={"language": "ru", "return_timestamps": True},
    ).json()

while True:
    state = requests.get(
        f"http://localhost:8020/transcriptions/{job['id']}/status", headers=headers
    ).json()
    if state["status"] in ("completed", "failed", "cancelled"):
        break
    print(f"{state['status']}: {state['progress']}%, в очереди перед вами: {state['queue_position']}")
    time.sleep(5)

result = requests.get(
    f"http://localhost:8020/transcriptions/{job['id']}", headers=headers
).json()
print(result["text"])

Синхронный POST /transcribe/ работает как раньше и возвращает готовый текст, но если обработка не уложилась в QUEUE_SYNC_WAIT_SECONDS (по умолчанию час), он ответит 504 с id задачи — задача при этом не теряется и продолжает выполняться.


История транскрибаций

GET /transcribe/history

Получение списка всех транскрибаций пользователя.

Параметры:

Ответ:

[
  {
    "id": "uuid",
    "filename": "audio.mp3",
    "status": "completed",
    "duration": 120.5,
    "language": "ru",
    "created_at": "2024-01-01T00:00:00",
    "translation_status": null  // Статус перевода (LLM) или null, если перевод не запрашивался
  }
]

Примеры:

Python:

import requests

url = "http://localhost:8020/transcribe/history"
headers = {
    "Authorization": f"Bearer {token}"
}
params = {
    "skip": 0,
    "limit": 50
}

response = requests.get(url, headers=headers, params=params)
history = response.json()

for item in history:
    print(f"{item['filename']} - {item['status']} - {item['created_at']}")

Получить транскрибацию

GET /transcribe/{transcription_id}

Получение полной информации о конкретной транскрибации.

Ответ:

{
  "id": "uuid",
  "filename": "audio.mp3",
  "text": "Полный текст транскрибации...",
  "language": "ru",
  "duration": 120.5,
  "processing_time": 15.3,
  "status": "completed",
  "created_at": "2024-01-01T00:00:00",
  "segments": []
}

Стриминг аудио

GET /transcriptions/{transcription_id}/audio

Получение аудиофайла для воспроизведения.

Примеры:

Python:

import requests

url = f"http://localhost:8020/transcriptions/{transcription_id}/audio"
headers = {
    "Authorization": f"Bearer {token}"
}

response = requests.get(url, headers=headers, stream=True)

# Сохранить аудио
with open("downloaded_audio.mp3", "wb") as f:
    for chunk in response.iter_content(chunk_size=8192):
        f.write(chunk)

JavaScript:

async function playAudio(transcriptionId, token) {
    const response = await fetch(
        `http://localhost:8020/transcriptions/${transcriptionId}/audio`,
        {
            headers: {
                'Authorization': `Bearer ${token}`
            }
        }
    );

    const blob = await response.blob();
    const audioUrl = URL.createObjectURL(blob);

    const audio = new Audio(audioUrl);
    audio.play();
}

Статус транскрибации

GET /transcriptions/{transcription_id}/status

Проверка статуса обработки транскрибации.

Ответ:

{
  "id": "uuid",
  "status": "processing",
  "progress": 42,
  "queue_position": null,
  "attempts": 1,
  "filename": "audio.mp3",
  "created_at": "2024-01-01T00:00:00",
  "queued_at": "2024-01-01T00:00:00",
  "started_at": "2024-01-01T00:00:05",
  "completed_at": null,
  "duration": null,
  "processing_time": null,
  "error_message": null,
  "translation_status": null,
  "translation_progress": null,
  "translation_error": null
}

Примеры:

Python:

import requests
import time

def wait_for_transcription(transcription_id, token):
    url = f"http://localhost:8020/transcriptions/{transcription_id}/status"
    headers = {"Authorization": f"Bearer {token}"}

    while True:
        response = requests.get(url, headers=headers)
        status = response.json()

        if status['status'] == 'completed':
            print("Transcription completed!")
            return True
        elif status['status'] == 'failed':
            print(f"Transcription failed: {status['error_message']}")
            return False

        print(f"Status: {status['status']}... waiting")
        time.sleep(2)

Скачать транскрибацию

GET /transcriptions/{transcription_id}/download

Скачивание текста транскрибации в различных форматах.

Параметры:

Имя файла передаётся в Content-Disposition. Имя только из латиницы, цифр и знаков ._- (token по RFC 7230) — в прежнем виде, attachment; filename=interview_transcription.txt. Любое другое — по RFC 6266: ASCII-замена в кавычках в filename и точное имя в filename*=UTF-8''…, поэтому файлы с кириллицей или пробелами в имени скачиваются корректно.

Примеры:

Python:

import requests

# Скачать как текст
url = f"http://localhost:8020/transcriptions/{transcription_id}/download?format=txt"
headers = {"Authorization": f"Bearer {token}"}

response = requests.get(url, headers=headers)
with open("transcription.txt", "w", encoding="utf-8") as f:
    f.write(response.text)

# Скачать как субтитры SRT
url = f"http://localhost:8020/transcriptions/{transcription_id}/download?format=srt"
response = requests.get(url, headers=headers)
with open("transcription.srt", "w", encoding="utf-8") as f:
    f.write(response.text)

3. Управление пользователями

Список пользователей (Admin)

GET /admin/users

Получение списка всех пользователей (требуются права администратора).

Ответ:

[
  {
    "id": "uuid",
    "username": "user1",
    "email": "user1@example.com",
    "is_active": true,
    "is_admin": false,
    "created_at": "2024-01-01T00:00:00"
  }
]

Обновить пользователя (Admin)

PATCH /admin/users/{user_id}

Обновление данных пользователя.

Параметры:

{
  "username": "new_username",
  "email": "new@example.com",
  "password": "new_password",
  "is_active": true,
  "is_admin": false
}

Примеры:

Python:

import requests

url = f"http://localhost:8020/admin/users/{user_id}"
headers = {
    "Authorization": f"Bearer {admin_token}",
    "Content-Type": "application/json"
}
data = {
    "is_active": False,
    "is_admin": True
}

response = requests.patch(url, headers=headers, json=data)
updated_user = response.json()
print(f"Updated user: {updated_user}")

Удалить пользователя (Admin)

DELETE /admin/users/{user_id}

Удаление пользователя из системы.

Примеры:

JavaScript:

async function deleteUser(userId, token) {
    const response = await fetch(`http://localhost:8020/admin/users/${userId}`, {
        method: 'DELETE',
        headers: {
            'Authorization': `Bearer ${token}`
        }
    });

    if (response.ok) {
        console.log('User deleted successfully');
    } else {
        console.error('Failed to delete user');
    }
}

4. API Ключи

Создать API ключ

POST /api-keys/

Создание нового API ключа для программного доступа.

Параметры:

{
  "name": "My Application",
  "permissions": ["transcribe", "read"],
  "rate_limit": 100,
  "expires_in_days": 365,

  "webhook_secret": null,
  "generate_webhook_secret": false,
  "sign_callbacks": false,
  "require_request_signature": false
}
Поле подписи По умолчанию Описание
webhook_secret null Своё секретное слово, от 8 до 256 символов (иначе 422)
generate_webhook_secret false Пусть секрет придумает сервер: whsec_ + 43 случайных символа
sign_callbacks false Подписывать исходящие вызовы на callback_url
require_request_signature false Требовать подпись у входящих запросов этим ключом

Задать одновременно webhook_secret и generate_webhook_secret нельзя, включить любой из выключателей без секретного слова — тоже (400, тексты — в разделе 9).

Ответ:

{
  "id": "uuid",
  "name": "My Application",
  "api_key": "sk-abcdef123456789...",
  "key_prefix": "sk-abcdef1...",
  "is_active": true,
  "permissions": ["transcribe", "read"],
  "rate_limit": 100,
  "created_at": "2024-01-01T00:00:00",
  "last_used_at": null,
  "expires_at": "2025-01-01T00:00:00",

  "sign_callbacks": false,
  "require_request_signature": false,
  "webhook_secret_set": false,
  "webhook_secret_hint": null,
  "webhook_secret": null
}

Важно: Полный ключ (api_key) показывается только один раз при создании!

Поле webhook_secret заполняется, только если секрет был задан или сгенерирован этим же вызовом: его надо увидеть один раз, чтобы передать интеграции. В списке ключей его больше не будет — там видно лишь webhook_secret_set (есть ли секрет) и маску webhook_secret_hint (whsec_••••••••). Полное значение отдаёт отдельная ручка GET /api-keys/{key_id}/webhook-secret.

Примеры:

Python:

import requests

url = "http://localhost:8020/api-keys/"
headers = {
    "Authorization": f"Bearer {token}",
    "Content-Type": "application/json"
}
data = {
    "name": "Python Client",
    "permissions": ["transcribe"],
    "rate_limit": 1000,
    "expires_in_days": 90
}

response = requests.post(url, headers=headers, json=data)
api_key = response.json()

print(f"API Key created: {api_key['key']}")
print("Save this key securely! It won't be shown again.")

# Использование API ключа вместо JWT токена
api_headers = {
    "X-API-Key": api_key['key']
}

Список API ключей

GET /api-keys/

Получение списка всех API ключей пользователя.

Ответ:

[
  {
    "id": "uuid",
    "name": "My Application",
    "key_prefix": "sk-abcdef1...",
    "is_active": true,
    "permissions": ["transcribe", "read"],
    "rate_limit": 100,
    "created_at": "2024-01-01T00:00:00",
    "last_used_at": "2024-01-15T12:00:00",
    "expires_at": "2025-01-01T00:00:00",
    "sign_callbacks": true,
    "require_request_signature": false,
    "webhook_secret_set": true,
    "webhook_secret_hint": "whsec_••••••••"
  }
]

Сам секрет в список не попадает никогда — только признак его наличия и маска.


Изменить ключ (имя и настройки подписи)

PATCH /api-keys/{key_id}

Меняет только переданные поля; чего нет в теле — остаётся как было. Доступны только собственные ключи пользователя, иначе 404 {"detail": "API-ключ не найден"}.

Параметры:

{
  "name": "Production key",
  "webhook_secret": "my-shared-secret",
  "generate_webhook_secret": false,
  "sign_callbacks": true,
  "require_request_signature": false
}
Поле Значение
name Новое имя. null — не менять
webhook_secret Новое секретное слово. null — не менять, "" — удалить секрет
generate_webhook_secret true — сервер сам придумает новый секрет и вернёт его в ответе
sign_callbacks true/false — включить/выключить подпись callback. null — не менять
require_request_signature true/false — требовать подпись входящих. null — не менять

Ответ:

Запись ключа (APIKeyFullResponse, то есть с полем api_key) с актуальными sign_callbacks, require_request_signature, webhook_secret_set, webhook_secret_hint. Поле webhook_secret заполняется только при generate_webhook_secret: true — своё слово вызывающий и так знает, а сгенерированное больше нигде не показать.

Ошибки 400:

Текст Когда
Укажите либо своё секретное слово, либо генерацию — но не оба сразу Переданы и webhook_secret, и generate_webhook_secret: true
Включить подпись нельзя: у ключа не задано секретное слово Итоговое состояние — выключатель включён, секрета нет
Сначала выключите подпись — без секретного слова она не работает webhook_secret: "" при хотя бы одном включённом выключателе

Чтобы удалить секрет, гасите подпись тем же запросом:

curl -X PATCH "http://localhost:8020/api-keys/$KEY_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"webhook_secret":"","sign_callbacks":false,"require_request_signature":false}'

Показать секретное слово

GET /api-keys/{key_id}/webhook-secret

Возвращает секрет полностью — его нужно как-то передать той стороне, которая будет проверять подписи. Только для своих ключей (иначе 404). Факт раскрытия попадает в лог сервера (кто и у какого ключа смотрел; само значение в лог не пишется).

Ответ:

{
  "id": "uuid",
  "webhook_secret": "whsec_2Vd1kZ0oQ7pR..."
}

Если секрет не задан — "webhook_secret": null.


5. Метрики

Системные метрики

GET /metrics/system

Получение метрик системы и статистики использования.

Ответ:

{
  "cpu_usage": 45.2,
  "memory_usage": 62.8,
  "disk_usage": 35.1,
  "gpu_available": true,
  "gpu_memory_used": "8.5 GB",
  "whisper_model": "large-v3",
  "active_users": 12,
  "total_transcriptions_24h": 156,
  "average_processing_time": 12.3,
  "uptime_seconds": 86400
}

Примеры:

Python:

import requests

url = "http://localhost:8020/metrics/system"
headers = {"Authorization": f"Bearer {token}"}

response = requests.get(url, headers=headers)
metrics = response.json()

print(f"CPU Usage: {metrics['cpu_usage']}%")
print(f"GPU Available: {metrics['gpu_available']}")
print(f"Model: {metrics['whisper_model']}")
print(f"Transcriptions (24h): {metrics['total_transcriptions_24h']}")

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

GET /metrics/user

Получение статистики использования для текущего пользователя.

Ответ:

{
  "total_transcriptions": 523,
  "total_duration_seconds": 125840.5,
  "total_characters": 2456789,
  "total_api_calls": 1523,
  "monthly_transcriptions": 45,
  "monthly_duration_seconds": 10234.2,
  "monthly_characters": 234567,
  "monthly_api_calls": 156,
  "last_reset_date": "2024-01-01T00:00:00"
}

6. Администрирование

Все транскрибации (Admin)

GET /admin/transcriptions

Получение списка всех транскрибаций в системе.

Ответ:

[
  {
    "id": "uuid",
    "filename": "audio.mp3",
    "status": "completed",
    "duration": 120.5,
    "language": "ru",
    "created_at": "2024-01-01T00:00:00",
    "user_id": "uuid",
    "username": "user1",
    "translation_status": null  // Статус перевода (LLM) или null
  }
]

Административные эндпоинты перевода (настройки, очередь переводов, повтор и отмена) описаны в разделе 7. Перевод на русский (LLM).


7. Перевод на русский (LLM)

Сервис может перевести готовую расшифровку на русский язык с помощью большой языковой модели. Язык записи указывается при загрузке или определяется автоматически. Переводит OpenAI-совместимый сервер LLM (/v1/chat/completions) — штатно LM Studio с моделью Qwen3.6-35B-A3B; подойдут и vLLM, Ollama и другие совместимые серверы.

Как это устроено:

Исходный язык и автоопределение (language)

Поле language одинаково работает во всех эндпоинтах загрузки: /transcribe/, /transcribe/async, /v1/audio/transcriptions и /v1/audio/translations.

Значение language Что происходит
код языка (en, de, ru, …) запись распознаётся на указанном языке — как раньше
auto или detect (регистр не важен) язык определяется автоматически, затем запись распознаётся на нём
не передано или пустое язык сервера по умолчанию (WHISPER_DEFAULT_LANGUAGE, обычно ru), без автоопределения — как раньше
не передано или пустое, но задан translate_to auto: при переводе язык записи определяется автоматически

У /v1/audio/translations значение language по умолчанию — auto.

Как работает автоопределение. Встроенное автоопределение Whisper в сервисе не используется: на части записей оно приводило к пропуску первых 6–30 секунд речи. Вместо него сервис перед распознаванием берёт до четырёх 30-секундных фрагментов из разных частей записи (для записей до 45 секунд — один фрагмент с начала), отбрасывает тишину, определяет язык каждого фрагмента и объединяет результаты с учётом уверенности. После этого запись распознаётся с явно заданным языком — точно так же, как при language=en.

Если уверенность ниже порога (по умолчанию 0.5) или самый вероятный язык опережает второй меньше чем на заданный отрыв (по умолчанию 0 — отрыв не проверяется), в записи нет речи или определить язык не удалось, используется язык отката (по умолчанию — WHISPER_DEFAULT_LANGUAGE), а в результате выставляется "fallback": true. Порог, отрыв и язык отката задаёт администратор: Система → Автоопределение языка (см. Настройки автоопределения). Значения по умолчанию берутся из .env (LANGUAGE_DETECT_MIN_PROBABILITY, LANGUAGE_DETECT_MIN_MARGIN, WHISPER_DEFAULT_LANGUAGE); изменения применяются к следующей задаче без перезапуска сервиса (воркеры перечитывают их не реже раза в 5 секунд).

Результат возвращается в поле language_detection (только при language=auto, иначе null), а поле language содержит код, с которым запись была распознана:

{
  "language": "en",
  "language_detection": {
    "requested": "auto",
    "detected": "en",
    "probability": 0.97,
    "used": "en",
    "fallback": false,
    "fallback_reason": null,
    "candidates": [["en", 0.97], ["de", 0.01], ["nl", 0.005]],
    "windows": [{"offset": 0.0, "language": "en", "probability": 0.98}]
  }
}
Поле Описание
requested что было запрошено (auto)
detected, probability самый вероятный язык и его вероятность (0–1) — даже если он не был принят
used язык, с которым запись распознана (совпадает с language ответа)
fallback true — определение ненадёжно, использован язык по умолчанию
fallback_reason null, low_confidence (уверенность ниже порога или слишком малый отрыв от второго языка), silence (в записи нет речи) или error (сбой определения, подробность — в поле error)
candidates самые вероятные языки: пары [код, вероятность]
windows проанализированные фрагменты: смещение от начала (сек), язык, вероятность

Перевод при загрузке (translate_to, wait_translation)

Эндпоинты POST /transcribe/, POST /transcribe/async и POST /v1/audio/transcriptions принимают два дополнительных поля (form-data):

Поле По умолчанию Описание
translate_to не задано — без перевода язык перевода: ru. Значения default, true, 1, yes, on — язык перевода из настроек (по умолчанию русский). Пустое значение, none, false, 0, off — без перевода
wait_translation true только синхронные /transcribe/ и /v1/audio/transcriptions: ждать ли перевод перед ответом. false — ответить сразу после распознавания, перевод выполнится в фоне. У /v1/audio/transcriptions действует только для response_format=json и verbose_json: ответы text, srt и vtt перевод не содержат и приходят сразу после распознавания

Правила:

Примеры:

cURL (синхронно — ответ придёт вместе с переводом):

curl -X POST "http://localhost:8020/transcribe/" \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -F "file=@interview_en.mp3" \
     -F "language=auto" \
     -F "translate_to=ru" \
     -F "return_segments=true"

Python (асинхронно, с ожиданием перевода):

import requests
import time

BASE = "http://localhost:8020"
headers = {"Authorization": f"Bearer {token}"}

# 1. Загрузка: язык определить автоматически, текст перевести на русский
with open("interview_en.mp3", "rb") as f:
    job = requests.post(
        f"{BASE}/transcribe/async",
        headers=headers,
        files={"file": f},
        data={"language": "auto", "translate_to": "ru", "diarize": "true"},
    ).json()

# 2. Перевод начнётся сам, как только завершится распознавание
url = f"{BASE}/transcriptions/{job['id']}/translation"
while True:
    r = requests.get(url, headers=headers, params={"include_content": "false"})
    if r.status_code == 404:
        raise SystemExit("Перевод не запрашивался")
    info = r.json()
    if info["status"] in ("completed", "failed", "skipped", "cancelled"):
        break
    print(f"{info['status']}: {info['progress']}% {info['blocked_by'] or ''}")
    time.sleep(5)

# 3. Результат с текстом (include_content=true по умолчанию)
info = requests.get(url, headers=headers).json()
if info["status"] == "completed":
    print(info["text_with_speakers"] or info["text"])
else:
    print(f"Перевод не выполнен: {info['reason']} — {info['error']}")

Фрагмент ответа /transcribe/ с переводом:

{
  "id": "uuid",
  "filename": "interview_en.mp3",
  "text": "Hello! Thanks for coming.",
  "language": "en",
  "status": "completed",
  "language_detection": {"requested": "auto", "detected": "en", "probability": 0.97, "used": "en", "fallback": false},
  "translation": {
    "id": "uuid",
    "transcription_id": "uuid",
    "status": "completed",
    "target_language": "ru",
    "source_language": "en",
    "text": "Здравствуйте! Спасибо, что пришли.",
    "progress": 100
  }
}

Объект translation

Один формат во всех ответах: GET /transcriptions/{id}, ответы загрузки и эндпоинты перевода.

{
  "id": "uuid",
  "transcription_id": "uuid",
  "status": "completed",
  "reason": null,
  "blocked_by": null,
  "waiting_for_transcription": false,
  "target_language": "ru",
  "source_language": "en",
  "text": "Здравствуйте! Спасибо, что пришли.",
  "segments": [
    {"id": 0, "start": 0.0, "end": 2.5, "text": "Здравствуйте!", "speaker": "SPEAKER_01"},
    {"id": 1, "start": 2.5, "end": 4.1, "text": "Спасибо, что пришли.", "speaker": "SPEAKER_02"}
  ],
  "text_with_speakers": "Собеседник 1: Здравствуйте!\n\nСобеседник 2: Спасибо, что пришли.",
  "model": "qwen/qwen3.6-35b-a3b",
  "error": null,
  "progress": 100,
  "progress_done": 1,
  "progress_total": 1,
  "requested_at": "2026-10-07T10:00:00",
  "started_at": "2026-10-07T10:01:12",
  "completed_at": "2026-10-07T10:01:24",
  "processing_time": 12.3
}
Поле Описание
status pending → processing → completed, failed, skipped или cancelled
reason машинно-читаемая причина (см. ниже) или null
blocked_by почему перевод ждёт: transcription (распознавание ещё идёт), service_disabled (перевод выключен), llm_unavailable (сервер LLM или модель недоступны), whisper_busy (пауза, пока Whisper распознаёт другие записи) или null. Подсказка для интерфейса, вычисляется приблизительно
waiting_for_transcription true, пока распознавание записи не завершилось
target_language язык перевода
source_language исходный язык; null — переведено как речь на нескольких языках
text переведённый текст
segments переведённые сегменты: id, start, end и speaker совпадают с оригиналом, text — перевод. Сегментов столько же и в том же порядке, что и в оригинале: у пустого сегмента оригинала пустой и text перевода
text_with_speakers перевод в виде диалога по собеседникам, если запись размечена диаризацией; иначе null
model модель, которая фактически выполнила перевод
error понятное сообщение об ошибке или null
progress, progress_done, progress_total прогресс в процентах и в переведённых фрагментах; у completed всегда 100
requested_at, started_at, completed_at время в UTC (ISO 8601, без часового пояса)
processing_time длительность перевода, сек

С include_content=false поля text, segments и text_with_speakers равны null — так удобно часто опрашивать статус. Административные эндпоинты дополнительно возвращают error_detail (техническая подробность ошибки), attempts (число попыток), usage (токены: prompt_tokens, completion_tokens, reasoning_tokens, а также requests, retries, untranslated — строки, которые модель оставила без перевода, и requeues — сколько раз перевод возвращался в очередь из-за сбоев сервера LLM без расхода попытки) и source (откуда запрошен перевод: web — загрузка со страницы /upload или из Студии админки, api, api-async, openai, openai-translations, user, admin).

Статусы и причины (reason)

status reason Значение
pending null в очереди на перевод или ждёт завершения распознавания
pending llm_unavailable сервер LLM был недоступен или отклонил запрос (в том числе 401/403 — неверный API-ключ) — перевод повторится автоматически
pending requeued запись отправлена на повторное распознавание — перевод будет выполнен заново
pending interrupted сервис перезапускался во время перевода — перевод продолжится
processing null переводится
completed null перевод готов
skipped disabled перевод выключен администратором
skipped same_language язык записи совпадает с языком перевода
skipped no_text в расшифровке нет текста
skipped detection_uncertain язык записи не удалось определить уверенно; загрузите запись повторно, указав language
skipped transcription_failed распознавание завершилось ошибкой или было отменено
failed llm_auth в текущей версии не выставляется: при ответе 401/403 переводы ждут в очереди (pending, llm_unavailable), пока администратор не исправит ключ. Может встретиться у переводов, выполненных прежней версией
failed model_unavailable модель не выбрана или недоступна на сервере LLM
failed context фрагмент текста не помещается в контекст модели
failed invalid_output правильный ответ модели (не пустой, не оборванный, в формате нумерованных строк) не удалось получить ни в одной из max_attempts попыток
failed max_attempts исчерпано число попыток перевода, или перевод 20 раз возвращался в очередь из-за сбоев сервера LLM («Перевод слишком много раз откладывался из-за сбоев сервера LLM»)
failed interrupted перевод прерван
failed internal внутренняя ошибка перевода
cancelled user или admin перевод отменён владельцем или администратором

Список причин может расширяться: незнакомое значение reason обрабатывайте по его status.

Если запись на русском, но не меньше 30 % букв в ней не кириллические (смешанная речь), она всё равно переводится — это не same_language. Причина detection_uncertain появляется, когда автоопределение не дало уверенного ответа и запись была распознана на языке по умолчанию, совпадающем с языком перевода: в сообщении error указан наиболее вероятный язык.

Параметры перевода для клиента

GET /translation/config

Включён ли перевод и на какой язык он выполняется — чтобы показывать или прятать переключатель перевода в своём интерфейсе. Сервер LLM при этом не опрашивается.

Ответ:

{
  "enabled": true,
  "target_language": "ru",
  "target_language_name": "русский",
  "target_language_label": "Перевести на русский",
  "languages": [
    {"code": "en", "name": "английский"},
    {"code": "de", "name": "немецкий"}
  ]
}

languages — коды языков с русскими названиями (по алфавиту названий) для списков выбора языка.


Получить перевод

GET /transcriptions/{transcription_id}/translation

Параметры:

Ответ:

Объект translation. Если перевод для записи не запрашивался — 404 «Перевод не запрашивался».


Запросить перевод

POST /transcriptions/{transcription_id}/translation

Переводит уже распознанную запись или ставит перевод в очередь для записи, которая ещё распознаётся (он начнётся после распознавания). Тело запроса необязательно.

Параметры запроса:

{
  "target_language": "ru",
  "force": false
}

Ответ (202 Accepted):

Объект translation.

Состояние перевода Результат запроса
перевода нет создаётся задача pending
pending задача остаётся в очереди, язык перевода обновляется
completed на тот же язык возвращается как есть — повторный запрос безопасен
completed на тот же язык, force=true 409 «Перевод уже готов» (перевести заново может администратор)
completed на другой язык, failed, skipped, cancelled перевод выполняется заново
processing 409 «Перевод уже выполняется»; с force=true — перевод начинается заново, но израсходованные попытки сохраняются (бесконечные перезапуски закончатся failed/max_attempts); у администратора счёт попыток начинается с нуля

Ошибки:


Отменить перевод

POST /transcriptions/{transcription_id}/translation/cancel

Отменяет перевод в состоянии pending или processing. Ответ 200 — объект translation со статусом cancelled. Если перевод уже завершён — 409 «Перевод уже завершён»; если его нет — 404.


Скачать перевод

GET /transcriptions/{transcription_id}/download?format=txt&translated=true

Примеры:

cURL:

# Состояние перевода без текста — для частого опроса
curl -H "Authorization: Bearer YOUR_API_KEY" \
     "http://localhost:8020/transcriptions/<ID>/translation?include_content=false"

# Перевести уже распознанную запись
curl -X POST "http://localhost:8020/transcriptions/<ID>/translation" \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"target_language": "ru"}'

# Отменить перевод
curl -X POST "http://localhost:8020/transcriptions/<ID>/translation/cancel" \
     -H "Authorization: Bearer YOUR_API_KEY"

# Скачать перевод субтитрами
curl -H "Authorization: Bearer YOUR_API_KEY" \
     "http://localhost:8020/transcriptions/<ID>/download?format=srt&translated=true" \
     -o interview_ru.srt

Статус перевода также есть в GET /transcriptions/{id}/status (translation_status, translation_progress, translation_error) и в списке истории (translation_status).


OpenAI-совместимый API: перевод

Перевод вместе с распознаванием

POST /v1/audio/transcriptions

Принимает те же translate_to и wait_translation, что и /transcribe/. Без translate_to эндпоинт работает в точности как раньше.

{
  "text": "Hello! Thanks for coming.",
  "translation": {
    "transcription_id": "uuid",
    "status": "completed",
    "reason": null,
    "progress": 100,
    "source_language": "en",
    "target_language": "ru",
    "text": "Здравствуйте! Спасибо, что пришли.",
    "error": null
  }
}

Перевод речи одним запросом

POST /v1/audio/translations

Аналог эндпоинта OpenAI. Отличие от OpenAI: OpenAI переводит на английский, а этот эндпоинт — на язык перевода из настроек (по умолчанию русский); для английского передайте translate_to=en. Возвращает только перевод. Работает, когда перевод включён администратором, иначе — 503 translation_disabled (до модуля перевода этого маршрута не было, ответ был 404).

Поле Тип По умолчанию Описание
file file — аудио или видео (mp3/mp4/mpeg/mpga/m4a/wav/webm/ogg/flac/mov/mxf)
model str whisper-1 игнорируется
prompt str — игнорируется
response_format str json json / text / srt / vtt / verbose_json
temperature float — игнорируется
language str auto язык записи (расширение)
translate_to str язык из настроек язык перевода (расширение)
diarize bool false разделение по собеседникам (расширение)
num_speakers int — точное число собеседников (расширение)

Сервер ждёт распознавание (до QUEUE_SYNC_WAIT_SECONDS), затем перевод (до sync_wait_seconds). Форматы ответа:

{
  "task": "translate",
  "language": "en",
  "duration": 4.1,
  "text": "Здравствуйте! Спасибо, что пришли.",
  "segments": [
    {"id": 0, "start": 0.0, "end": 2.5, "text": "Здравствуйте!"},
    {"id": 1, "start": 2.5, "end": 4.1, "text": "Спасибо, что пришли."}
  ],
  "translation": {
    "transcription_id": "uuid",
    "status": "completed",
    "reason": null,
    "progress": 100,
    "source_language": "en",
    "target_language": "ru",
    "text": "Здравствуйте! Спасибо, что пришли.",
    "error": null
  }
}

Если язык записи уже совпадает с языком перевода, возвращается исходный текст (диалог при diarize=true) с исходными сегментами; если в записи нет речи — пустой текст.

Ошибки — в формате OpenAI, с заголовками x-should-retry: false и (когда задача уже создана) X-Transcription-Id:

{
  "error": {
    "message": "…",
    "type": "…",
    "param": null,
    "code": "translation_timeout"
  }
}
HTTP code Когда
401 invalid_api_key ключ не передан, неверен или истёк — задача не создаётся
400 invalid_file_format, file_too_large, invalid_num_speakers, invalid_response_format, invalid_translate_to, invalid_language ошибка в запросе — файл не сохраняется, задача не создаётся
400 invalid_value, missing_required_parameter, invalid_request_body поле формы не разобрано (например, temperature=warm; имя поля — в param), не передан file или тело запроса не читается как форма — задача не создаётся. У /v1/audio/transcriptions такие ошибки по-прежнему отдаются как 422 FastAPI ({"detail": [...]})
503 translation_disabled перевод выключен администратором. Проверяется до сохранения файла (тогда задачи нет и заголовка X-Transcription-Id нет); если перевод выключили, пока запрос ждал, — ответ с X-Transcription-Id, распознавание продолжается
500 file_processing_error не удалось сохранить загруженный файл — задача не создаётся
500 transcription_error распознавание завершилось ошибкой
504 transcription_timeout распознавание не уложилось в QUEUE_SYNC_WAIT_SECONDS
500 translation_error не удалось поставить перевод в очередь — распознавание продолжается, перевод можно запросить позже: POST /transcriptions/{id}/translation
503 translation_unavailable с X-Transcription-Id: сервер LLM или модель сейчас недоступны — перевод ждёт в очереди. Без заголовка: модуль перевода недоступен, запрос отклонён до сохранения файла, задача не создана
502 translation_failed перевод завершился ошибкой (в сообщении — текст ошибки и причина)
504 translation_timeout перевод не уложился в sync_wait_seconds — он продолжается, результат: GET /transcriptions/{id}/translation
409 translation_cancelled перевод отменил владелец или администратор, пока запрос ждал
422 translation_skipped язык записи не удалось определить уверенно — повторите с явным language
500 internal_error непредвиденная ошибка сервера; X-Transcription-Id есть, только если задача уже создана

Ошибка с заголовком X-Transcription-Id означает, что задача создана и не потеряна (в том числе после 503/504): не загружайте файл заново, а заберите результат по этому id через GET /transcriptions/{id}/translation; если перевод не выполнится сам (translation_disabled, translation_error, translation_cancelled), запросите его повторно — POST /transcriptions/{id}/translation. Ошибка без X-Transcription-Id (400, 401, file_processing_error, а также 503 до сохранения файла) задачу не создала: запрос можно повторить.

Примеры:

cURL:

curl -X POST "http://localhost:8020/v1/audio/translations" \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -F "file=@interview_en.mp3" \
     -F "response_format=srt" \
     -D headers.txt \
     -o interview_ru.srt

Python (OpenAI SDK):

from openai import OpenAI

client = OpenAI(
    api_key="sk-...",                       # API-ключ Speech2Text
    base_url="http://localhost:8020/v1",
    timeout=4200,   # распознавание (до 3600 с) + перевод (до 600 с)
    max_retries=0,  # повтор отправил бы файл ещё раз и создал бы вторую задачу
)

# Только перевод: /v1/audio/translations
with open("interview_en.mp3", "rb") as f:
    result = client.audio.translations.create(
        model="whisper-1",
        file=f,
        response_format="verbose_json",
        extra_body={"language": "auto", "translate_to": "ru"},
    )
print(result.text)  # текст на русском

# Оригинал и перевод одним запросом: /v1/audio/transcriptions
with open("interview_en.mp3", "rb") as f:
    transcript = client.audio.transcriptions.create(
        model="whisper-1",
        file=f,
        response_format="json",
        extra_body={"language": "auto", "translate_to": "ru"},
    )
print(transcript.text)  # оригинал
translation = getattr(transcript, "translation", None)  # расширение, нет в схеме OpenAI
if translation and translation["status"] == "completed":
    print(translation["text"])

Поля, которых нет в OpenAI SDK (language=auto, translate_to, wait_translation, diarize), передаются через extra_body. Параметры timeout и max_retries важны: ответ может прийти через десятки минут, а повтор по таймауту загрузил бы файл ещё раз.


Настройки автоопределения (/admin/system/language-detection)

Пороги автоопределения языка (language=auto) задаются в админ-панели: Система → Автоопределение языка. Они хранятся в базе данных (app_settings, ключ language_detection) и переопределяют значения из .env: поле, не заданное в админ-панели, берётся из .env. Воркеры Whisper перечитывают настройки не реже раза в 5 секунд — перезапуск не нужен. Если база данных недоступна, используются последние прочитанные значения, а без них — значения .env. Язык записей, загруженных без language, по-прежнему задаёт только WHISPER_DEFAULT_LANGUAGE в .env. Эндпоинты доступны только администратору с токеном входа (JWT); с API-ключом sk-… они отвечают 403 «Только из админ-панели».

Параметр По умолчанию (.env) Допустимые значения Назначение
min_probability LANGUAGE_DETECT_MIN_PROBABILITY (0.5) 0–1 минимальная уверенность определения
min_margin LANGUAGE_DETECT_MIN_MARGIN (0) 0–1 минимальный отрыв самого вероятного языка от второго; 0 — не проверяется
fallback_language WHISPER_DEFAULT_LANGUAGE (ru) код или название языка Whisper язык распознавания, если определение ненадёжно, в записи тишина или определить язык не удалось

Определение ниже min_probability или с отрывом меньше min_margin отмечается в language_detection как "fallback": true, "fallback_reason": "low_confidence".

GET /admin/system/language-detection — действующие значения, значения из .env (defaults) и список переопределённых полей (overridden):

{
  "min_probability": 0.5,
  "min_margin": 0.0,
  "fallback_language": "ru",
  "fallback_language_name": "русский",
  "defaults": {"min_probability": 0.5, "min_margin": 0.0, "fallback_language": "ru"},
  "overridden": [],
  "default_language": "ru",
  "updated_at": null,
  "updated_by": null,
  "cache_ttl_seconds": 5.0,
  "languages": [{"code": "en", "name": "английский"}, {"code": "de", "name": "немецкий"}]
}

PUT /admin/system/language-detection — передаются только изменяемые поля; null возвращает поле к значению из .env. Ответ — как у GET. Ошибки: 400 с описанием неверного поля; 400 «Не передано ни одной настройки».

curl -X PUT "http://localhost:8020/admin/system/language-detection" \
     -H "Authorization: Bearer ADMIN_JWT_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"min_probability": 0.6, "min_margin": 0.1}'

Настройки перевода (/admin/system/translation)

Все настройки задаются в админ-панели: Система → Перевод (LLM). Они хранятся в базе данных (не в .env) и применяются без перезапуска сервиса. Эндпоинты этого подраздела доступны только администратору с токеном входа (JWT); с API-ключом sk-… они отвечают 403 «Только из админ-панели».

Параметр По умолчанию Допустимые значения Назначение
enabled false true / false модуль перевода включён
base_url http://127.0.0.1:1234/v1 http/https, путь заканчивается на /v1, без логина и пароля в адресе, без ? и # адрес OpenAI-совместимого сервера LLM, например http://<llm-host>:1234/v1
api_key пусто до 500 символов ключ сервера LLM (передаётся как Authorization: Bearer); пусто — без ключа
model qwen/qwen3.6-35b-a3b до 200 символов id модели на сервере LLM (по умолчанию — Qwen3.6-35B-A3B под её id в LM Studio); пусто — автоматически: единственная загруженная LLM/VLM на сервере LM Studio
target_language ru код языка Whisper язык перевода по умолчанию
concurrency 2 1–8 сколько записей переводится одновременно
timeout_seconds 180 10–600 таймаут одного запроса к LLM, сек
max_tokens 8192 256–131072 верхний предел max_tokens одного запроса
temperature 0.2 0–2 температура генерации
chunk_chars 3000 300–8000, причём chunk_chars × 0.6 ≤ max_tokens размер фрагмента текста на один запрос, символов (иероглифы и тайский считаются за 2,5)
reasoning_effort none none, minimal, low, medium, high или пусто режим рассуждений модели; пусто — параметр не передаётся
skip_same_language true true / false не переводить, если язык записи уже совпадает с языком перевода
sync_wait_seconds 600 0–3600 сколько синхронные API ждут перевод, сек
callback_wait_seconds 3600 0–86400 сколько callback_url задачи с translate_to ждёт перевод после распознавания, сек (раздел 8); 0 — не ждать
defer_while_busy false true / false не переводить, пока Whisper распознаёт записи. Включайте, только если LLM и Whisper делят одну видеокарту
defer_max_minutes 30 0–1440 после стольких минут ожидания перевод всё же идёт (по одной записи); 0 — ждать без ограничения
allow_model_load false true / false разрешить серверу LLM загружать модель по запросу (JIT)
extra_prompt пусто до 2000 символов, можно в несколько строк дополнительные инструкции переводчику: глоссарий, стиль
max_attempts 3 1–10 сколько попыток даётся одному переводу
max_active_per_user 5 1–100 сколько переводов обычного пользователя может быть в очереди и в работе; проверяется только при явном запросе POST /transcriptions/{id}/translation, на загрузки и администратора не действует

Строки, содержащие ${, не принимаются.

Получить настройки

GET /admin/system/translation

Все параметры, кроме ключа, плюс api_key_set (ключ задан) и api_key_hint (последние 4 символа ключа, например …abcd; для ключей короче 8 символов — пусто), состояние модуля status и max_concurrency. Сам ключ API никогда не возвращает. Сервер LLM при этом не опрашивается.

{
  "enabled": true,
  "base_url": "http://<llm-host>:1234/v1",
  "model": "qwen/qwen3.6-35b-a3b",
  "target_language": "ru",
  "concurrency": 2,
  "timeout_seconds": 180,
  "max_tokens": 8192,
  "temperature": 0.2,
  "chunk_chars": 3000,
  "reasoning_effort": "none",
  "skip_same_language": true,
  "sync_wait_seconds": 600,
  "callback_wait_seconds": 3600,
  "defer_while_busy": false,
  "defer_max_minutes": 30,
  "allow_model_load": false,
  "extra_prompt": "",
  "max_attempts": 3,
  "max_active_per_user": 5,
  "api_key_set": true,
  "api_key_hint": "…abcd",
  "status": {"enabled": true, "running": true, "in_flight": 1, "waiting_transcription": 0},  // сокращено
  "max_concurrency": 8
}

status — счётчики переводов по статусам, число ждущих распознавания и выполняющихся сейчас, возраст самого старого перевода в очереди, работает ли обработчик очереди, а также состояние сервера LLM: время последнего успеха и последней ошибки, задержка ответа, пауза после сбоев (paused_until) и проверка модели (model_state).

Изменить настройки

PUT /admin/system/translation

Передаются только изменяемые параметры. api_key: не передан, null, пустая строка или строка из одних пробелов — ключ не меняется; "clear_api_key": true удаляет сохранённый ключ. Ответ — как у GET. Ошибки: 400 с описанием первого неверного параметра; 400 «Не передано ни одной настройки».

curl -X PUT "http://localhost:8020/admin/system/translation" \
     -H "Authorization: Bearer ADMIN_JWT_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"enabled": true, "base_url": "http://<llm-host>:1234/v1", "model": "qwen/qwen3.6-35b-a3b", "reasoning_effort": "none"}'

Список моделей сервера LLM

POST /admin/system/translation/models

Тело (все поля необязательны): {"base_url": "...", "api_key": "...", "clear_api_key": false} — позволяет проверить адрес до сохранения настроек.

{
  "ok": true,
  "lmstudio": true,
  "models": [
    {"id": "qwen/qwen3.6-35b-a3b", "type": "vlm", "loaded": true, "context_length": 65536},
    {"id": "text-embedding-nomic-embed-text-v1.5", "type": "embeddings", "loaded": false, "context_length": null}
  ],
  "error": null
}

lmstudio: true — сервер отвечает на LM Studio API, и известно, какие модели загружены. У других серверов loaded равно null.

Проверить соединение

POST /admin/system/translation/test

Тело (все поля необязательны): {"base_url", "api_key", "clear_api_key", "model", "allow_load", "allow_model_load", "reasoning_effort", "temperature", "target_language"} — текущие (в том числе несохранённые) значения формы. Не переданные поля (или null) берутся из сохранённых настроек. "allow_model_load": false действует и тогда, когда в сохранённых настройках загрузка по запросу включена: незагруженная модель в таком случае не используется (ответ с needs_load). Сохранённый ключ отправляется только на тот же сервер (схема, хост и порт совпадают с сохранённым адресом) — при проверке другого адреса ключ нужно указать явно.

Если модель на сервере не загружена, проверка не отправляет текст модели (иначе LM Studio загрузил бы её сам) и возвращает ok: false, model_loaded: false, needs_load и suggestions — похожие загруженные модели. "allow_load": true разрешает загрузить модель ради проверки: она займёт видеопамять сервера LLM. Если модель доступна, сервер переводит две тестовые строки и возвращает результат:

{
  "ok": true,
  "models": [{"id": "qwen/qwen3.6-35b-a3b", "type": "vlm", "loaded": true, "context_length": 65536}],
  "model": "qwen/qwen3.6-35b-a3b",
  "model_found": true,
  "model_loaded": true,
  "needs_load": false,
  "suggestions": [],
  "served_model": "qwen/qwen3.6-35b-a3b",
  "reply": "Здравствуйте! Это проверка соединения. Спасибо.",
  "latency_ms": 840,
  "reasoning_tokens": 0,
  "protocol_ok": true,
  "warnings": [],
  "error": null
}

Переводы (Admin)

Доступны администратору (JWT или API-ключ администратора) для записей любых пользователей.

Метод Путь Описание
GET /admin/transcriptions/{id}/translation?include_content=true перевод записи, с полями error_detail, attempts, usage, source
POST /admin/transcriptions/{id}/translation запросить перевод: тело {"target_language", "force"}, ответ 202. Без лимита на пользователя; force=true переводит заново и готовый перевод
POST /admin/transcriptions/{id}/translation/cancel отменить перевод, ответ 200
GET /admin/translations/status?ids=a,b,c краткие статусы до 100 записей: {id: {status, reason, progress, waiting_for_transcription}}
GET /admin/transcriptions/{id}/download?format=srt&translated=true скачать оригинал или перевод любой записи (format: txt или srt)
GET /admin/transcriptions/{id} в ответе добавлены translation и language_detection
GET /admin/transcriptions в каждой строке добавлен translation_status
GET /admin/queue добавлен блок translation: stats, items (переводы в очереди и в работе, до 50, старые первыми) и recent_failures (10 последних сбоев); null, если собрать его не удалось

Prometheus (GET /metrics/prometheus) дополнительно отдаёт метрики без меток: speech2text_translation_pending, _processing, _completed, _failed, _skipped, _in_flight, speech2text_translation_enabled, _dispatcher_running, _llm_up (1 — перевод включён, пауза после сбоев не активна и последняя проверка сервера LLM и модели прошла; 0 — перевод выключен, сервер ещё не проверялся с момента запуска (проверка идёт, когда есть работа) или проверка не прошла), _oldest_pending_age_seconds, _last_success_timestamp_seconds.


Как перевод бережёт распознавание

Защита от загрузки моделей (JIT). LM Studio загружает в видеопамять любую модель, имя которой пришло в запросе, и может ради неё выгрузить уже работающую. Если LLM и Whisper делят видеокарты, такая подмена замедлит или сорвёт распознавание. Поэтому перед отправкой текста модуль спрашивает у LM Studio список загруженных моделей (GET /api/v0/models, результат кешируется на 30 секунд) и обращается только к загруженной модели:

Пауза, пока Whisper занят (defer_while_busy, по умолчанию выключено). Когда параметр включён, то пока хотя бы один воркер Whisper распознаёт запись, новые переводы не начинаются, а начатые приостанавливаются между фрагментами. Если пауза длится дольше defer_max_minutes, переводы продолжаются по одному. Включайте его, только если LLM и Whisper делят одну видеокарту: на отдельной видеокарте или отдельном сервере пауза лишь задерживает переводы.

Пауза после сбоев. Если сервер LLM недоступен (нет соединения, таймаут, ответы 429, 502, 503, 504, модель загружается), перевод возвращается в очередь (pending, причина llm_unavailable), а модуль делает паузу 15 → 30 → 60 → 120 → 300 секунд; после первого успешного перевода пауза сбрасывается. Ошибка соединения, ответ 503 и незагруженная модель попытку не расходуют, но такие возвраты считаются (usage.requeues в административных эндпоинтах): после 20 возвратов перевод завершается со статусом failed (max_attempts, «Перевод слишком много раз откладывался из-за сбоев сервера LLM»). Таймауты, прочие ошибки 5xx и 429 попытку расходуют, и после max_attempts попыток перевод завершается со статусом failed (max_attempts).

Плохой ответ модели. Пустой ответ, ответ не в формате нумерованных строк или ответ, оборванный лимитом токенов (модель зациклилась), — проблема одного перевода, а не сервера: очередь не приостанавливается. Сначала запрос сразу повторяется (с повышенной температурой, при необходимости — по частям). Если и это не помогло, перевод возвращается в очередь и повторяется через 15 с × номер попытки (пауза хранится в памяти процесса: после перезапуска повтор начнётся сразу), причём на второй попытке фрагменты вдвое, а на третьей и дальше — вчетверо меньше (но не меньше 300 символов). После max_attempts попыток перевод завершается со статусом failed (invalid_output).

Выключение на ходу. Если администратор выключает перевод, начатые переводы возвращаются в очередь (попытка не расходуется) и продолжатся после повторного включения.

Как переводится текст. Расшифровка переводится фрагментами (по умолчанию до 3000 символов и не больше 80 сегментов): каждый сегмент — отдельная нумерованная строка, поэтому таймкоды и собеседники перевода совпадают с оригиналом один к одному. Модель получает контекст — последние строки предыдущего фрагмента и их перевод, а в диалогах — метки собеседников, чтобы сохранять род и форму обращения. Если ответ модели оборван или не разобран, фрагмент переводится повторно, при необходимости — по частям. При переводе на русский строки, оставшиеся без перевода, переводятся ещё раз отдельно; если эта попытка не удалась, готовый перевод сохраняется как есть. Пустые сегменты оригинала остаются в segments перевода с пустым текстом: число, порядок и id сегментов перевода всегда совпадают с оригиналом. Числа модель пишет цифрами, сохраняя значение и порядок величины; если в тексте есть китайские или японские числительные 万 и 亿, в инструкцию модели добавляется подсказка об их значении.

Рассуждения модели. Qwen3.6 по умолчанию «рассуждает» перед ответом — это медленнее и расходует токены. Значение reasoning_effort: "none" (по умолчанию) отключает рассуждения.


8. Загрузка по ссылке и обратный вызов

Оба эндпоинта транскрибации — POST /transcribe/ и POST /transcribe/async — принимают два дополнительных поля формы: url (откуда взять аудио) и callback_url (куда отдать результат). Вместе они закрывают сценарий «отдал ссылку — забыл»: клиенту не нужно ни держать соединение с файлом на сотни мегабайт, ни опрашивать статус.

Ссылка вместо файла (url)

curl -X POST "http://localhost:8020/transcribe/async" \
  -H "Authorization: Bearer sk-XXXXXXXX..." \
  -F "url=https://example.com/records/call-2026-09-03.mp3" \
  -F "language=ru"

Правила:

Ошибки скачивания в POST /transcribe/ — код ответа на сам запрос:

Код Когда
400 Не та схема, битый URL, 4xx от источника, пустой ответ, неподдерживаемое расширение
403 Адрес запрещён политикой хостов (см. предупреждение ниже)
413 Аудио больше MAX_FILE_SIZE_MB
502 Источник недоступен или ответил 5xx
504 Источник не уложился в FETCH_TIMEOUT_SEC

POST /transcribe/async до ответа отказывает кодом только из-за самой ссылки (и прочих полей запроса — translate_to, callback_url, числа собеседников):

Код Когда
400 Заданы оба источника или ни одного; не та схема, битый URL, нет хоста, хост не резолвится
403 Адрес запрещён политикой хостов (см. предупреждение ниже)

Всё остальное выясняется уже после ответа 202. Задача переходит в failed, а в error_message (GET /transcriptions/{id}/status, очередь в админ-панели) записывается Не удалось скачать файл по ссылке: <причина>; ссылки в причине — с вырезанными секретами, как в source_url:

Что случилось Причина после двоеточия (пример)
Источник ответил 4xx или 5xx https://example.com/records/call.mp3 returned HTTP 404
Источник недоступен (соединение, TLS) Could not fetch https://example.com/records/call.mp3: ConnectError: …
Пустой ответ https://example.com/records/call.mp3 returned an empty file
Больше MAX_FILE_SIZE_MB The file is 6144 MB, over the 5000 MB limit (по Content-Length) или The file exceeds 5000 MB
Дедлайн Timed out fetching … after 3600s (FETCH_BACKGROUND_TIMEOUT_SEC), … (no data for 120s) (источник молчит дольше FETCH_TIMEOUT_SEC), … while connecting
Заголовки ответа не пришли за FETCH_TIMEOUT_SEC https://example.com/records/call.mp3 sent no response within 120s
Источник слишком медленный (в среднем меньше 8 КБ/с после первой минуты) https://example.com/records/call.mp3 is too slow: 300 KB in 61s, under 8 KB/s
Неподдерживаемый тип файла Тип файла по ссылке не поддерживается (.html). Допустимые: …
Редирект на запрещённый адрес Host '…' resolves to the non-public address … — refused (FETCH_ALLOW_PRIVATE_HOSTS is off)
Слишком много редиректов https://example.com/records/call.mp3 redirected more than 5 times

Что ещё происходит при неудачном скачивании:

Перезапуск, отмена, удаление. Если сервис перезапускается во время скачивания, оно прерывается (недокачанный файл удаляется), а при следующем старте начинается заново, с начала — докачки нет. Presigned-ссылка, истёкшая за это время, даёт failed с HTTP-ошибкой источника (обычно 403). Раз в 5 минут сервис также сверяет задачи, ждущие файла, с идущими скачиваниями и перезапускает потерянные. Отмена задачи администратором, DELETE /transcriptions/{id} и удаление пользователя администратором останавливают скачивание, и задача больше не оживает.

⚠️ Внутренняя сеть. По умолчанию FETCH_ALLOW_PRIVATE_HOSTS=false: адреса из приватных, loopback- и link-local-диапазонов, а также из диапазона операторского NAT 100.64.0.0/10 (CGNAT, Tailscale) запрещены и как источник (url), и как приёмник (callback_url). Проверяется каждый редирект, а имя резолвится один раз, и соединение идёт именно по проверенному адресу — иначе смена DNS между проверкой и подключением обходила бы политику. Включённый FETCH_ALLOW_PRIVATE_HOSTS=true превращает любой API-ключ в HTTP-прокси внутрь вашего периметра: держатель ключа сможет заставить сервис ходить по внутренним адресам. Включайте, только если держатели ключей и так имеют доступ в эту сеть — например, когда записи лежат на файловом сервере в LAN.

source_url сохраняется и отдаётся с вырезанными секретами: ?X-Amz-Signature=…, ?token=…, https://user:pass@host/… и подобное заменяется на REDACTED. Presigned-ссылке нечего делать ни в истории, ни в теле обратного вызова на чужом приёмнике. Для фонового скачивания /transcribe/async полная ссылка (с подписью) хранится во внутреннем поле базы только до конца скачивания — чтобы повторить его после перезапуска — и не отдаётся ни одним ответом API и ни в одном обратном вызове.

Обратный вызов (callback_url)

curl -X POST "http://localhost:8020/transcribe/async" \
  -H "Authorization: Bearer sk-XXXXXXXX..." \
  -F "url=https://example.com/records/call.mp3" \
  -F "callback_url=https://example.com/hooks/asr.php" \
  -F "language=ru" \
  -F "return_segments=true"

Адрес проверяется сразу при постановке (та же политика хостов, что и для url): опечатка — это 400 с телом {"detail": "callback_url: ..."} на том запросе, который её сделал, а не молчаливый провал через десять минут.

Когда задача доходит до конечного состояния — completed, failed или cancelled — сервис отправляет POST:

POST /hooks/asr.php HTTP/1.1
Content-Type: application/json; charset=utf-8
X-ASR-Task-Id: 9191b72b-23d1-4bc8-b2e9-24075e66a5af
X-ASR-Event: transcription.completed
User-Agent: Speech2Text ASR callback

Неудачное скачивание по url в /transcribe/async — тоже конечное состояние: задача failed, и приходит вызов с X-ASR-Event: transcription.failed. Причины в теле вызова нет (как и у любой другой неудачи — поля error_message в нём нет): её отдаёт GET /transcriptions/{id}/status («Не удалось скачать файл по ссылке: …»). Такой вызов может прийти через несколько секунд после ответа 202 — возможно, раньше, чем клиент успел сохранить id задачи. Приёмник должен принимать вызов с ещё незнакомым ему id, а не отвергать его: ответ не-2xx лишь запустит повторы (см. «Повторы» ниже).

Тело — тот же JSON, что отдаёт GET /transcriptions/{id} (text, language, duration, segments, diarization, поля доставки). Поля модуля перевода translation и language_detection в теле вызова есть, только когда у них есть значение (перевод заказан, язык определялся при language=auto): у задачи без перевода тело такое же, как до модуля, и приёмник со строгой схемой (без лишних ключей) продолжает его принимать. Приёмник получает его в php://input — в $_POST его не будет, это JSON, а не форма.

Подпись вызова. Если у API-ключа, создавшего задачу, задано секретное слово и включён sign_callbacks, к заголовкам добавляются X-Webhook-Timestamp и X-Webhook-Signature — см. раздел 9. Ключ без секрета или с выключенным sign_callbacks шлёт вызовы без этих заголовков, как раньше.

Вызов и перевод (LLM). Если задача загружена с translate_to, вызов ждёт и перевода: тело уходит, когда перевод в конечном состоянии (completed / failed / skipped / cancelled), и поле translation в нём уже содержит переведённый текст. Ждёт не дольше callback_wait_seconds (настройка перевода в админ-панели, «Ожидание перевода для callback», по умолчанию 3600 с) после завершения распознавания — дальше вызов уходит с translation.status = "pending", а перевод забирается через GET /transcriptions/{id}/translation. Задачи failed/cancelled не ждут никогда; пока перевод выключен в админ-панели или callback_wait_seconds = 0, вызов не ждёт его вовсе.

Размер тела. С переводом тело вызова растёт: к оригиналу добавляются translation.text, translation.segments и translation.text_with_speakers — у длинной иноязычной записи тело может стать в 2–3 раза больше. Своего ограничения размера у отправителя нет. Если приёмник ограничивает размер запроса (например, nginx по умолчанию — client_max_body_size 1m), поднимите лимит; при ответе 413 в callback_error указывается размер отправленного тела.

Повторы. Доставкой занимается отдельный фоновый поток, который раз в CALLBACK_POLL_SECONDS (по умолчанию 2 с) забирает готовые задачи с заказанным вызовом. Ответ 2xx — доставлено. Иначе делается до CALLBACK_RETRIES повторов (по умолчанию 3) с паузами 2 / 10 / 60 секунд, то есть всего до четырёх попыток; после этого callback_status становится failed, а причина остаётся в callback_error (из тела ответа приёмника читается не больше 2 КБ). Таймаут одной попытки — CALLBACK_TIMEOUT_SEC (30 с).

Неудачная доставка никогда не меняет статус самой задачи: аудио распознано, текст лежит в базе и доступен по GET /transcriptions/{id}, доставка — отдельное дело.

Редиректы не выполняются. Ответ 3xx от приёмника — это неудача с понятным текстом (callback_url redirected to '…'; redirects are not followed — register the final URL), а не переход: 301/302 молча превратили бы POST в GET без тела, а 307 отправил бы всю транскрипцию на хост, который политику хостов не проходил. Регистрируйте конечный адрес.

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

Если сервис перезапустился, пока доставка была в полёте, вызовы, застрявшие в sending, возвращаются в pending при старте и уходят заново — «в процессе отправки» не остаётся навсегда.

Ход доставки виден в GET /transcriptions/{id}:

Поле Значение
callback_status null (вызов не заказан) · pending · sending · delivered · failed
callback_attempts Сколько попыток сделано
callback_error Причина последней неудачи
callback_delivered_at Когда доставлено (UTC)

Приёмник должен быть готов к повтору уже доставленного вызова (сеть могла оборвать ответ): идентификатор задачи в X-ASR-Task-Id и поле id в теле — то, по чему дубль опознаётся.


9. Подпись HMAC-SHA256

Помимо Bearer-ключа сервис умеет подписывать трафик по схеме HMAC-SHA256 + Base64. Подпись настраивается поключево: владелец задаёт секретное слово конкретному API-ключу и независимо включает два выключателя.

Выключатель Поле ключа Что делает
Подписывать callback sign_callbacks Сервис добавляет X-Webhook-Timestamp / X-Webhook-Signature к исходящему POST на callback_url, чтобы приёмник мог убедиться: вызов пришёл именно отсюда
Требовать подпись запросов require_request_signature Сервис отвергает неподписанные запросы этим ключом: украденный ключ без секретного слова бесполезен

ℹ️ Оба выключателя по умолчанию выключены и независимы друг от друга. Обновление ничего не меняет для существующих интеграций: пока ключу не задано секретное слово и не включён соответствующий флаг, ни один запрос и ни один callback не требует подписи. Включить подпись, не задав секрет, нельзя — API управления ключами вернёт 400.

Секретное слово

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

Наружу секрет отдаётся только владельцу ключа: в списке видно лишь webhook_secret_set и маску webhook_secret_hint (whsec_••••••••), полное значение возвращает GET /api-keys/{key_id}/webhook-secret. В логи и в тексты ошибок секрет не попадает никогда.

Секрет хранится в базе в открытом виде — иначе им нельзя было бы считать HMAC: это ключ подписи, а не пароль, и из хеша его не восстановить.

Заголовки

Заголовок Направление Значение
X-Signature-Timestamp клиент → сервис Unix-время в секундах (целое) на момент отправки запроса
X-Signature клиент → сервис base64(HMAC-SHA256(timestamp + "." + МЕТОД + "." + путь, секрет))
X-Webhook-Timestamp сервис → приёмник callback Unix-время в секундах на момент этой попытки доставки
X-Webhook-Signature сервис → приёмник callback base64(HMAC-SHA256(id задачи + "." + timestamp, секрет))

Обе схемы — одна и та же операция: HMAC-SHA256 от строки, ключ — секретное слово в UTF-8, результат — Base64 от сырых байт дайджеста (не от hex-строки). Различаются только подписываемые строки.

Схема 1. Входящий запрос

Подписывается строка

<timestamp>.<МЕТОД>.<путь со строкой запроса>

Пример подписываемой строки для POST /transcribe/async:

1769670760.POST./transcribe/async

и для GET /transcriptions/history?skip=0&limit=50:

1769670760.GET./transcriptions/history?skip=0&limit=50

⚠️ Путь и строка запроса подписываются «как есть». Сервис берёт их из самой строки запроса HTTP, в неразобранном виде; заголовок Host на подпись не влияет. URL нужно собрать самому и передать библиотеке готовым: если отдать параметры отдельным словарём (params= в requests, http_build_query в PHP), библиотека может закодировать их иначе, чем вы при подписи, — и подпись не сойдётся, хотя адрес выглядит тем же.

Проверка выполняется там же, где проверяется API-ключ: после проверки is_active и срока действия и до обновления last_used_at. Запрос, отклонённый по подписи, не считается использованием ключа.

Требование действует на все эндпоинты, которые авторизуются обычным путём: /transcribe/, /transcribe/async, /transcriptions/* (включая /transcriptions/{id}/translation), /translation/config, /api-keys/*, /metrics/user, /auth/me. Сессия администратора (JWT) подписи не требует никогда — выключатель относится к ключу, а не к пользователю. OpenAI-совместимые /v1/... проверяют ключ собственным кодом и подпись не требуют (в том числе /v1/audio/translations модуля перевода): если ключ используется и там, помните, что этот путь остаётся без подписи.

Тело запроса в подпись не входит: загрузка может достигать MAX_FILE_SIZE_MB (5000 МБ), и буферизовать её ради HMAC сервис не станет. Подпись подтверждает, что запрос выпущен держателем секрета, и привязывает его к методу, адресу и моменту времени; целостность содержимого обеспечивает TLS.

Ошибки 401

Тело Когда
{ "detail": "API key requires signed requests but has no signing secret configured" } Флаг включён, а секретного слова у ключа нет (штатным путём такое состояние не создаётся)
{ "detail": "Missing signature headers (X-Signature-Timestamp, X-Signature)" } Нет одного из двух заголовков
{ "detail": "Signature timestamp outside the allowed window" } Метка устарела, пришла из будущего или это не число
{ "detail": "Invalid request signature" } Подпись не сошлась

Схема 2. Исходящий callback

Подписывается строка

<id задачи>.<timestamp>

где <id задачи> — поле id из тела вызова (оно же в заголовке X-ASR-Task-Id), а <timestamp> — значение X-Webhook-Timestamp.

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

Окно свежести

Параметр По умолчанию Описание
SIGNATURE_MAX_SKEW_SEC 300 Допустимый разбег часов для X-Signature-Timestamp на входящих подписанных запросах, секунды

Запрос принимается, если |сейчас − timestamp| ≤ SIGNATURE_MAX_SKEW_SEC, то есть подпись живёт 5 минут в обе стороны — перехваченный запрос нельзя повторить назавтра. Та же метка кладётся в исходящий callback, чтобы приёмник мог применить своё окно той же ширины. Если часы убегают, лечится это синхронизацией времени (NTP), а не расширением окна.

Пример: подписанный запрос (bash + openssl)

BASE=http://localhost:8020
API_KEY=sk-XXXXXXXX...
SECRET=whsec_2Vd1kZ0oQ7pR...        # секретное слово этого ключа

REQ_PATH="/transcribe/async"
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST.$REQ_PATH" \
  | openssl dgst -sha256 -hmac "$SECRET" -binary \
  | base64)

curl -s -X POST "$BASE$REQ_PATH" \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-Signature-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -F "file=@./audio.mp3" \
  -F "language=ru"

printf '%s' здесь важен: echo добавил бы перевод строки в подписываемые данные.

Пример: подпись запроса на Python

import base64
import hashlib
import hmac
import time

import requests

BASE = "http://localhost:8020"
API_KEY = "sk-XXXXXXXX..."
SECRET = "whsec_2Vd1kZ0oQ7pR..."


def signed_headers(method: str, path_with_query: str, secret: str) -> dict:
    ts = str(int(time.time()))
    data = f"{ts}.{method.upper()}.{path_with_query}"
    digest = hmac.new(secret.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
    return {
        "X-Signature-Timestamp": ts,
        "X-Signature": base64.b64encode(digest).decode("ascii"),
    }


# URL собираем сами — так подписанный путь и отправленный совпадают символ в символ
path = "/transcribe/async"
headers = {"Authorization": f"Bearer {API_KEY}"}
headers.update(signed_headers("POST", path, SECRET))

resp = requests.post(
    BASE + path,
    headers=headers,
    data={
        "url": "https://example.com/records/call.mp3",
        "callback_url": "https://example.com/hooks/asr",
        "language": "ru",
    },
    timeout=30,   # 202 приходит сразу: ссылку сервис скачивает уже после ответа
)
resp.raise_for_status()
print(resp.json()["id"])

Пример: проверка callback на Python (Flask)

import base64
import hashlib
import hmac
import time

from flask import Flask, jsonify, request

SECRET = "whsec_2Vd1kZ0oQ7pR..."
MAX_SKEW_SEC = 300

app = Flask(__name__)


@app.post("/hooks/asr")
def asr_callback():
    ts = request.headers.get("X-Webhook-Timestamp", "")
    received = request.headers.get("X-Webhook-Signature", "")
    if not ts or not received:
        return jsonify(error="Missing signature headers"), 401

    try:
        if abs(int(time.time()) - int(ts)) > MAX_SKEW_SEC:
            return jsonify(error="Stale timestamp"), 401
    except ValueError:
        return jsonify(error="Bad timestamp"), 401

    task = request.get_json(silent=True) or {}
    data = f"{task.get('id', '')}.{ts}"        # id задачи из тела, он же в X-ASR-Task-Id
    expected = base64.b64encode(
        hmac.new(SECRET.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
    ).decode("ascii")

    if not hmac.compare_digest(expected, received):
        return jsonify(error="Invalid signature"), 401

    # подпись верна — телу можно доверять
    print(task["id"], task["status"], len(task.get("text") or ""))
    return jsonify(status="received"), 200

Пример: проверка callback на PHP

<?php
header('Content-Type: application/json');

$secret  = getenv('ASR_WEBHOOK_SECRET');
$maxSkew = 300;

$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if ($ts === '' || $sig === '') {
    http_response_code(401);
    exit(json_encode(['error' => 'Missing signature headers']));
}
if (abs(time() - (int) $ts) > $maxSkew) {
    http_response_code(401);
    exit(json_encode(['error' => 'Stale timestamp']));
}

$task   = json_decode(file_get_contents('php://input'), true);   // тело — JSON, не форма
$taskId = $task['id'] ?? '';                                     // он же в X-ASR-Task-Id

$expected = base64_encode(hash_hmac('sha256', $taskId . '.' . $ts, $secret, true));

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit(json_encode(['error' => 'Invalid signature']));
}

// подпись верна — обрабатываем $task['status'] и $task['text']
http_response_code(200);
echo json_encode(['status' => 'received']);

Переменные окружения

Все новые настройки задаются в .env и имеют рабочие значения по умолчанию — менять их для включения подписи или callback не требуется.

Переменная По умолчанию Описание
FETCH_TIMEOUT_SEC 120 Абсолютный дедлайн на скачивание аудио по url в POST /transcribe/ (внутри запроса) и предел молчания источника посреди передачи в обоих эндпоинтах, секунды
FETCH_BACKGROUND_TIMEOUT_SEC 3600 Абсолютный дедлайн фонового скачивания по url в POST /transcribe/async, секунды; отсчитывается с начала скачивания, а не с приёма задачи
FETCH_MAX_CONCURRENT 4 Сколько фоновых скачиваний POST /transcribe/async идёт одновременно; остальные ждут свободного места
FETCH_MAX_REDIRECTS 5 Сколько редиректов разрешено пройти при скачивании (каждый проверяется политикой хостов)
FETCH_ALLOW_PRIVATE_HOSTS false Разрешить непубличные адреса (приватные, loopback, link-local, CGNAT 100.64.0.0/10) в url и callback_url. См. предупреждение в разделе 8: включённый, он превращает любой API-ключ в HTTP-прокси внутрь периметра
CALLBACK_TIMEOUT_SEC 30 Таймаут одной попытки доставки callback, секунды
CALLBACK_RETRIES 3 Сколько повторов после первой неудачной попытки (паузы 2 / 10 / 60 с)
CALLBACK_POLL_SECONDS 2.0 Как часто фоновый поток ищет готовые задачи с заказанным вызовом, секунды
SIGNATURE_MAX_SKEW_SEC 300 Окно свежести X-Signature-Timestamp у входящих подписанных запросов, секунды

Обработка ошибок

Все ошибки возвращаются в стандартном формате:

{
  "detail": "Описание ошибки"
}

Коды ошибок:

Не всякая неудача — это HTTP-код: файл приняли, а сломалась уже обработка. Тогда задача переходит в failed, а причина лежит в error_message (GET /transcriptions/{id}/status). Так, файл, в котором вовсе нет звуковой дорожки, даёт: «В файле нет звуковой дорожки — распознавать нечего.» Так же ведёт себя ссылка в POST /transcribe/async: ответ 202 приходит до скачивания, и любая неудача скачивания, кроме отказа по самой ссылке (400/403), — это failed с error_message «Не удалось скачать файл по ссылке: …» (см. раздел 8).

Пример обработки ошибок (Python):

import requests

def safe_api_call(url, headers, method="GET", **kwargs):
    try:
        if method == "GET":
            response = requests.get(url, headers=headers, **kwargs)
        elif method == "POST":
            response = requests.post(url, headers=headers, **kwargs)

        if response.status_code == 200:
            return response.json()
        elif response.status_code == 401:
            print("Authentication required. Please login.")
        elif response.status_code == 403:
            print("Access denied. Check permissions.")
        elif response.status_code == 404:
            print("Resource not found.")
        elif response.status_code == 429:
            print("Rate limit exceeded. Please wait.")
        else:
            error = response.json()
            print(f"Error: {error.get('detail', 'Unknown error')}")

    except requests.exceptions.RequestException as e:
        print(f"Network error: {e}")

    return None

Полный пример использования

Python - Полный workflow:

import requests
import time

class Speech2TextClient:
    def __init__(self, base_url="http://localhost:8020"):
        self.base_url = base_url
        self.token = None

    def login(self, username, password):
        """Авторизация в системе"""
        response = requests.post(
            f"{self.base_url}/auth/login",
            json={"username": username, "password": password}
        )
        if response.status_code == 200:
            self.token = response.json()["access_token"]
            print("Login successful!")
            return True
        return False

    def transcribe(self, audio_path, language=None):
        """Транскрибация аудиофайла"""
        if not self.token:
            print("Please login first")
            return None

        headers = {"Authorization": f"Bearer {self.token}"}

        with open(audio_path, "rb") as f:
            files = {"file": f}
            data = {"language": language} if language else {}

            response = requests.post(
                f"{self.base_url}/transcribe/",
                headers=headers,
                files=files,
                data=data
            )

        if response.status_code == 200:
            return response.json()
        else:
            print(f"Error: {response.text}")
            return None

    def wait_for_completion(self, transcription_id, timeout=300):
        """Ожидание завершения транскрибации"""
        headers = {"Authorization": f"Bearer {self.token}"}
        start_time = time.time()

        while time.time() - start_time < timeout:
            response = requests.get(
                f"{self.base_url}/transcriptions/{transcription_id}/status",
                headers=headers
            )

            if response.status_code == 200:
                status = response.json()
                if status["status"] == "completed":
                    return True
                elif status["status"] == "failed":
                    print(f"Transcription failed: {status.get('error_message')}")
                    return False

            time.sleep(2)

        print("Timeout waiting for transcription")
        return False

    def get_transcription(self, transcription_id):
        """Получение результата транскрибации"""
        headers = {"Authorization": f"Bearer {self.token}"}

        response = requests.get(
            f"{self.base_url}/transcribe/{transcription_id}",
            headers=headers
        )

        if response.status_code == 200:
            return response.json()
        return None

    def download_text(self, transcription_id, output_path):
        """Скачивание текста транскрибации"""
        headers = {"Authorization": f"Bearer {self.token}"}

        response = requests.get(
            f"{self.base_url}/transcriptions/{transcription_id}/download",
            headers=headers
        )

        if response.status_code == 200:
            with open(output_path, "w", encoding="utf-8") as f:
                f.write(response.text)
            print(f"Transcription saved to {output_path}")
            return True
        return False

# Использование
if __name__ == "__main__":
    client = Speech2TextClient()

    # Авторизация
    if client.login("admin", "admin123"):

        # Транскрибация
        result = client.transcribe("podcast.mp3", language="ru")
        if result:
            print(f"Transcription ID: {result['id']}")
            print(f"Status: {result['status']}")

            # Ожидание завершения
            if result['status'] == 'processing':
                if client.wait_for_completion(result['id']):
                    # Получение результата
                    full_result = client.get_transcription(result['id'])
                    print(f"Text: {full_result['text'][:200]}...")

                    # Сохранение в файл
                    client.download_text(result['id'], "transcription.txt")

JavaScript - Полный workflow:

class Speech2TextClient {
    constructor(baseUrl = 'http://localhost:8020') {
        this.baseUrl = baseUrl;
        this.token = null;
    }

    async login(username, password) {
        const response = await fetch(`${this.baseUrl}/auth/login`, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({username, password})
        });

        if (response.ok) {
            const data = await response.json();
            this.token = data.access_token;
            console.log('Login successful!');
            return true;
        }
        return false;
    }

    async transcribe(file, language = null) {
        if (!this.token) {
            console.error('Please login first');
            return null;
        }

        const formData = new FormData();
        formData.append('file', file);
        if (language) {
            formData.append('language', language);
        }

        const response = await fetch(`${this.baseUrl}/transcribe/`, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${this.token}`
            },
            body: formData
        });

        if (response.ok) {
            return await response.json();
        }
        return null;
    }

    async waitForCompletion(transcriptionId, timeout = 300000) {
        const startTime = Date.now();

        while (Date.now() - startTime < timeout) {
            const response = await fetch(
                `${this.baseUrl}/transcriptions/${transcriptionId}/status`,
                {
                    headers: {'Authorization': `Bearer ${this.token}`}
                }
            );

            if (response.ok) {
                const status = await response.json();
                if (status.status === 'completed') {
                    return true;
                } else if (status.status === 'failed') {
                    console.error('Transcription failed:', status.error_message);
                    return false;
                }
            }

            await new Promise(resolve => setTimeout(resolve, 2000));
        }

        console.error('Timeout waiting for transcription');
        return false;
    }

    async getTranscription(transcriptionId) {
        const response = await fetch(
            `${this.baseUrl}/transcribe/${transcriptionId}`,
            {
                headers: {'Authorization': `Bearer ${this.token}`}
            }
        );

        if (response.ok) {
            return await response.json();
        }
        return null;
    }
}

// Использование
async function main() {
    const client = new Speech2TextClient();

    // Авторизация
    if (await client.login('admin', 'admin123')) {

        // Выбор файла
        const fileInput = document.getElementById('fileInput');
        fileInput.addEventListener('change', async (e) => {
            const file = e.target.files[0];

            // Транскрибация
            const result = await client.transcribe(file, 'ru');
            if (result) {
                console.log('Transcription ID:', result.id);

                // Ожидание завершения
                if (result.status === 'processing') {
                    if (await client.waitForCompletion(result.id)) {
                        // Получение результата
                        const fullResult = await client.getTranscription(result.id);
                        console.log('Text:', fullResult.text);

                        // Отображение результата
                        document.getElementById('result').textContent = fullResult.text;
                    }
                }
            }
        });
    }
}

main();

Советы и рекомендации

Оптимизация производительности:

  1. Формат файлов: Конвертируйте в WAV 16kHz моно для быстрой обработки
  2. Размер файлов: Разбивайте большие файлы на части по 30 минут
  3. Язык: Указывайте язык явно для ускорения обработки
  4. Batch обработка: Загружайте несколько файлов параллельно
  5. Видео: .mov, .mxf, .webm, .mp4 можно слать как есть — звук извлечёт сервис. Но по сети едет весь контейнер (реальный MXF — 3.88 ГБ ради процентов полезных данных), так что на узком канале дешевле вырезать звук у себя и прислать, например, .m4a

Безопасность:

  1. Храните токены безопасно: Никогда не включайте токены в код
  2. Используйте HTTPS: В продакшене всегда используйте HTTPS
  3. Ротация ключей: Регулярно обновляйте API ключи
  4. Ограничение прав: Давайте минимально необходимые права
  5. Подпись запросов: Включите require_request_signature — украденный ключ без секретного слова станет бесполезен (раздел 9)
  6. Проверяйте подпись callback: Включите sign_callbacks и проверяйте X-Webhook-Signature на приёмнике — иначе кто угодно может прислать вам «результат»

Обработка ошибок:

  1. Retry логика: Реализуйте повторные попытки при временных ошибках
  2. Таймауты: Устанавливайте разумные таймауты для долгих операций
  3. Логирование: Логируйте все ошибки для отладки
  4. Graceful degradation: Предусмотрите fallback сценарии

Полезные ссылки


Поддержка

При возникновении проблем: 1. Проверьте логи сервера 2. Убедитесь в правильности токена 3. Проверьте формат и размер файла 4. Обратитесь к документации API

Для технической поддержки создайте issue на GitHub или напишите на support@speech2text.com