Andrey Epifancev b38e160073 fix(core): wrap redact() in transcribe()'s try/catch so it never throws
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 00:46:02 +04:00

196 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<OpenAiSettings> = {
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<string, unknown>) : {};
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<TranscribeResult> {
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<TranscribeResult> {
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<HttpResponse>;
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<TranscribeResult>, signal: AbortSignal): Promise<TranscribeResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const deadline = new Promise<TranscribeResult>((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);
});
}