const { test } = require('node:test'); const assert = require('node:assert/strict'); const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE']; function withEnv(overrides, fn) { const previous = {}; ENV_KEYS.forEach((key) => { previous[key] = process.env[key]; delete process.env[key]; }); Object.entries(overrides).forEach(([key, value]) => { process.env[key] = value; }); try { return fn(); } finally { ENV_KEYS.forEach((key) => { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; }); } } test('TTS provider defaults to LiteLLM when gateway is configured', () => { const ttsProvider = require('../src/utils/ttsProvider'); withEnv({ LITELLM_API_BASE: 'https://llm.example.com' }, () => { assert.equal(ttsProvider.getTTSEnvProvider(), 'auto'); assert.equal(ttsProvider.getTTSProvider(), 'litellm'); }); }); test('TTS provider ignores non-LiteLLM provider overrides', () => { const ttsProvider = require('../src/utils/ttsProvider'); withEnv({ TTS_PROVIDER: 'some-other-provider', LITELLM_API_BASE: 'https://llm.example.com' }, () => { assert.equal(ttsProvider.getTTSEnvProvider(), 'some-other-provider'); assert.equal(ttsProvider.getTTSProvider(), 'litellm'); }); }); test('LiteLLM TTS voice list comes from configured voice catalog', () => { const ttsProvider = require('../src/utils/ttsProvider'); withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => { const voices = ttsProvider.getTTSVoiceLists().litellm; assert.equal(voices[0], 'sherpa/kokoro:am_adam'); assert.equal(voices[1], 'sherpa/kokoro:af_bella'); // The configured list, plus the curated voices of the families that remain. // Kitten and Supertonic were retired with their models. assert.equal(voices.includes('hannah'), true, 'Orpheus English'); assert.equal(voices.includes('noura'), true, 'Orpheus Arabic'); assert.equal(voices.includes('Jasper'), false, 'Kitten is gone'); assert.equal(voices.includes('F1'), false, 'Supertonic is gone'); }); }); test('TTS model discovery uses audio_speech metadata only', () => { const ttsProvider = require('../src/utils/ttsProvider'); assert.equal(ttsProvider.isOpenAITTSModel, undefined); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-chatterbox-turbo' }), false); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-kokoro-tts' }), false); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'openai-tts-1' }), false); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'openai-gpt-4o-mini-tts' }), false); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'mistral-voxtral-mini-tts' }), false); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'vertex-gemini-2.5-flash-tts' }), false); assert.equal(ttsProvider.isLiteLLMTTSModel({ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } }), true); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'custom-provider-model', model_info: { mode: 'audio_speech' } }), true); assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }), false); }); test('LiteLLM TTS model extraction filters gateway model objects', () => { const ttsProvider = require('../src/utils/ttsProvider'); assert.deepEqual(ttsProvider.getLiteLLMTTSModels([ { id: 'openai-tts-1' }, { id: 'mistral-voxtral-mini-tts' }, { id: 'openai-gpt-4o-mini-tts' }, { id: 'local-chatterbox-turbo' }, { model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } }, { id: 'custom-provider-model', model_info: { mode: 'audio_speech' } }, { id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }, { id: 'vertex-gemini-2.5-flash-tts' }, { id: 'gpt-4.1' } ]), ['local-chatterbox-turbo', 'custom-provider-model']); }); test('LiteLLM TTS discovery includes metadata models and configured fallbacks', () => { const ttsProvider = require('../src/utils/ttsProvider'); withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => { const items = ttsProvider.getLiteLLMTTSDiscoveryItems([ { model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } }, { model_name: 'not-tts-by-name-only' }, { model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } } ], { currentModel: 'local-kokoro-tts', currentVoice: 'sherpa/kokoro:am_adam' }); assert.deepEqual(items.slice(0, 3), [ { id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model' }, { id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' }, // A voice now says which model it belongs to. It used to say // "configured-voice-list", which named the file it came from and not the // model that would accept it — the distinction the screen was missing. { id: 'sherpa/kokoro:af_bella', name: 'sherpa/kokoro:af_bella', source: 'local-kokoro-tts', kind: 'voice', model: 'local-kokoro-tts' } ]); assert.equal(items.some(function(item) { return item.id === 'not-tts-by-name-only'; }), false); }); }); test('LiteLLM TTS discovery expands the voices of a model it can see', () => { // Models are discovered from /model/info; voices are not. No TTS provider // exposes its voice list consistently, so each family's voices are curated // here — a list guessed from a model id is how a picker ends up offering a // voice the provider rejects. Kitten and Supertonic were retired from the // gateway in favour of Kokoro, and their lists went with them. const ttsProvider = require('../src/utils/ttsProvider'); withEnv({}, () => { const items = ttsProvider.getLiteLLMTTSDiscoveryItems([ { model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } } ], {}); assert.equal(items.some(function(item) { return item.id === 'groq-orpheus-english' && item.kind === 'model'; }), true); assert.equal(items.some(function(item) { return item.id === 'hannah' && item.kind === 'voice'; }), true); }); }); test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => { const ttsProvider = require('../src/utils/ttsProvider'); assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], { currentModel: 'local-kokoro-tts', currentVoice: 'sherpa/kokoro:am_adam' }).slice(0, 2), [ { id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'configured-model', kind: 'model' }, { id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' } ]); }); test('LiteLLM TTS voices are scoped to the active local model', () => { const ttsProvider = require('../src/utils/ttsProvider'); // LITELLM_TTS_VOICES names the voices of LITELLM_TTS_MODEL and no other // model — production sets both. Treating that list as universal is what // offered Kokoro's voices for Fish. withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => { assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']); assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']); assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']); }); }); test('LiteLLM TTS compatibility rejects cross-model local voices', () => { const ttsProvider = require('../src/utils/ttsProvider'); // Kokoro names its own voices and the list is open, so it accepts anything // that is not another family's voice. An Orpheus voice is still refused. assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'sherpa/kokoro:af_bella'), true); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'hannah'), false); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'noura'), true); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'hannah'), false); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'hannah'), true); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'sherpa/kokoro:af_bella'), false); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'aisha'), true); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'troy'), false); }); test('Groq Orpheus TTS requests force wav response format', () => { const ttsProvider = require('../src/utils/ttsProvider'); assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('groq-orpheus-english'), { response_format: 'wav' }); assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('canopylabs/orpheus-arabic-saudi'), { response_format: 'wav' }); assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('local-kokoro-tts'), {}); }); test('LiteLLM headers include optional content type without exposing key value', () => { const ttsProvider = require('../src/utils/ttsProvider'); withEnv({ LITELLM_API_KEY: 'secret-token' }, () => { assert.deepEqual(ttsProvider.getLiteLLMHeaders('application/json'), { 'Content-Type': 'application/json', Authorization: 'Bearer secret-token' }); }); }); test('LiteLLM admin headers prefer master key for metadata routes', () => { const { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../src/utils/litellm'); withEnv({ LITELLM_API_KEY: 'virtual-token', LITELLM_MASTER_KEY: 'master-token' }, () => { assert.equal(getLiteLLMHeaders().Authorization, 'Bearer virtual-token'); assert.equal(getLiteLLMAdminHeaders().Authorization, 'Bearer master-token'); }); });