feat: OpenAI-compatible transcriber provider (#5) #6

Merged
aep merged 15 commits from feat/5-openai-transcriber into main 2026-10-10 17:43:23 +03:00
4 changed files with 380 additions and 2 deletions
Showing only changes of commit 06d3528efd - Show all commits

View File

@ -1,4 +1,6 @@
import type { HttpResponse, TranscribeResult } from './ports';
import { buildMultipart, makeBoundary } from './multipart';
import { extFromMime } from './naming';
import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports';
export interface OpenAiSettings {
baseUrl: string;
@ -100,3 +102,93 @@ export function redact(message: string, secrets: { apiKey: string; boundary?: st
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);
} catch (e) {
result = fail('temporary', `внутренняя ошибка: ${errorText(e)}`);
}
return result.ok ? result : { ...result, message: redact(result.message, { apiKey, boundary }) };
}
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);
});
}

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { redact } from '../src/core/openai';
import { DEFAULT_OPENAI, OpenAiTranscriber, redact } from '../src/core/openai';
import type { HttpRequest } from '../src/core/ports';
import { FakeHttp } from './fakes';
const KEY = 'sk-test-123';
@ -38,3 +40,45 @@ describe('redact', () => {
expect(out).not.toContain('sk-');
});
});
describe('secrets never reach the reason (end to end)', () => {
const audio = () => new Uint8Array([1, 2, 3]).buffer;
const run = async (reply: ConstructorParameters<typeof FakeHttp>[0]) => {
const http = new FakeHttp(reply);
const t = new OpenAiTranscriber(http, () => ({ ...DEFAULT_OPENAI, apiKey: KEY }), () => 0);
const result = await t.transcribe(audio(), 'audio/mp4', new AbortController().signal);
if (result.ok) throw new Error('expected an error');
return { message: result.message, req: http.requests[0] };
};
const echo = (status: number) => (req: HttpRequest) => ({ status, text: new TextDecoder().decode(req.body) });
it('401 JSON echoing the key and Authorization header', async () => {
const body = JSON.stringify({
error: { message: `Incorrect API key provided: ${KEY}. Authorization: Bearer ${KEY}` },
});
const { message } = await run(() => ({ status: 401, text: body }));
expect(message).toBe('HTTP 401: Incorrect API key provided: ***. Authorization: Bearer ***');
});
it('401 plain text echoing the Authorization header', async () => {
const { message } = await run(() => ({ status: 401, text: `Authorization: Bearer ${KEY}` }));
expect(message).not.toContain(KEY);
expect(message).toBe('HTTP 401: Authorization: Bearer ***');
});
it('network exception mentioning the key', async () => {
const { message } = await run(() =>
Promise.reject(new Error(`connect failed for Bearer ${KEY} at https://x/?key=${KEY}`)),
);
expect(message).not.toContain(KEY);
expect(message).toBe('сеть: connect failed for Bearer *** at https://x/?key=***');
});
it.each([200, 400, 401, 500])('service echoing the request body with status %i', async (status) => {
const { message, req } = await run(echo(status));
const boundary = /boundary=(.+)$/.exec(req.contentType)![1];
expect(message).not.toContain(KEY);
expect(message).not.toContain(boundary);
expect(message.toLowerCase()).not.toContain('content-disposition');
});
});

View File

