Andrey Epifancev a7902222db docs(openspec): trim model setting too (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-27 23:44:18 +04:00

14 KiB
Raw Blame History

Spec Delta: transcription-provider

Purpose

Определяет, как плагин отправляет аудиозапись в OpenAI-совместимый сервис распознавания (по умолчанию AITUNNEL, gpt-4o-transcribe), как раскладывает любой исход в результат ok / temporary / permanent для очереди и как хранит настройки провайдера, не допуская утечки API-ключа.

ADDED Requirements

Requirement: Настройки провайдера

Провайдер MUST использовать настройки baseUrl, model, apiKey, language со значениями по умолчанию https://api.aitunnel.ru/v1, gpt-4o-transcribe, пустая строка и пустая строка соответственно. Настройки MUST храниться только в data.json плагина и MUST читаться заново при каждом запросе, так что изменённый ключ применяется к следующему запросу без перезапуска плагина. Значения baseUrl, model, apiKey и language MUST обрезаться от пробелов при чтении. Сохранённые настройки MUST накладываться поверх значений по умолчанию, чтобы отсутствующее поле получало умолчание. API-ключ MUST NOT попадать в репозиторий: ни в исходники, ни в тесты, ни в фикстуры.

Scenario: Первый запуск

  • WHEN плагин загружается, а data.json отсутствует
  • THEN провайдер использует https://api.aitunnel.ru/v1, модель gpt-4o-transcribe, пустой ключ и пустой язык

Scenario: Ключ исправлен между запросами

  • WHEN запрос завершился ошибкой 401, пользователь ввёл новый ключ во вкладке настроек, и провайдер вызван снова (очередью или командой проверки)
  • THEN следующий запрос уходит с новым ключом без перезапуска плагина

Scenario: Ключ вставлен с пробелами

  • WHEN в настройке ключа сохранено " sk-abc \n"
  • THEN запрос уходит с заголовком Authorization: Bearer sk-abc

Requirement: Вкладка настроек

Плагин MUST предоставлять вкладку настроек с полями «Адрес сервиса» (baseUrl), «Модель», «API-ключ» и «Язык». Поле ключа MUST отображаться как поле пароля. Изменение любого поля MUST сохраняться в data.json.

Scenario: Ввод ключа

  • WHEN пользователь вводит ключ во вкладке настроек
  • THEN символы ключа скрыты, а после ввода ключ сохранён в data.json

Requirement: Формирование запроса

Провайдер MUST отправлять один POST-запрос на <baseUrl>/audio/transcriptions, где у baseUrl отброшены завершающие /. Запрос MUST содержать заголовок Authorization: Bearer <apiKey> и тело multipart/form-data с полями model, response_format со значением json и file. Поле language MUST передаваться только если настройка не пустая. Часть file MUST иметь имя audio.<ext>, где расширение определяется по mime записи (audio/mp4 → m4a, audio/webm → webm, audio/ogg → ogg, иначе bin), и Content-Type, равный mime записи без параметров. Байты аудио MUST передаваться без изменений. Тип тела multipart/form-data; boundary=<boundary> MUST передаваться HTTP-клиенту отдельным полем contentType, а boundary в нём MUST совпадать с boundary тела.

Scenario: Запись с iPhone

  • WHEN отправляется запись с mime audio/mp4;codecs=mp4a.40.2, baseUrl https://api.aitunnel.ru/v1/, язык не задан
  • THEN запрос уходит на https://api.aitunnel.ru/v1/audio/transcriptions с полями model=gpt-4o-transcribe, response_format=json и файлом audio.m4a с Content-Type: audio/mp4, поля language нет

Scenario: Язык задан

  • WHEN в настройках язык ru
  • THEN в запросе есть поле language=ru

Requirement: Проверки до запроса

Провайдер MUST завершать распознавание без сетевого запроса в следующих случаях, проверяемых в этом порядке: пустой apiKey — постоянная ошибка «не задан API-ключ»; пустой baseUrl — постоянная ошибка «не задан адрес сервиса»; аудио длиной 0 байт — постоянная ошибка «пустая запись»; уже отменённый сигнал — временная ошибка «запрос отменён».

Scenario: Ключ не задан

  • WHEN очередь отправляет запись, а ключ в настройках пустой
  • THEN сетевого запроса нет, результат — постоянная ошибка «не задан API-ключ»

Scenario: Пустое аудио

  • WHEN провайдеру передано аудио длиной 0 байт
  • THEN сетевого запроса нет, результат — постоянная ошибка «пустая запись»

Requirement: Классификация ответа

Провайдер MUST раскладывать исход запроса так:

Исход Результат Причина
2xx, тело — JSON со строковым полем text успех с этим текстом без изменений —
2xx, тело не JSON или без строкового поля text постоянная ошибка HTTP <код>: ответ без поля text: <первые 200 символов тела>
408, 429, 5xx временная ошибка HTTP <код>: <деталь>
3xx и 4xx, кроме 408 и 429 постоянная ошибка HTTP <код>: <деталь>
1xx и любой другой код временная ошибка HTTP <код>: <деталь>
HTTP-клиент бросил исключение временная ошибка сеть: <сообщение исключения>

Деталь MUST браться из error.message, если тело — JSON вида {"error":{"message":"…"}} со строковым message, иначе MUST быть первыми 200 символами тела. Если тело пустое или из одних пробелов, MUST использоваться причина HTTP <код> без двоеточия и детали. Провайдер MUST NOT бросать исключения: любой исход MUST возвращаться как результат.

Scenario: Неверный ключ

  • WHEN сервис ответил 401 с телом {"error":{"message":"Invalid API key"}}
  • THEN результат — постоянная ошибка HTTP 401: Invalid API key

Scenario: Превышен лимит запросов

  • WHEN сервис ответил 429
  • THEN результат — временная ошибка, очередь планирует повтор

Scenario: Неверный адрес сервиса

  • WHEN сервис ответил 200 с HTML-страницей вместо JSON
  • THEN результат — постоянная ошибка, причина начинается с HTTP 200: ответ без поля text: и содержит не больше 200 символов тела

Scenario: Редирект дошёл до провайдера

  • WHEN HTTP-клиент вернул статус 301
  • THEN результат — постоянная ошибка

Scenario: Сеть недоступна

  • WHEN HTTP-клиент бросил исключение net::ERR_INTERNET_DISCONNECTED
  • THEN результат — временная ошибка сеть: net::ERR_INTERNET_DISCONNECTED

Scenario: Пустой текст

  • WHEN сервис ответил 200 с телом {"text":" "}
  • THEN результат — успех с текстом " ", а решение «нет речи» принимает очередь по recording-lifecycle

Requirement: Таймаут и отмена

Провайдер MUST завершать ожидание ответа через 5 минут временной ошибкой «таймаут запроса (5 мин)». Таймаут MUST быть меньше порога зависшей отправки из recording-lifecycle (10 минут). Если сигнал отмены срабатывает во время запроса, провайдер MUST сразу вернуть временную ошибку «запрос отменён». После возврата результата провайдер MUST NOT оставлять активных таймеров и слушателей сигнала. Ответ или исключение запроса, пришедшие после таймаута или отмены, MUST игнорироваться без необработанного отклонения промиса.

Scenario: Сервис молчит

  • WHEN ответ на запрос не пришёл за 5 минут
  • THEN результат — временная ошибка «таймаут запроса (5 мин)», очередь планирует повтор

Scenario: Плагин выгружен во время запроса

  • WHEN очередь отменяет сигнал, пока запрос в полёте
  • THEN провайдер сразу возвращает временную ошибку «запрос отменён», а пришедший позже ответ ни на что не влияет

Requirement: Причина ошибки без секретов

Причина любой ошибки MUST NOT содержать API-ключ, значение заголовка Authorization и какую-либо часть тела запроса. Перед возвратом каждая причина MUST проходить очистку: каждое вхождение ключа заменяется на ***, каждая последовательность Bearer <непробельные символы> заменяется на Bearer ***, результат обрезается до 300 символов.

Scenario: Сервис вернул ключ в тексте ошибки

  • WHEN сервис ответил 401 с телом, содержащим ключ и строку Authorization: Bearer <ключ>
  • THEN причина не содержит ключа, вместо него ***

Scenario: Ключ в сообщении сетевого исключения

  • WHEN HTTP-клиент бросил исключение, в сообщении которого есть ключ
  • THEN причина не содержит ключа

Scenario: Тело запроса не попадает в причину

  • WHEN запрос завершился любой ошибкой
  • THEN причина не содержит boundary тела и строки Content-Disposition

Requirement: Команда «Проверить провайдер распознавания»

Команда MUST отправить провайдеру последний по имени аудиофайл из voice/source/ с расширением m4a, webm или ogg, с mime по расширению, и показать исход во всплывающем уведомлении: при успехе — начало распознанного текста или сообщение, что речь не распознана; при ошибке — вид ошибки (временная или постоянная) и её причину. Если подходящего файла нет, команда MUST показать уведомление об этом без сетевого запроса. Команда MUST NOT изменять заметки, frontmatter и очередь.

Scenario: Проверка после смены ключа

  • WHEN в voice/source/ есть записи, пользователь выполнил команду
  • THEN последняя запись отправлена провайдеру, уведомление показывает начало текста, ни одна заметка не изменилась

Scenario: Нет записей

  • WHEN папка voice/source/ пуста или отсутствует
  • THEN уведомление сообщает, что проверять нечем, сетевого запроса нет