360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|