import { beforeEach, describe, expect, it, vi } from 'vitest' const audioConvertMock = vi.hoisted(() => ({ transcodeToWav: vi.fn(async (audio: Buffer) => ({ audio: Buffer.from(`wav:${audio.toString('utf-8')}`), mimeType: 'audio/wav', fileName: 'audio.wav', })), })) vi.mock('../../packages/server/src/services/hermes/stt-providers/audio-convert', () => audioConvertMock) import { transcribeWithProvider } from '../../packages/server/src/services/hermes/stt-providers' import { transcribeOpenAiCompatible } from '../../packages/server/src/services/hermes/stt-providers/openai' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) function jsonResponse(body: unknown, init: { status?: number; statusText?: string } = {}) { return { ok: (init.status ?? 200) >= 200 && (init.status ?? 200) < 300, status: init.status ?? 200, statusText: init.statusText ?? 'OK', async json() { return body }, async text() { return JSON.stringify(body) }, } } function textResponse(body: string, init: { status?: number; statusText?: string } = {}) { return { ok: (init.status ?? 500) >= 200 && (init.status ?? 500) < 300, status: init.status ?? 500, statusText: init.statusText ?? 'Error', async json() { return { error: body } }, async text() { return body }, } } function getHeader(headers: RequestInit['headers'] | undefined, name: string): string | undefined { if (!headers) return undefined if (headers instanceof Headers) return headers.get(name) ?? undefined if (Array.isArray(headers)) { const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase()) return match?.[1] } const entries = Object.entries(headers) const match = entries.find(([key]) => key.toLowerCase() === name.toLowerCase()) return typeof match?.[1] === 'string' ? match[1] : undefined } function getRequestInit(): RequestInit { const [, init] = mockFetch.mock.calls[0] as [string | URL, RequestInit] return init } function getRequestUrl(): string { const [url] = mockFetch.mock.calls[0] as [string | URL, RequestInit] return typeof url === 'string' ? url : url.toString() } function getFormData(): FormData { const init = getRequestInit() expect(init.body).toBeInstanceOf(FormData) return init.body as FormData } describe('transcribeOpenAiCompatible', () => { beforeEach(() => { mockFetch.mockReset() audioConvertMock.transcodeToWav.mockClear() }) it('posts multipart audio using server-side API key', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'hello from voice' })) const result = await transcribeOpenAiCompatible({ provider: 'openai', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { model: 'gpt-4o-transcribe', }, secrets: { apiKey: 'server-secret', }, }) expect(result.text).toBe('hello from voice') expect(result.provider).toBe('openai') expect(result.model).toBe('gpt-4o-transcribe') expect(result.language).toBeUndefined() expect(result.durationMs).toEqual(expect.any(Number)) expect(mockFetch).toHaveBeenCalledTimes(1) expect(getRequestUrl()).toBe('https://api.openai.com/v1/audio/transcriptions') const init = getRequestInit() expect(init.method).toBe('POST') expect(init.redirect).toBe('manual') expect(getHeader(init.headers, 'Authorization')).toBe('Bearer server-secret') const form = getFormData() expect(form.get('model')).toBe('gpt-4o-transcribe') expect(form.get('language')).toBeNull() expect(form.get('prompt')).toBeNull() const file = form.get('file') as File expect(file).toBeTruthy() expect(file.name).toBe('speech.webm') expect(file.type).toBe('audio/webm') expect(Buffer.from(await file.arrayBuffer())).toEqual(Buffer.from('audio')) expect(audioConvertMock.transcodeToWav).not.toHaveBeenCalled() }) it('transcodes audio to wav only when ffmpeg is selected', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'hello from wav' })) await transcribeOpenAiCompatible({ provider: 'openai', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { model: 'gpt-4o-transcribe', audioTranscode: 'ffmpeg', }, secrets: { apiKey: 'server-secret', }, }) expect(audioConvertMock.transcodeToWav).toHaveBeenCalledWith(Buffer.from('audio'), 'audio/webm') const file = getFormData().get('file') as File expect(file.name).toBe('audio.wav') expect(file.type).toBe('audio/wav') expect(Buffer.from(await file.arrayBuffer())).toEqual(Buffer.from('wav:audio')) }) it('rejects missing API key', async () => { await expect( transcribeOpenAiCompatible({ provider: 'openai', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { model: 'gpt-4o-transcribe', }, secrets: { apiKey: ' ', }, }), ).rejects.toThrow('OpenAI-compatible STT API key is required') expect(mockFetch).not.toHaveBeenCalled() }) it('rejects empty audio before fetch', async () => { await expect( transcribeOpenAiCompatible({ provider: 'openai', audio: Buffer.alloc(0), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { model: 'gpt-4o-transcribe', }, secrets: { apiKey: 'server-secret', }, }), ).rejects.toThrow('OpenAI-compatible STT audio is empty') expect(mockFetch).not.toHaveBeenCalled() }) it('throws HTTP errors with status but does not leak the raw secret', async () => { mockFetch.mockResolvedValueOnce(textResponse('upstream rejected bearer server-secret', { status: 401, statusText: 'Unauthorized' })) const error = await transcribeOpenAiCompatible({ provider: 'openai', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { model: 'gpt-4o-transcribe', }, secrets: { apiKey: 'server-secret', }, }).catch(error => error) expect(String(error)).toContain('401') expect(String(error)).not.toContain('server-secret') }) it('rejects successful JSON responses that omit text', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ text: ' ' })) await expect( transcribeOpenAiCompatible({ provider: 'openai', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { model: 'gpt-4o-transcribe', }, secrets: { apiKey: 'server-secret', }, }), ).rejects.toThrow('OpenAI-compatible STT response text is empty') }) it('custom provider derives the transcription endpoint from a base URL root', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'custom transcript' })) const result = await transcribeWithProvider({ provider: 'custom', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { baseUrl: 'https://custom.example.com/v1', model: 'whisper-1', language: 'fr', prompt: ' Summarize the French speech. ', }, secrets: { apiKey: 'server-secret', }, }) expect(result).toMatchObject({ text: 'custom transcript', provider: 'custom', model: 'whisper-1', language: 'fr', }) expect(getRequestUrl()).toBe('https://custom.example.com/v1/audio/transcriptions') expect(getHeader(getRequestInit().headers, 'Authorization')).toBe('Bearer server-secret') const form = getFormData() expect(form.get('language')).toBe('fr') expect(form.get('prompt')).toBe('Summarize the French speech.') }) it('custom provider does not append a duplicate transcription path', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'custom transcript' })) await transcribeWithProvider({ provider: 'custom', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { baseUrl: 'https://custom.example.com/v1/audio/transcriptions', model: 'whisper-1', }, secrets: { apiKey: 'server-secret', }, }) expect(getRequestUrl()).toBe('https://custom.example.com/v1/audio/transcriptions') }) it.each([ 'http://10.0.0.1:8000/v1/audio/transcriptions', 'http://172.16.0.1:8000/v1/audio/transcriptions', 'http://192.168.1.1:8000/v1/audio/transcriptions', 'http://169.254.169.254/latest/meta-data', 'http://[::1]:8000/v1/audio/transcriptions', 'http://[fd00::1]:8000/v1/audio/transcriptions', 'http://[fe90::1]:8000/v1/audio/transcriptions', ])('allows local or private custom baseUrl %s', async (baseUrl) => { mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'local transcript' })) const result = await transcribeWithProvider({ provider: 'custom', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { baseUrl, model: 'whisper-1', }, secrets: { apiKey: 'server-secret', }, }) expect(result.text).toBe('local transcript') expect(mockFetch).toHaveBeenCalledTimes(1) }) it('does not follow redirects to unsafe targets when fetch returns 302', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 302, statusText: 'Found', headers: { get(name: string) { return name.toLowerCase() === 'location' ? 'http://127.0.0.1:8000/internal' : null }, }, async json() { return {} }, async text() { return '' }, }) const error = await transcribeOpenAiCompatible({ provider: 'openai', audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: { model: 'gpt-4o-transcribe', }, secrets: { apiKey: 'server-secret', }, }).catch(error => error) expect(String(error)).toContain('OpenAI-compatible STT returned HTTP 302') expect(String(error)).not.toContain('127.0.0.1') expect(mockFetch).toHaveBeenCalledTimes(1) expect(getRequestUrl()).toBe('https://api.openai.com/v1/audio/transcriptions') expect(getRequestInit().redirect).toBe('manual') }) it('rejects unsupported providers', async () => { await expect( transcribeWithProvider({ provider: 'openai-compatible' as any, audio: Buffer.from('audio'), fileName: 'speech.webm', mimeType: 'audio/webm', settings: {}, secrets: { apiKey: 'server-secret', }, }), ).rejects.toThrow('Unsupported STT provider: openai-compatible') }) })