diff --git a/src/backend/drivers/ai-speech2txt/SpeechToTextDriver.test.ts b/src/backend/drivers/ai-speech2txt/SpeechToTextDriver.test.ts
new file mode 100644
index 000000000..b10e4c85c
--- /dev/null
+++ b/src/backend/drivers/ai-speech2txt/SpeechToTextDriver.test.ts
@@ -0,0 +1,786 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * Offline unit tests for SpeechToTextDriver.
+ *
+ * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
+ * redis) configured with an OpenAI API key, then drives
+ * `server.drivers.aiSpeech2Txt` directly. The OpenAI SDK is mocked at
+ * the module boundary — that's the real network egress point — so the
+ * driver never reaches OpenAI. Audio inputs use `data:` URLs through
+ * the live `loadFileInput`, and the FS-resolution branch is exercised
+ * by writing a real file via `server.services.fs.write`. Aligns with
+ * AGENTS.md: "Prefer test server over mocking deps."
+ */
+
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+ type MockInstance,
+} from 'vitest';
+import { v4 as uuidv4 } from 'uuid';
+
+import type { Actor } from '../../core/actor.js';
+import { runWithContext } from '../../core/context.js';
+import { PuterServer } from '../../server.js';
+import type { MeteringService } from '../../services/metering/MeteringService.js';
+import { setupTestServer } from '../../testUtil.js';
+import { generateDefaultFsentries } from '../../util/userProvisioning.js';
+import type { SpeechToTextDriver } from './SpeechToTextDriver.js';
+import { SPEECH_TO_TEXT_COSTS } from './costs.js';
+
+// ── OpenAI SDK mock ─────────────────────────────────────────────────
+//
+// The driver does `import OpenAI, { toFile } from 'openai'` and then
+// calls `audio.transcriptions.create` / `audio.translations.create`.
+// Mock the constructor + `toFile` so the driver never actually issues
+// a network request and we can inspect the payload it would have sent.
+
+const {
+ transcriptionsCreateMock,
+ translationsCreateMock,
+ openAICtor,
+ toFileMock,
+} = vi.hoisted(() => ({
+ transcriptionsCreateMock: vi.fn(),
+ translationsCreateMock: vi.fn(),
+ openAICtor: vi.fn(),
+ // Capture the (buffer, filename, options) handed to `toFile` and
+ // return a sentinel object so we can assert it was forwarded as
+ // `payload.file` to the OpenAI call. Real toFile builds a multipart
+ // FileLike which would otherwise leak into the assertions.
+ toFileMock: vi.fn(
+ async (
+ buffer: unknown,
+ filename: string,
+ options?: { type?: string },
+ ) => ({
+ __mockFile: true,
+ buffer,
+ filename,
+ type: options?.type,
+ }),
+ ),
+}));
+
+vi.mock('openai', () => {
+ const OpenAICtor = vi.fn().mockImplementation(function (
+ this: Record,
+ opts: unknown,
+ ) {
+ openAICtor(opts);
+ this.audio = {
+ transcriptions: { create: transcriptionsCreateMock },
+ translations: { create: translationsCreateMock },
+ // Sibling TTS provider in the same PuterServer constructs
+ // its own OpenAI client during boot — keep that namespace
+ // populated so the boot doesn't crash on missing fields.
+ speech: { create: vi.fn() },
+ };
+ this.chat = { completions: { create: vi.fn() } };
+ this.images = { generate: vi.fn() };
+ this.responses = { create: vi.fn() };
+ });
+ // Two consumer shapes coexist in the codebase:
+ // - `import OpenAI from 'openai'; new OpenAI(...)` (this driver)
+ // - `import openai from 'openai'; new openai.OpenAI(...)` (Ollama chat)
+ // The default export has to satisfy both, so attach `.OpenAI` onto
+ // the constructor itself before returning.
+ (OpenAICtor as unknown as { OpenAI: unknown }).OpenAI = OpenAICtor;
+ return {
+ OpenAI: OpenAICtor,
+ default: OpenAICtor,
+ toFile: toFileMock,
+ };
+});
+
+// ── Test harness ────────────────────────────────────────────────────
+
+let server: PuterServer;
+let driver: SpeechToTextDriver;
+let hasCreditsSpy: MockInstance;
+let incrementUsageSpy: MockInstance;
+
+beforeAll(async () => {
+ server = await setupTestServer({
+ providers: {
+ 'openai-speech-to-text': { apiKey: 'openai-test-key' },
+ },
+ } as never);
+ driver = server.drivers.aiSpeech2Txt as unknown as SpeechToTextDriver;
+});
+
+afterAll(async () => {
+ await server?.shutdown();
+});
+
+beforeEach(() => {
+ transcriptionsCreateMock.mockReset();
+ translationsCreateMock.mockReset();
+ openAICtor.mockReset();
+ toFileMock.mockClear();
+ // Spy on metering — keep the real impl so its recording side runs,
+ // but capture calls so per-test assertions can inspect them.
+ hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
+ incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
+ const username = `stt-${Math.random().toString(36).slice(2, 10)}`;
+ const created = await server.stores.user.create({
+ username,
+ uuid: uuidv4(),
+ password: null,
+ email: `${username}@test.local`,
+ free_storage: 100 * 1024 * 1024,
+ requires_email_confirmation: false,
+ });
+ await generateDefaultFsentries(
+ server.clients.db,
+ server.stores.user,
+ created,
+ );
+ const refreshed = (await server.stores.user.getById(created.id))!;
+ return {
+ userId: refreshed.id,
+ actor: {
+ user: {
+ id: refreshed.id,
+ uuid: refreshed.uuid,
+ username: refreshed.username,
+ email: refreshed.email ?? null,
+ email_confirmed: true,
+ } as Actor['user'],
+ },
+ };
+};
+
+const withActor = (actor: Actor, fn: () => T | Promise): Promise =>
+ Promise.resolve(runWithContext({ actor }, fn));
+
+const dataUrl = (buffer: Buffer, mime: string) =>
+ `data:${mime};base64,${buffer.toString('base64')}`;
+
+// ── getReportedCosts ────────────────────────────────────────────────
+
+describe('SpeechToTextDriver.getReportedCosts', () => {
+ it('mirrors every entry in costs.ts as a per-second line item', () => {
+ const reported = driver.getReportedCosts();
+
+ expect(reported).toHaveLength(Object.keys(SPEECH_TO_TEXT_COSTS).length);
+ for (const [usageType, ucentsPerUnit] of Object.entries(
+ SPEECH_TO_TEXT_COSTS,
+ )) {
+ expect(reported).toContainEqual({
+ usageType,
+ ucentsPerUnit,
+ unit: 'second',
+ source: 'driver:aiSpeech2Txt',
+ });
+ }
+ });
+});
+
+// ── list_models ─────────────────────────────────────────────────────
+
+describe('SpeechToTextDriver.list_models', () => {
+ it('returns the full catalog with response_formats and capability flags', async () => {
+ const models = await driver.list_models();
+ const ids = models.map((m) => m.id);
+
+ expect(ids).toEqual(
+ expect.arrayContaining([
+ 'gpt-4o-mini-transcribe',
+ 'gpt-4o-transcribe',
+ 'gpt-4o-transcribe-diarize',
+ 'whisper-1',
+ ]),
+ );
+
+ const miniTranscribe = models.find(
+ (m) => m.id === 'gpt-4o-mini-transcribe',
+ )!;
+ expect(miniTranscribe.type).toBe('transcription');
+ expect(miniTranscribe.supports_prompt).toBe(true);
+ expect(miniTranscribe.supports_logprobs).toBe(true);
+ expect(miniTranscribe.response_formats).toEqual(['json', 'text']);
+
+ // whisper-1 is the only one we classify as "translation" since
+ // it's the default model the driver picks for translate().
+ const whisper = models.find((m) => m.id === 'whisper-1')!;
+ expect(whisper.type).toBe('translation');
+ expect(whisper.response_formats).toEqual(
+ expect.arrayContaining(['json', 'text', 'srt', 'verbose_json', 'vtt']),
+ );
+ expect(
+ (whisper as { supports_timestamp_granularities?: boolean })
+ .supports_timestamp_granularities,
+ ).toBe(true);
+
+ const diarize = models.find(
+ (m) => m.id === 'gpt-4o-transcribe-diarize',
+ )!;
+ expect(diarize.supports_prompt).toBe(false);
+ expect(diarize.supports_logprobs).toBe(false);
+ expect(
+ (diarize as { supports_diarization?: boolean }).supports_diarization,
+ ).toBe(true);
+ expect(diarize.response_formats).toContain('diarized_json');
+ });
+});
+
+// ── test_mode bypass ────────────────────────────────────────────────
+
+describe('SpeechToTextDriver.transcribe test_mode', () => {
+ it('returns the canned sample for transcribe, bypassing all I/O', async () => {
+ const result = (await driver.transcribe({
+ file: undefined,
+ test_mode: true,
+ })) as { text: string; model: string; language: string };
+
+ expect(result.text).toMatch(/sample transcription/i);
+ expect(result.language).toBe('en');
+ // No file required, no actor required, no SDK / metering hit.
+ expect(transcriptionsCreateMock).not.toHaveBeenCalled();
+ expect(translationsCreateMock).not.toHaveBeenCalled();
+ expect(incrementUsageSpy).not.toHaveBeenCalled();
+ expect(result.model).toBe('gpt-4o-mini-transcribe');
+ });
+
+ it('returns the canned sample for translate with whisper-1 default', async () => {
+ const result = (await driver.translate({
+ file: undefined,
+ test_mode: true,
+ })) as { text: string; model: string };
+ expect(result.model).toBe('whisper-1');
+ expect(translationsCreateMock).not.toHaveBeenCalled();
+ });
+
+ it('echoes an explicit model in test_mode rather than the default', async () => {
+ const result = (await driver.transcribe({
+ file: undefined,
+ test_mode: true,
+ model: 'whisper-1',
+ })) as { model: string };
+ expect(result.model).toBe('whisper-1');
+ });
+});
+
+// ── Argument validation ─────────────────────────────────────────────
+
+describe('SpeechToTextDriver argument validation', () => {
+ it('rejects streaming with 400 — not yet supported', async () => {
+ const { actor } = await makeUser();
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ stream: true,
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ expect(transcriptionsCreateMock).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when file is missing', async () => {
+ const { actor } = await makeUser();
+ await expect(
+ withActor(actor, () => driver.transcribe({ file: undefined })),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ it('throws 401 when no actor is on the request context', async () => {
+ await expect(
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ }),
+ ).rejects.toMatchObject({ statusCode: 401 });
+ });
+
+ it('throws 400 on an unknown model', async () => {
+ const { actor } = await makeUser();
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'totally-not-real',
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ expect(transcriptionsCreateMock).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when response_format is not supported by the chosen model', async () => {
+ const { actor } = await makeUser();
+ // `srt` is whisper-only — not in the gpt-4o-mini-transcribe catalog.
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ response_format: 'srt',
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ it('throws 400 when prompt is supplied to a model that does not support it', async () => {
+ const { actor } = await makeUser();
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-transcribe-diarize',
+ prompt: 'context',
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ it('throws 400 when logprobs is requested on a model that does not support it', async () => {
+ const { actor } = await makeUser();
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'whisper-1',
+ logprobs: true,
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+});
+
+// ── Credit gate ─────────────────────────────────────────────────────
+
+describe('SpeechToTextDriver credit gate', () => {
+ it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
+ hasCreditsSpy.mockResolvedValueOnce(false);
+ const { actor } = await makeUser();
+
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('audio-bytes'), 'audio/mp3'),
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 402 });
+
+ expect(transcriptionsCreateMock).not.toHaveBeenCalled();
+ expect(incrementUsageSpy).not.toHaveBeenCalled();
+ });
+});
+
+// ── Audio input handling ────────────────────────────────────────────
+
+describe('SpeechToTextDriver audio input handling', () => {
+ it('decodes a base64 data URL and forwards the raw buffer to OpenAI', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({
+ text: 'hello world',
+ });
+
+ const audioBytes = Buffer.from('fake-mp3-bytes');
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(audioBytes, 'audio/mp3'),
+ }),
+ );
+
+ // toFile got the decoded buffer + mime type from the data URL.
+ expect(toFileMock).toHaveBeenCalledTimes(1);
+ const [toFileBuf, , toFileOpts] = toFileMock.mock.calls[0]!;
+ expect(Buffer.isBuffer(toFileBuf)).toBe(true);
+ expect((toFileBuf as Buffer).equals(audioBytes)).toBe(true);
+ expect(toFileOpts).toEqual({ type: 'audio/mp3' });
+
+ // That sentinel is what got forwarded as `payload.file`.
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.file).toEqual({
+ __mockFile: true,
+ buffer: audioBytes,
+ filename: expect.any(String),
+ type: 'audio/mp3',
+ });
+ });
+
+ it('resolves an FS path through the live FSService and preserves the filename', async () => {
+ const { actor, userId } = await makeUser();
+ const audioBytes = Buffer.from('fs-backed-audio-data');
+ await server.services.fs.write(userId, {
+ fileMetadata: {
+ path: `/${actor.user.username}/clip.mp3`,
+ size: audioBytes.byteLength,
+ contentType: 'audio/mpeg',
+ },
+ fileContent: audioBytes,
+ });
+
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'ok' });
+
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: { path: `/${actor.user.username}/clip.mp3` },
+ }),
+ );
+
+ const [, toFileName, toFileOpts] = toFileMock.mock.calls[0]!;
+ expect(toFileName).toBe('clip.mp3');
+ expect(toFileOpts).toEqual({ type: 'audio/mpeg' });
+ });
+
+ it('rejects audio above the 25 MB cap via loadFileInput (413 storage_limit_reached)', async () => {
+ const { actor } = await makeUser();
+ // 25 MiB + 1 byte → exceeds MAX_AUDIO_FILE_SIZE; loadFileInput throws
+ // 413 from assertMax (not 400 — this is a payload-size error, not a
+ // bad request).
+ const huge = Buffer.alloc(25 * 1024 * 1024 + 1, 0);
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(huge, 'audio/mp3'),
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 413 });
+ expect(transcriptionsCreateMock).not.toHaveBeenCalled();
+ });
+});
+
+// ── Model selection / payload shape ─────────────────────────────────
+
+describe('SpeechToTextDriver model selection and payload shape', () => {
+ it('defaults transcribe() to gpt-4o-mini-transcribe', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.model).toBe('gpt-4o-mini-transcribe');
+ // translate endpoint must not be touched.
+ expect(translationsCreateMock).not.toHaveBeenCalled();
+ });
+
+ it('defaults translate() to whisper-1 and hits the translations endpoint', async () => {
+ const { actor } = await makeUser();
+ translationsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ await withActor(actor, () =>
+ driver.translate({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ }),
+ );
+
+ const sent = translationsCreateMock.mock.calls[0]![0];
+ expect(sent.model).toBe('whisper-1');
+ expect(transcriptionsCreateMock).not.toHaveBeenCalled();
+ });
+
+ it('forwards optional fields (language, prompt, logprobs, temperature)', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ language: 'en',
+ prompt: 'transcribe this carefully',
+ logprobs: true,
+ temperature: 0.2,
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.language).toBe('en');
+ expect(sent.prompt).toBe('transcribe this carefully');
+ expect(sent.logprobs).toBe(true);
+ expect(sent.temperature).toBe(0.2);
+ });
+
+ it('forwards timestamp_granularities only on whisper-1 (the model that supports it)', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'whisper-1',
+ response_format: 'verbose_json',
+ timestamp_granularities: ['word'],
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.timestamp_granularities).toEqual(['word']);
+ });
+
+ it('passes extra_body through verbatim for non-diarize models', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ extra_body: { custom: 'value' },
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.extra_body).toEqual({ custom: 'value' });
+ });
+});
+
+// ── Diarization branch ──────────────────────────────────────────────
+
+describe('SpeechToTextDriver diarization handling', () => {
+ it('defaults response_format to diarized_json on the diarize model', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] });
+
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-transcribe-diarize',
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.response_format).toBe('diarized_json');
+ });
+
+ it('auto-enables chunking_strategy when estimated duration exceeds 30s', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] });
+
+ // estimatedSeconds = ceil(bytes / 16000); 16000 * 31 = 496000 bytes
+ // → 31s > 30s threshold → driver sets chunking_strategy = 'auto'.
+ const longAudio = Buffer.alloc(16000 * 31, 0);
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(longAudio, 'audio/mp3'),
+ model: 'gpt-4o-transcribe-diarize',
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.chunking_strategy).toBe('auto');
+ });
+
+ it('does NOT auto-enable chunking_strategy for short audio', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] });
+
+ // 16000 bytes = 1s estimated → below the 30s threshold.
+ const shortAudio = Buffer.from('short');
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(shortAudio, 'audio/mp3'),
+ model: 'gpt-4o-transcribe-diarize',
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.chunking_strategy).toBeUndefined();
+ });
+
+ it('packs known_speaker_names / known_speaker_references into extra_body', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] });
+
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-transcribe-diarize',
+ known_speaker_names: ['Alice', 'Bob'],
+ known_speaker_references: ['ref1', 'ref2'],
+ extra_body: { keep_me: true },
+ }),
+ );
+
+ const sent = transcriptionsCreateMock.mock.calls[0]![0];
+ expect(sent.extra_body).toEqual({
+ keep_me: true,
+ known_speaker_names: ['Alice', 'Bob'],
+ known_speaker_references: ['ref1', 'ref2'],
+ });
+ });
+});
+
+// ── Response shape ──────────────────────────────────────────────────
+
+describe('SpeechToTextDriver response shape', () => {
+ it('returns a raw string when response_format=text and OpenAI yields a string', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce(
+ 'just the plain text transcript',
+ );
+
+ const result = await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ response_format: 'text',
+ }),
+ );
+
+ expect(result).toBe('just the plain text transcript');
+ });
+
+ it('extracts .text when response_format=text but OpenAI returns an object', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({
+ text: 'extracted from object',
+ });
+
+ const result = await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ response_format: 'text',
+ }),
+ );
+
+ expect(result).toBe('extracted from object');
+ });
+
+ it('forwards the OpenAI object verbatim for non-text response formats', async () => {
+ const { actor } = await makeUser();
+ const upstream = {
+ text: 'hello world',
+ language: 'en',
+ duration: 1.5,
+ segments: [{ id: 0, text: 'hello' }],
+ };
+ transcriptionsCreateMock.mockResolvedValueOnce(upstream);
+
+ const result = await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ }),
+ );
+
+ expect(result).toBe(upstream);
+ });
+});
+
+// ── Metering ────────────────────────────────────────────────────────
+
+describe('SpeechToTextDriver metering', () => {
+ it('meters estimated seconds × per-model ucents from costs.ts', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ // estimatedSeconds = max(1, ceil(bytes / 16000)). 32000 bytes → 2s.
+ const audio = Buffer.alloc(32000, 0);
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(audio, 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ }),
+ );
+
+ const usageType = 'openai:gpt-4o-mini-transcribe:second';
+ const perSecond = SPEECH_TO_TEXT_COSTS[usageType];
+ const sttCalls = incrementUsageSpy.mock.calls.filter(
+ ([, type]) => type === usageType,
+ );
+ expect(sttCalls).toHaveLength(1);
+ const [actorArg, , count, cost] = sttCalls[0]!;
+ expect((actorArg as Actor).user.id).toBe(actor.user.id);
+ expect(count).toBe(2);
+ expect(cost).toBe(perSecond * 2);
+ });
+
+ it('clamps the metered duration to a minimum of one second', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ // 1-byte buffer → ceil(1/16000) = 1 → metered as 1 second.
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ model: 'whisper-1',
+ }),
+ );
+
+ const usageType = 'openai:whisper-1:second';
+ const sttCalls = incrementUsageSpy.mock.calls.filter(
+ ([, type]) => type === usageType,
+ );
+ expect(sttCalls).toHaveLength(1);
+ const [, , count, cost] = sttCalls[0]!;
+ expect(count).toBe(1);
+ expect(cost).toBe(SPEECH_TO_TEXT_COSTS[usageType]);
+ });
+
+ it('asks hasEnoughCredits for the same total it later meters', async () => {
+ const { actor } = await makeUser();
+ transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
+
+ const audio = Buffer.alloc(32000, 0);
+ await withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(audio, 'audio/mp3'),
+ model: 'gpt-4o-mini-transcribe',
+ }),
+ );
+
+ const usageType = 'openai:gpt-4o-mini-transcribe:second';
+ const expected = SPEECH_TO_TEXT_COSTS[usageType] * 2;
+ const creditCall = hasCreditsSpy.mock.calls[0]!;
+ expect(creditCall[1]).toBe(expected);
+ });
+});
+
+// ── Error paths ─────────────────────────────────────────────────────
+
+describe('SpeechToTextDriver error paths', () => {
+ it('propagates upstream OpenAI errors and does NOT meter when the call rejects', async () => {
+ const { actor } = await makeUser();
+ const sdkError = new Error('upstream blew up');
+ transcriptionsCreateMock.mockRejectedValueOnce(sdkError);
+
+ await expect(
+ withActor(actor, () =>
+ driver.transcribe({
+ file: dataUrl(Buffer.from('a'), 'audio/mp3'),
+ }),
+ ),
+ ).rejects.toBe(sdkError);
+ expect(incrementUsageSpy).not.toHaveBeenCalled();
+ });
+});