From 06d3528efdba46684dbbdc52dfd09b6e5aaa142c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:31:01 +0400 Subject: [PATCH] feat(core): OpenAI-compatible transcriber with prechecks, timeout and abort Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/openai.ts | 94 ++++++++++++++++++++- tests/openai-redact.test.ts | 46 ++++++++++- tests/openai-request.test.ts | 154 +++++++++++++++++++++++++++++++++++ tests/openai-timeout.test.ts | 88 ++++++++++++++++++++ 4 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 tests/openai-request.test.ts create mode 100644 tests/openai-timeout.test.ts diff --git a/src/core/openai.ts b/src/core/openai.ts index 404afed..ecfd6d4 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -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 { + 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 { + 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); + }); +} diff --git a/tests/openai-redact.test.ts b/tests/openai-redact.test.ts index d1a0f97..fb9efcf 100644 --- a/tests/openai-redact.test.ts +++ b/tests/openai-redact.test.ts @@ -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[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'); + }); +}); diff --git a/tests/openai-request.test.ts b/tests/openai-request.test.ts new file mode 100644 index 0000000..c75d619 --- /dev/null +++ b/tests/openai-request.test.ts @@ -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 = {}) => () => ({ ...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 = {}, 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 /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, 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' }); + }); +}); diff --git a/tests/openai-timeout.test.ts b/tests/openai-timeout.test.ts new file mode 100644 index 0000000..096bc5c --- /dev/null +++ b/tests/openai-timeout.test.ts @@ -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(() => {}); + +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((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((_, 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); + } + }); +});