@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import {
CANCELLED,
DEFAULT_OPENAI,
EMPTY_AUDIO,
NO_API_KEY,
NO_BASE_URL,
OpenAiTranscriber,
type OpenAiSettings,
} from '../src/core/openai';
import { bytesIndexOf, FakeHttp, formParts } from './fakes';
const KEY = 'sk-test-123';
const settings = (over: Partial<OpenAiSettings> = {}) => () => ({ ...DEFAULT_OPENAI, apiKey: KEY, ...over });
const audio = () => new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a]).buffer;
const live = () => new AbortController().signal;
async function send(over: Partial<OpenAiSettings> = {}, mime = 'audio/mp4') {
const http = new FakeHttp();
const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(audio(), mime, live());
return { http, result, req: http.requests[0] };
}
describe('request', () => {
it.each([
'https://api.aitunnel.ru/v1',
'https://api.aitunnel.ru/v1/',
'https://api.aitunnel.ru/v1//',
' https://api.aitunnel.ru/v1/ ',
])('posts to <baseUrl>/audio/transcriptions for %j', async (baseUrl) => {
const { req } = await send({ baseUrl });
expect(req.url).toBe('https://api.aitunnel.ru/v1/audio/transcriptions');
});
it('trims key with newline and sends only Authorization in headers', async () => {
const { req } = await send({ apiKey: ` ${KEY} \n` });
expect(req.headers).toEqual({ Authorization: `Bearer ${KEY}` });
});
it('passes multipart contentType separately, boundary from random() matches the body', async () => {
const { req } = await send();
const boundary = `----voice-note-${'0'.repeat(24)}`;
expect(req.contentType).toBe(`multipart/form-data; boundary=${boundary}`);
expect(new TextDecoder().decode(req.body).startsWith(`--${boundary}\r\n`)).toBe(true);
});
it('sends model, response_format=json and no language by default', async () => {
const { req } = await send();
expect(formParts(req)).toEqual({ model: 'gpt-4o-transcribe', response_format: 'json', file: 'audio.m4a|audio/mp4' });
});
it('sends trimmed model and language when set', async () => {
const { req } = await send({ model: ' whisper-1 ', language: ' ru ' });
expect(formParts(req)).toMatchObject({ model: 'whisper-1', language: 'ru' });
});
it.each([
['audio/mp4;codecs=mp4a.40.2', 'audio.m4a|audio/mp4'],
['audio/webm;codecs=opus', 'audio.webm|audio/webm'],
['audio/ogg', 'audio.ogg|audio/ogg'],
['AUDIO/MP4', 'audio.m4a|audio/mp4'],
['audio/x-foo', 'audio.bin|audio/x-foo'],
['', 'audio.bin|application/octet-stream'],
])('names the file for mime %j', async (mime, expected) => {
const { req } = await send({}, mime);
expect(formParts(req).file).toBe(expected);
});
it('sends audio bytes unchanged', async () => {
const { req } = await send();
expect(bytesIndexOf(new Uint8Array(req.body), new Uint8Array(audio()))).toBeGreaterThan(0);
});
it('reads settings on every call (fixed key applies without restart)', async () => {
const http = new FakeHttp();
const current: OpenAiSettings = { ...DEFAULT_OPENAI, apiKey: 'sk-test-old' };
const t = new OpenAiTranscriber(http, () => current, () => 0);
await t.transcribe(audio(), 'audio/mp4', live());
current.apiKey = 'sk-test-new';
await t.transcribe(audio(), 'audio/mp4', live());
expect(http.requests.map((r) => r.headers.Authorization)).toEqual(['Bearer sk-test-old', 'Bearer sk-test-new']);
});
it('returns the service text on success', async () => {
const { result } = await send();
expect(result).toEqual({ ok: true, text: 'ok' });
});
});
describe('prechecks (no network)', () => {
const run = async (over: Partial<OpenAiSettings>, data: ArrayBuffer, signal: AbortSignal) => {
const http = new FakeHttp();
const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(data, 'audio/mp4', signal);
expect(http.requests).toHaveLength(0);
return result;
};
const aborted = () => {
const c = new AbortController();
c.abort();
return c.signal;
};
it.each(['', ' \n'])('empty key %j → permanent', async (apiKey) => {
expect(await run({ apiKey }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_API_KEY });
});
it('empty baseUrl → permanent', async () => {
expect(await run({ baseUrl: ' ' }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_BASE_URL });
});
it('0 bytes → permanent', async () => {
expect(await run({}, new ArrayBuffer(0), live())).toEqual({ ok: false, kind: 'permanent', message: EMPTY_AUDIO });
});
it('already aborted → temporary', async () => {
expect(await run({}, audio(), aborted())).toEqual({ ok: false, kind: 'temporary', message: CANCELLED });
});
it('checks in order: key, baseUrl, audio, signal', async () => {
expect(await run({ apiKey: '', baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_API_KEY });
expect(await run({ baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_BASE_URL });
expect(await run({}, new ArrayBuffer(0), aborted())).toMatchObject({ message: EMPTY_AUDIO });
});
});
describe('never throws', () => {
it('network rejection → temporary "сеть: …"', async () => {
const http = new FakeHttp(() => Promise.reject(new Error('net::ERR_INTERNET_DISCONNECTED')));
const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: net::ERR_INTERNET_DISCONNECTED' });
});
it('synchronous throw from the client → temporary "сеть: …"', async () => {
const http = new FakeHttp(() => {
throw new Error('boom');
});
const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: boom' });
});
it('non-Error rejection is stringified', async () => {
const http = new FakeHttp(() => Promise.reject('offline'));
const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: offline' });
});
it('unexpected exception → temporary "внутренняя ошибка: …"', async () => {
const broken = () => {
throw new Error('settings exploded');
};
const result = await new OpenAiTranscriber(new FakeHttp(), broken, () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'внутренняя ошибка: settings exploded' });
});
});

View File

@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CANCELLED, DEFAULT_OPENAI, OpenAiTranscriber, REQUEST_TIMEOUT_MS, TIMED_OUT } from '../src/core/openai';
import type { HttpResponse, TranscribeResult } from '../src/core/ports';
import { SENT_STALE_MS } from '../src/core/record';
import { FakeHttp } from './fakes';
const settings = () => ({ ...DEFAULT_OPENAI, apiKey: 'sk-test-123' });
const audio = () => new Uint8Array([1, 2, 3]).buffer;
const never = () => new Promise<HttpResponse>(() => {});
describe('timeout invariant', () => {
it('is 5 minutes and below the stale-sent threshold', () => {
expect(REQUEST_TIMEOUT_MS).toBe(300_000);
expect(REQUEST_TIMEOUT_MS).toBeLessThan(SENT_STALE_MS);
});
});
describe('timeout and abort', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('gives up after exactly REQUEST_TIMEOUT_MS and leaves no timers', async () => {
const t = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0);
let done: TranscribeResult | undefined;
const p = t.transcribe(audio(), 'audio/mp4', new AbortController().signal).then((r) => (done = r));
await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS - 1);
expect(done).toBeUndefined();
await vi.advanceTimersByTimeAsync(1);
await p;
expect(done).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT });
expect(vi.getTimerCount()).toBe(0);
});
it('returns "cancelled" right away when aborted in flight', async () => {
const c = new AbortController();
const remove = vi.spyOn(c.signal, 'removeEventListener');
const p = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal);
await vi.advanceTimersByTimeAsync(1000);
c.abort();
expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED });
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
});
it('clears the timer and the abort listener after a normal answer', async () => {
const c = new AbortController();
const remove = vi.spyOn(c.signal, 'removeEventListener');
const result = await new OpenAiTranscriber(new FakeHttp(), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal);
expect(result).toEqual({ ok: true, text: 'ok' });
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
});
it('ignores a late answer after timeout', async () => {
let resolve!: (r: HttpResponse) => void;
const http = new FakeHttp(() => new Promise<HttpResponse>((r) => (resolve = r)));
const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', new AbortController().signal);
await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS);
expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT });
resolve({ status: 200, text: '{"text":"late"}' });
await vi.advanceTimersByTimeAsync(0);
expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT });
});
it('does not leak an unhandled rejection from a late failure', async () => {
const unhandled: unknown[] = [];
const onUnhandled = (e: unknown) => unhandled.push(e);
process.on('unhandledRejection', onUnhandled);
try {
let reject!: (e: unknown) => void;
const http = new FakeHttp(() => new Promise<HttpResponse>((_, r) => (reject = r)));
const c = new AbortController();
const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal);
c.abort();
expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED });
reject(new Error('late failure'));
vi.useRealTimers();
await new Promise((r) => setTimeout(r, 10));
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
}
});
});