import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { TranscriptionProvider } from "@rakazo/adapter-kit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createOpenAiCompatibleTranscriptionProvider, OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL, OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES, parseTranscriptionResponse, TranscriptionServerUnavailableError, } from "./openai-compatible-transcription.js"; import { EMULATED_TRANSCRIPT, EmulatorTranscriptionProvider, OpenAiCompatibleTranscriptionServerEmulator, } from "./transcription-emulator.js"; const AUDIO = new Uint8Array([82, 73, 70, 70, 1, 2, 3, 4]); function httpProvider( flavor: "openai" | "plain" | "text", options: { model?: string; apiKey?: string } = {}, ) { const server = new OpenAiCompatibleTranscriptionServerEmulator({ flavor, apiKey: options.apiKey, }); const provider = createOpenAiCompatibleTranscriptionProvider({ baseUrl: "http://127.0.0.1:8080", ...options, fetch: server.fetch, }); return { server, provider }; } const factories: Record TranscriptionProvider> = { emulator: () => new EmulatorTranscriptionProvider(), "openai-compatible": () => httpProvider("openai").provider, "openai-compatible without timed output": () => httpProvider("plain").provider, "openai-compatible plain text": () => httpProvider("text").provider, }; /** * Offline conformance: every TranscriptionProvider declares its limits, returns the spoken * text with ordered, non-overlapping segments in milliseconds (or none), refuses empty or * oversized audio before uploading, and honors cancellation. */ for (const [name, create] of Object.entries(factories)) { describe(`${name} transcription conformance (offline)`, () => { it("describes the contract and its limits", () => { const descriptor = create().describe(); expect(descriptor.contractVersion).toBe("1"); expect(descriptor.capabilities.maxBytes).toBeGreaterThan(0); }); it("returns the text and ordered segments", async () => { const transcript = await create().transcribe({ audio: AUDIO, mimeType: "audio/wav" }); expect(transcript.text).toBe(EMULATED_TRANSCRIPT.text); let previousEnd = 0; for (const segment of transcript.segments) { expect(segment.text.trim()).toBe(segment.text); expect(segment.text.length).toBeGreaterThan(0); expect(segment.startMs).toBeGreaterThanOrEqual(previousEnd); expect(segment.endMs).toBeGreaterThanOrEqual(segment.startMs); previousEnd = segment.endMs; } }); it("refuses empty and oversized audio", async () => { const provider = create(); await expect( provider.transcribe({ audio: new Uint8Array(), mimeType: "audio/wav" }), ).rejects.toThrow(/empty/); const tooLarge = new Uint8Array(provider.describe().capabilities.maxBytes + 1); await expect(provider.transcribe({ audio: tooLarge, mimeType: "audio/wav" })).rejects.toThrow( /too large/, ); }); it("rejects when the caller cancels", async () => { await expect( create().transcribe({ audio: AUDIO, mimeType: "audio/wav", signal: AbortSignal.abort() }), ).rejects.toThrow(); }); }); } describe("OpenAI-compatible transcription provider", () => { afterEach(() => { vi.unstubAllEnvs(); }); it("posts a multipart upload to /v1/audio/transcriptions asking for timed segments", async () => { const { server, provider } = httpProvider("openai", { model: " " }); const transcript = await provider.transcribe({ audio: AUDIO, mimeType: "audio/wav", fileName: "interview take 1.wav", language: "de-AT", }); expect(server.requests).toEqual([ { url: "http://127.0.0.1:8080/v1/audio/transcriptions", authorization: null, model: OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL, responseFormat: "verbose_json", language: "de", fileName: "interview_take_1.wav", fileType: "audio/wav", fileSize: AUDIO.byteLength, }, ]); expect(transcript).toEqual({ ...EMULATED_TRANSCRIPT, language: "en" }); }); it("sends a real multipart body through the default address-checked fetch", async () => { const received: { contentType?: string; body?: Buffer } = {}; const server = createServer((request, response) => { const chunks: Buffer[] = []; request.on("data", (chunk: Buffer) => chunks.push(chunk)); request.on("end", () => { received.contentType = request.headers["content-type"]; received.body = Buffer.concat(chunks); response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ text: "loopback" })); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address() as AddressInfo; const provider = createOpenAiCompatibleTranscriptionProvider({ baseUrl: `http://127.0.0.1:${port}`, }); await expect( provider.transcribe({ audio: AUDIO, mimeType: "audio/wav", fileName: "a.wav" }), ).resolves.toEqual({ text: "loopback", segments: [] }); const form = await new Request("http://127.0.0.1/", { method: "POST", headers: { "content-type": received.contentType ?? "" }, body: new Uint8Array(received.body ?? []), }).formData(); expect(form.get("response_format")).toBe("verbose_json"); const file = form.get("file"); expect(file instanceof File && file.name).toBe("a.wav"); expect(file instanceof File && new Uint8Array(await file.arrayBuffer())).toEqual(AUDIO); } finally { await new Promise((resolve) => server.close(resolve)); } }); it("names an upload without an extension after its type and sends the configured model", async () => { const { server, provider } = httpProvider("openai", { model: "Systran/faster-whisper-small" }); await provider.transcribe({ audio: AUDIO, mimeType: "audio/mpeg", fileName: "voice note" }); expect(server.requests[0]).toMatchObject({ model: "Systran/faster-whisper-small", fileName: "voice_note.mp3", }); }); it("asks again for plain JSON when the server rejects timed output", async () => { const { server, provider } = httpProvider("plain"); const transcript = await provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" }); expect(server.requests.map((request) => request.responseFormat)).toEqual([ "verbose_json", "json", ]); expect(transcript).toEqual({ text: EMULATED_TRANSCRIPT.text, segments: [] }); }); it("sends the API key as a bearer token over https", async () => { vi.stubEnv("RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC", "1"); const server = new OpenAiCompatibleTranscriptionServerEmulator({ apiKey: "test-key" }); const provider = createOpenAiCompatibleTranscriptionProvider({ baseUrl: "https://speech.example.test/v1", apiKey: "test-key", fetch: server.fetch, }); await expect( provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" }), ).resolves.toMatchObject({ text: EMULATED_TRANSCRIPT.text }); expect(server.requests[0]?.authorization).toBe("Bearer test-key"); }); it("reports server failures in words", async () => { const { server, provider } = httpProvider("openai"); server.failNextStatus = 500; await expect(provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow( "Transcription server returned 500: Upstream failure", ); const keyed = httpProvider("openai", { apiKey: "right" }); const wrong = createOpenAiCompatibleTranscriptionProvider({ baseUrl: "http://127.0.0.1:8080", apiKey: "wrong", fetch: keyed.server.fetch, }); await expect(wrong.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow( "Transcription server rejected the API key", ); const missing = createOpenAiCompatibleTranscriptionProvider({ baseUrl: "http://127.0.0.1:8080", fetch: async () => Response.json({ error: { message: "Not found" } }, { status: 404 }), }); await expect(missing.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow( "Transcription server has no /audio/transcriptions endpoint", ); const unreachable = createOpenAiCompatibleTranscriptionProvider({ baseUrl: "http://127.0.0.1:8080", fetch: async () => { throw new TypeError("fetch failed"); }, }); await expect(unreachable.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow( TranscriptionServerUnavailableError, ); server.failNextStatus = 503; await expect(provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow( TranscriptionServerUnavailableError, ); }); it("refuses unsafe endpoints before sending anything", () => { expect(() => createOpenAiCompatibleTranscriptionProvider({ baseUrl: "http://169.254.169.254" }), ).toThrow(/blocked/); expect(() => createOpenAiCompatibleTranscriptionProvider({ baseUrl: "https://speech.example.test" }), ).toThrow(/Public model endpoints are blocked/); vi.stubEnv("RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC", "1"); expect(() => createOpenAiCompatibleTranscriptionProvider({ baseUrl: "http://speech.example.test", apiKey: "secret", }), ).toThrow(/must use HTTPS/); expect(() => createOpenAiCompatibleTranscriptionProvider({ baseUrl: "file:///etc/passwd" }), ).toThrow(); }); it("caps the upload size at the protocol limit", () => { const { provider } = httpProvider("openai"); expect(provider.describe().capabilities.maxBytes).toBe( OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES, ); }); }); describe("parseTranscriptionResponse", () => { it("reads verbose JSON segments in seconds, dropping malformed ones and overlaps", () => { const transcript = parseTranscriptionResponse( JSON.stringify({ text: " Hallo. Wie geht's? ", language: "german", segments: [ { start: 0, end: 1.25, text: " Hallo." }, { start: "x", end: 2, text: "bad timing" }, { start: 1, end: 2.5, text: " Wie geht's?" }, { start: 3, end: 4, text: " " }, { start: 3, end: 4 }, ], }), "application/json", ); expect(transcript).toEqual({ text: "Hallo. Wie geht's?", language: "de", segments: [ { startMs: 0, endMs: 1250, text: "Hallo." }, { startMs: 1250, endMs: 2500, text: "Wie geht's?" }, ], }); }); it("keeps a language code and reads plain JSON and text bodies", () => { expect(parseTranscriptionResponse('{"text":"hi","language":"en"}', null)).toEqual({ text: "hi", language: "en", segments: [], }); expect(parseTranscriptionResponse(" just words \n", "text/plain")).toEqual({ text: "just words", segments: [], }); expect(() => parseTranscriptionResponse('{"segments":[]}', "application/json")).toThrow( "Transcription server returned no text", ); expect(() => parseTranscriptionResponse("{oops", "application/json")).toThrow( "Transcription server returned invalid JSON", ); }); });