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