openreader/tests/unit/tts-provider-catalog.vitest.spec.ts
Bilal Tahir 801f0dcca2 feat(tts): add Speech SDK provider for ElevenLabs, Cartesia, Deepgram, Google, Inworld, and more
Adds 'speech-sdk' as a fifth built-in TTS provider backed by @speech-sdk/core
(Apache 2.0). Models use the provider/model format; requests go from the
server directly to the provider's API with the user's own key. Existing
providers, defaults, and config are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:39:20 -07:00

552 lines
17 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
getDefaultVoices,
providerSupportsCustomModel,
resolveProviderModels,
speechSdkProviderPrefix,
supportsNativeModelSpeed,
supportsTtsInstructions,
} from '../../src/lib/shared/tts-provider-catalog';
import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy';
import {
resolveReplicateLanguageInputKey,
resolveReplicateVoiceInputKey,
resolveVoices,
} from '../../src/lib/server/tts/voice-resolution';
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
describe('tts provider catalog', () => {
test('resolves provider models with fixed Deepinfra catalog', () => {
expect(resolveProviderModels('openai').map((model) => model.id)).toEqual([
'tts-1',
'tts-1-hd',
'gpt-4o-mini-tts',
]);
expect(resolveProviderModels('deepinfra', {
apiKey: '',
}).map((model) => model.id)).toEqual([
'hexgrad/Kokoro-82M',
'canopylabs/orpheus-3b-0.1-ft',
'sesame/csm-1b',
'ResembleAI/chatterbox',
'Zyphra/Zonos-v0.1-hybrid',
'Zyphra/Zonos-v0.1-transformer',
'custom',
]);
});
test('resolves default voices and instruction support unchanged', () => {
expect(getDefaultVoices('openai', 'gpt-4o-mini-tts')).toContain('sage');
expect(getDefaultVoices('custom-openai', 'kokoro')).toContain('af_sarah');
// Non-kokoro custom-openai models fall back to the canonical OpenAI six.
expect(getDefaultVoices('custom-openai', 'supertonic-3')).toEqual(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']);
expect(getDefaultVoices('deepinfra', 'ResembleAI/chatterbox')).toEqual(['None']);
expect(getDefaultVoices('deepinfra', 'Zyphra/Zonos-v0.1-transformer')).toEqual(['random']);
expect(supportsTtsInstructions('gpt-4o-mini-tts')).toBe(true);
expect(supportsTtsInstructions('tts-1')).toBe(false);
expect(providerSupportsCustomModel('openai')).toBe(false);
expect(providerSupportsCustomModel('deepinfra')).toBe(true);
});
test('resolves speech-sdk models, voices, and prefix parsing', () => {
expect(resolveProviderModels('speech-sdk').map((model) => model.id)).toContain('openai/gpt-4o-mini-tts');
expect(providerSupportsCustomModel('speech-sdk')).toBe(true);
expect(speechSdkProviderPrefix('elevenlabs/eleven_multilingual_v2')).toBe('elevenlabs');
// fal model ids are path-style, so only the first slash delimits the provider.
expect(speechSdkProviderPrefix('fal-ai/kokoro/american-english')).toBe('fal-ai');
expect(getDefaultVoices('speech-sdk', 'openai/gpt-4o-mini-tts')).toContain('sage');
expect(getDefaultVoices('speech-sdk', 'deepgram/aura-2')).toContain('thalia-en');
// Providers without a static voice list fall back to the provider default.
expect(getDefaultVoices('speech-sdk', 'mistral/voxtral-mini-tts-2603')).toEqual(['default']);
// Speed is applied client-side for speech-sdk so cached audio is speed-independent.
expect(supportsNativeModelSpeed('speech-sdk', 'openai/gpt-4o-mini-tts')).toBe(false);
});
test('uses blocklist semantics for Replicate native speed support', () => {
expect(supportsNativeModelSpeed('replicate', REPLICATE_KOKORO_82M_VERSIONED_MODEL)).toBe(true);
expect(supportsNativeModelSpeed('replicate', 'acme/runtime-speed-model')).toBe(true);
expect(supportsNativeModelSpeed('replicate', 'google/gemini-3.1-flash-tts')).toBe(false);
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
});
test('preserves default-openai unless explicit non-default fallback is provided', () => {
expect(normalizeLegacyProviderRef('default-openai', 'shared-replicate')).toBe('shared-replicate');
expect(normalizeLegacyProviderRef('default-openai', '')).toBe('default-openai');
expect(normalizeLegacyProviderRef('default-openai', 'default-openai')).toBe('default-openai');
expect(normalizeLegacyProviderRef('default-openai')).toBe('default-openai');
});
test('resolveProviderDefaults keeps runtime/admin default provider ref when valid', () => {
const defaults = resolveProviderDefaults({
providerRef: 'default-openai',
providerType: 'unknown',
});
expect(defaults.providerRef).toBe('default-openai');
expect(defaults.providerType).toBe('unknown');
});
test('resolves shared-provider default model as authoritative', () => {
const defaults = resolveProviderDefaults({
providerRef: 'shared-replicate',
providerType: 'unknown',
fallbackProviderRef: 'shared-replicate',
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: 'Narrate warmly',
},
],
});
expect(defaults.providerRef).toBe('shared-replicate');
expect(defaults.providerType).toBe('replicate');
expect(defaults.defaultModel).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
expect(defaults.defaultInstructions).toBe('Narrate warmly');
});
test('resolveTtsModelForProvider enforces shared default model when showAllProviderModels is false', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'shared-replicate',
providerType: 'unknown',
model: 'acme/custom-model',
showAllProviderModels: false,
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: null,
},
],
});
expect(resolved).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
});
test('resolveTtsModelForProvider keeps requested model when showAllProviderModels is true', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'shared-replicate',
providerType: 'unknown',
model: 'acme/custom-model',
showAllProviderModels: true,
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: null,
},
],
});
expect(resolved).toBe('acme/custom-model');
});
test('resolveTtsModelForProvider falls back to built-in provider default model when locked', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'openai',
providerType: 'openai',
model: 'gpt-4o-mini-tts',
showAllProviderModels: false,
});
expect(resolved).toBe('tts-1');
});
test('keeps explicit empty custom-openai voices response', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: true,
json: async () => ({ voices: [] }),
}) as Response;
try {
await expect(resolveVoices({
provider: 'custom-openai',
model: 'kokoro',
apiKey: 'token',
baseUrl: 'https://example.com',
})).resolves.toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fetches custom Replicate model voices from model schema', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
let url = '';
globalThis.fetch = async (input) => {
calls += 1;
url = String(input);
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice: {
type: 'string',
enum: ['Narrator A', 'Narrator B'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/my-tts-model-voices',
apiKey: 'r8_token',
})).resolves.toEqual(['Narrator A', 'Narrator B']);
expect(calls).toBe(1);
expect(url).toContain('/v1/models/acme/my-tts-model-voices');
} finally {
globalThis.fetch = originalFetch;
}
});
test('falls back to default voice for custom Replicate models when schema lookup fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: false,
status: 404,
json: async () => ({}),
}) as Response;
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/my-tts-model-fallback',
apiKey: 'r8_token',
})).resolves.toEqual(['default']);
} finally {
globalThis.fetch = originalFetch;
}
});
test('uses schema voices first for built-in Replicate models', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
speaker: {
type: 'string',
enum: ['Schema Aiden', 'Schema Dylan'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'qwen/qwen3-tts',
apiKey: 'r8_token',
})).resolves.toEqual(['Schema Aiden', 'Schema Dylan']);
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('falls back to built-in Replicate static voices when schema has no voice enum', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
speaker: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'inworld/tts-1.5-mini',
apiKey: 'r8_token',
})).resolves.toEqual(['Ashley', 'Dennis', 'Alex', 'Darlene']);
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolves Replicate custom-model voice input key and caches it', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice_id: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/custom-voice-model',
apiKey: 'r8_token',
})).resolves.toBe('voice_id');
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/custom-voice-model',
apiKey: 'r8_token',
})).resolves.toBe('voice_id');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('shares Replicate schema fetch between voices and voice-key resolution', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice: {
type: 'string',
enum: ['V1', 'V2'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/shared-schema-model',
apiKey: 'r8_token',
})).resolves.toEqual(['V1', 'V2']);
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/shared-schema-model',
apiKey: 'r8_token',
})).resolves.toBe('voice');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('discovers Replicate language input key from model schema', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
text: { type: 'string' },
language_code: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'acme/schema-language-model',
apiKey: 'r8_token',
})).resolves.toBe('language_code');
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'acme/schema-language-model',
apiKey: 'r8_token',
})).resolves.toBe('language_code');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('discovers Replicate language input key for built-in models too', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
text: { type: 'string' },
language: { type: 'string', enum: ['auto', 'en', 'ja'] },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'minimax/speech-2.8-turbo',
apiKey: 'r8_token',
})).resolves.toBe('language');
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'minimax/speech-2.8-turbo',
apiKey: 'r8_token',
})).resolves.toBe('language');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('config helpers', () => {
test('persists voice preferences by provider/model pair', () => {
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
const voiceUpdate = applyConfigUpdate({
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {},
}, 'voice', 'alloy');
expect(voiceUpdate.storagePatch).toEqual({
savedVoices: { 'openai:tts-1': 'alloy' },
voice: 'alloy',
});
const providerUpdate = applyConfigUpdate({
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
},
}, 'providerRef', 'deepinfra');
expect(providerUpdate.storagePatch).toEqual({
providerRef: 'deepinfra',
voice: '',
});
});
test('builds synced preference patches and honors non-default filtering', () => {
expect(buildSyncedPreferencePatch({
voiceSpeed: 1.2,
baseUrl: 'http://localhost',
ttsModel: 'kokoro',
})).toEqual({
voiceSpeed: 1.2,
ttsModel: 'kokoro',
});
// The user's provider/model now default to empty ("inherit the admin
// default"), so empty values are treated as defaults and filtered out.
expect(buildSyncedPreferencePatch({
voiceSpeed: 1,
providerRef: '',
providerType: 'unknown',
ttsModel: '',
}, { nonDefaultOnly: true })).toEqual({});
});
});