import { buildMultipart, makeBoundary } from './multipart'; import { extFromMime } from './naming'; import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports'; export interface OpenAiSettings { baseUrl: string; model: string; apiKey: string; language: string; } export const DEFAULT_OPENAI: Readonly = { baseUrl: 'https://api.aitunnel.ru/v1', model: 'gpt-4o-transcribe', apiKey: '', language: '', }; // MUST быть меньше SENT_STALE_MS (record.ts): иначе зависший sent // восстановится раньше, чем провайдер сдастся по таймауту. export const REQUEST_TIMEOUT_MS = 5 * 60_000; export const NO_API_KEY = 'не задан API-ключ'; export const NO_BASE_URL = 'не задан адрес сервиса'; export const EMPTY_AUDIO = 'пустая запись'; export const CANCELLED = 'запрос отменён'; export const TIMED_OUT = 'таймаут запроса (5 мин)'; const DETAIL_LIMIT = 200; const REASON_LIMIT = 300; const FIELDS = ['baseUrl', 'model', 'apiKey', 'language'] as const; // Сохранённое поверх умолчаний; нестроковое поле → умолчание; всё обрезается. export function readSettings(saved: unknown): OpenAiSettings { const src = saved !== null && typeof saved === 'object' ? (saved as Record) : {}; const out: OpenAiSettings = { ...DEFAULT_OPENAI }; for (const key of FIELDS) { const value = src[key]; if (typeof value === 'string') out[key] = value.trim(); } return out; } function parseJson(text: string): unknown { try { return JSON.parse(text); } catch { return undefined; } } function textField(body: string): string | null { const json = parseJson(body); if (json === null || typeof json !== 'object') return null; const text = (json as { text?: unknown }).text; return typeof text === 'string' ? text : null; } function detail(body: string): string { const json = parseJson(body); if (json !== null && typeof json === 'object') { const error = (json as { error?: unknown }).error; if (error !== null && typeof error === 'object') { const message = (error as { message?: unknown }).message; if (typeof message === 'string' && message.trim() !== '') return message.trim(); } } return body.trim().slice(0, DETAIL_LIMIT); } // Причины здесь ещё не очищены: redact применяет OpenAiTranscriber. export function classify(res: HttpResponse): TranscribeResult { const { status, text } = res; if (status >= 200 && status < 300) { const value = textField(text); if (value !== null) return { ok: true, text: value }; const snippet = text.trim().slice(0, DETAIL_LIMIT); return { ok: false, kind: 'permanent', message: `HTTP ${status}: ответ без поля text${snippet ? `: ${snippet}` : ''}`, }; } const info = detail(text); const message = info ? `HTTP ${status}: ${info}` : `HTTP ${status}`; // 3xx до нас доходит только при неверном baseUrl (редиректы адаптер проходит сам). const permanent = status >= 300 && status < 500 && status !== 408 && status !== 429; return { ok: false, kind: permanent ? 'permanent' : 'temporary', message }; } // Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса. export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string { let out = message; const lower = out.toLowerCase(); const cuts = [lower.indexOf('content-disposition')]; if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary)); const cut = Math.min(...cuts.filter((i) => i >= 0), out.length); out = out.slice(0, cut); if (cut < message.length) out = out.replace(/[\s:]+$/, ''); if (secrets.apiKey) out = out.split(secrets.apiKey).join('***'); out = out.replace(/Bearer\s+\S+/g, 'Bearer ***'); return out.slice(0, REASON_LIMIT); } const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message }); const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); function partType(mime: string): string { return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream'; } export class OpenAiTranscriber implements Transcriber { // settings — геттер: исправленный во вкладке ключ применяется к следующему // запросу без пересоздания очереди. constructor( private readonly http: HttpClient, private readonly settings: () => OpenAiSettings, private readonly random: () => number, ) {} // Никогда не бросает: любой исход — TranscribeResult с очищенной причиной. async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { let apiKey = ''; let boundary: string | undefined; let result: TranscribeResult; try { const s = readSettings(this.settings()); apiKey = s.apiKey; boundary = makeBoundary(this.random); result = await this.request(s, audio, mime, signal, boundary); if (!result.ok) result = { ...result, message: redact(result.message, { apiKey, boundary }) }; } catch (e) { result = fail('temporary', redact(`внутренняя ошибка: ${errorText(e)}`, { apiKey, boundary })); } return result; } private request( s: OpenAiSettings, audio: ArrayBuffer, mime: string, signal: AbortSignal, boundary: string, ): Promise { if (!s.apiKey) return Promise.resolve(fail('permanent', NO_API_KEY)); if (!s.baseUrl) return Promise.resolve(fail('permanent', NO_BASE_URL)); if (audio.byteLength === 0) return Promise.resolve(fail('permanent', EMPTY_AUDIO)); if (signal.aborted) return Promise.resolve(fail('temporary', CANCELLED)); const fields: [string, string][] = [ ['model', s.model], ['response_format', 'json'], ]; if (s.language) fields.push(['language', s.language]); const { body, contentType } = buildMultipart( fields, { name: 'file', filename: `audio.${extFromMime(mime)}`, contentType: partType(mime), data: audio }, boundary, ); const url = `${s.baseUrl.replace(/\/+$/, '')}/audio/transcriptions`; // post вызывается синхронно (до того, как abort успеет сработать); // синхронный throw клиента превращается в rejection. let sent: Promise; try { sent = Promise.resolve( this.http.post({ url, headers: { Authorization: `Bearer ${s.apiKey}` }, contentType, body }), ); } catch (e) { sent = Promise.reject(e); } const answer = sent.then( (res) => classify(res), (e: unknown) => fail('temporary', `сеть: ${errorText(e)}`), ); return raceWithDeadline(answer, signal); } } // requestUrl нельзя оборвать: при таймауте или отмене запрос доживает в фоне, // его исход игнорируется. answer никогда не отклоняется — unhandled rejection нет. function raceWithDeadline(answer: Promise, signal: AbortSignal): Promise { let timer: ReturnType | undefined; let onAbort: (() => void) | undefined; const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve(fail('temporary', TIMED_OUT)), REQUEST_TIMEOUT_MS); onAbort = () => resolve(fail('temporary', CANCELLED)); signal.addEventListener('abort', onAbort, { once: true }); }); return Promise.race([answer, deadline]).finally(() => { clearTimeout(timer); if (onAbort) signal.removeEventListener('abort', onAbort); }); }