import { expect, test } from '@playwright/test'; import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, getDefaultVoices, providerSupportsCustomModel, resolveProviderModels, supportsNativeModelSpeed, supportsTtsInstructions, } from '../../src/lib/shared/tts-provider-catalog'; import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy'; import { 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'; test.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'); 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('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.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', }); expect(buildSyncedPreferencePatch({ voiceSpeed: 1, ttsModel: 'kokoro', }, { nonDefaultOnly: true })).toEqual({}); }); });