Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
196 lines
8.0 KiB
TypeScript
196 lines
8.0 KiB
TypeScript
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);
|
||
});
|
||
}
|