Files
hermes-ui/tests/server/openai-tts-provider.test.ts
T
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

299 lines
8.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clampTtsText, cleanTtsText } from '../../packages/server/src/services/hermes/tts-providers/text'
import { customTtsProvider, openaiTtsProvider } from '../../packages/server/src/services/hermes/tts-providers/openai'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
function audioResponse(
bytes: Buffer | Uint8Array,
init: { status?: number; statusText?: string; contentType?: string } = {},
) {
const buffer = Buffer.from(bytes)
return {
ok: (init.status ?? 200) >= 200 && (init.status ?? 200) < 300,
status: init.status ?? 200,
statusText: init.statusText ?? 'OK',
headers: {
get(name: string) {
if (name.toLowerCase() === 'content-type') {
return init.contentType ?? null
}
return null
},
},
async arrayBuffer() {
return buffer
},
async text() {
return buffer.toString('utf8')
},
}
}
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',
headers: {
get() {
return null
},
},
async arrayBuffer() {
return Buffer.from(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 getJsonBody() {
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(init?.body).toBeTypeOf('string')
return JSON.parse(init.body as string)
}
describe('openaiTtsProvider', () => {
beforeEach(() => {
mockFetch.mockReset()
})
it('normalizes baseUrl, posts to /audio/speech, sends bearer auth, and defaults model/voice with cleaned text', async () => {
const audio = Buffer.from('openai-audio')
mockFetch.mockResolvedValueOnce(audioResponse(audio, { contentType: 'audio/ogg' }))
const signal = new AbortController().signal
const text = 'Hello <b>world</b> ```ts\nsecret()\n``` '.repeat(120)
const result = await openaiTtsProvider.synthesize(
{ text, signal },
{
baseUrl: 'https://api.example.com///',
apiKey: 'secret',
},
)
expect(result).toEqual({
audio,
contentType: 'audio/ogg',
engine: 'openai',
provider: 'openai',
})
expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/audio/speech')
expect(init?.method).toBe('POST')
expect(init?.signal).toBe(signal)
expect(getHeader(init?.headers, 'Content-Type')).toBe('application/json')
expect(getHeader(init?.headers, 'Authorization')).toBe('Bearer secret')
expect(getJsonBody()).toEqual({
model: 'tts-1',
voice: 'alloy',
input: clampTtsText(cleanTtsText(text)),
})
})
it('includes rate and pitch only when provided', async () => {
mockFetch.mockResolvedValueOnce(audioResponse(Buffer.from('ok')))
await openaiTtsProvider.synthesize(
{ text: 'Hello' },
{
baseUrl: 'https://api.example.com',
voice: 'nova',
model: 'tts-1-hd',
rate: '+20%',
pitch: '-8Hz',
},
)
expect(getJsonBody()).toEqual({
model: 'tts-1-hd',
voice: 'nova',
input: 'Hello',
rate: '+20%',
pitch: '-8Hz',
})
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(getHeader(init?.headers, 'Authorization')).toBeUndefined()
})
it('appends /audio/speech to versioned base urls without duplicating slashes', async () => {
mockFetch.mockResolvedValueOnce(audioResponse(Buffer.from('ok')))
await openaiTtsProvider.synthesize(
{ text: 'Hello' },
{
baseUrl: 'https://api.example.com/v1/',
},
)
const [url] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/v1/audio/speech')
})
it('does not double-append /audio/speech when baseUrl already targets the speech endpoint', async () => {
mockFetch.mockResolvedValueOnce(audioResponse(Buffer.from('ok')))
await openaiTtsProvider.synthesize(
{ text: 'Hello' },
{
baseUrl: 'https://api.example.com/v1/audio/speech',
},
)
const [url] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/v1/audio/speech')
})
it('preserves query strings and drops hashes when appending /audio/speech', async () => {
mockFetch.mockResolvedValueOnce(audioResponse(Buffer.from('ok')))
await openaiTtsProvider.synthesize(
{ text: 'Hello' },
{
baseUrl: 'https://api.example.com/v1?api-version=2024-01-01#frag',
},
)
const [url] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/v1/audio/speech?api-version=2024-01-01')
})
it('preserves query strings and drops hashes when baseUrl already targets the speech endpoint', async () => {
mockFetch.mockResolvedValueOnce(audioResponse(Buffer.from('ok')))
await openaiTtsProvider.synthesize(
{ text: 'Hello' },
{
baseUrl: 'https://api.example.com/v1/audio/speech?api-version=2024-01-01#frag',
},
)
const [url] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/v1/audio/speech?api-version=2024-01-01')
})
it('rejects invalid baseUrl values before fetch', async () => {
for (const baseUrl of [
'file:///tmp/tts',
'https://user:pass@api.example.com/v1',
]) {
await expect(
openaiTtsProvider.synthesize(
{ text: 'Hello' },
{ baseUrl },
),
).rejects.toThrow(/OpenAI TTS baseUrl/)
}
expect(mockFetch).not.toHaveBeenCalled()
})
it('allows local or private baseUrl values', async () => {
for (const baseUrl of [
'http://localhost:8000/v1',
'http://127.0.0.1:8000/v1',
'http://[::1]:8000/v1',
'http://169.254.169.254/latest',
]) {
mockFetch.mockResolvedValueOnce(new Response(Buffer.from('audio'), { status: 200 }))
await openaiTtsProvider.synthesize(
{ text: 'Hello' },
{ baseUrl },
)
}
expect(mockFetch).toHaveBeenCalledTimes(4)
})
it('throws non-ok responses with status and body text', async () => {
mockFetch.mockResolvedValueOnce(textResponse('bad request body', { status: 401, statusText: 'Unauthorized' }))
await expect(
openaiTtsProvider.synthesize(
{ text: 'Hello' },
{
baseUrl: 'https://api.example.com',
},
),
).rejects.toThrow('OpenAI TTS returned 401: bad request body')
})
it('throws before fetch when cleaned text is empty', async () => {
await expect(
openaiTtsProvider.synthesize(
{ text: '<think>secret</think> <b></b>' },
{
baseUrl: 'https://api.example.com',
},
),
).rejects.toThrow('OpenAI TTS text is empty after cleaning')
expect(mockFetch).not.toHaveBeenCalled()
})
it('custom provider reuses OpenAI-compatible speech requests but reports provider=custom', async () => {
const audio = Buffer.from('custom-audio')
mockFetch.mockResolvedValueOnce(audioResponse(audio, { contentType: 'audio/mpeg' }))
const result = await customTtsProvider.synthesize(
{ text: 'Hello custom' },
{
baseUrl: 'https://custom.example.com/v1',
apiKey: 'custom-secret',
},
)
expect(result).toEqual({
audio,
contentType: 'audio/mpeg',
engine: 'custom',
provider: 'custom',
})
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://custom.example.com/v1/audio/speech')
expect(getHeader(init?.headers, 'Authorization')).toBe('Bearer custom-secret')
})
it('falls back to audio/mpeg when content-type header is missing', async () => {
const audio = Buffer.from('fallback-audio')
mockFetch.mockResolvedValueOnce(audioResponse(audio))
const result = await openaiTtsProvider.synthesize(
{ text: 'Hello' },
{
baseUrl: 'https://api.example.com',
},
)
expect(result).toEqual({
audio,
contentType: 'audio/mpeg',
engine: 'openai',
provider: 'openai',
})
})
})