Files
hermes-ui/tests/server/openai-stt-provider.test.ts
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

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')
})
})