diff --git a/public/js/admin.js b/public/js/admin.js index 68cf4e2..dd309a4 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -11,6 +11,24 @@ function adminTableMessage(colspan, color, text) { return '' + adminEscapeHtml(text) + ''; } +function adminSetButtonText(btn, text, disabled) { + if (!btn) return; + btn.textContent = text; + btn.disabled = !!disabled; +} + +function adminSetButtonHtml(btn, html, disabled) { + if (!btn) return; + btn.innerHTML = html; + btn.disabled = !!disabled; +} + +function adminFlashButtonBackground(btn, color) { + if (!btn) return; + btn.style.background = color || ''; + setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); +} + (function() { var loaded = false; @@ -1154,7 +1172,7 @@ function adminTableMessage(colspan, color, text) { function testModel(modelId, btn) { if (!modelId) return; var origText = btn ? btn.textContent : 'Test'; - if (btn) { btn.textContent = '...'; btn.disabled = true; } + adminSetButtonText(btn, '...', true); fetch('/api/admin/config/models/test', { method: 'POST', @@ -1163,7 +1181,7 @@ function adminTableMessage(colspan, color, text) { }) .then(function(r) { return r.json(); }) .then(function(data) { - if (btn) { btn.textContent = origText; btn.disabled = false; } + adminSetButtonText(btn, origText, false); if (data.success) { showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success'); } else { @@ -1171,7 +1189,7 @@ function adminTableMessage(colspan, color, text) { } }) .catch(function() { - if (btn) { btn.textContent = origText; btn.disabled = false; } + adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } @@ -1280,7 +1298,7 @@ function adminTableMessage(colspan, color, text) { function setTTSDefault(id, type, btn) { var key = type === 'model' ? 'tts.model' : 'tts.voice'; var origText = btn ? btn.textContent : ''; - if (btn) { btn.textContent = '...'; btn.disabled = true; } + adminSetButtonText(btn, '...', true); fetch('/api/admin/config/' + encodeURIComponent(key), { method: 'PUT', @@ -1289,7 +1307,8 @@ function adminTableMessage(colspan, color, text) { }) .then(function(r) { return r.json(); }) .then(function(data) { - if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = 'var(--green)'; setTimeout(function() { btn.style.background = ''; }, 2000); } + adminSetButtonText(btn, 'Set', false); + adminFlashButtonBackground(btn, 'var(--green)'); if (data.success) { showToast('TTS ' + type + ' set to: ' + id, 'success'); // Update voice selector @@ -1309,7 +1328,7 @@ function adminTableMessage(colspan, color, text) { } }) .catch(function() { - if (btn) { btn.textContent = origText; btn.disabled = false; } + adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } @@ -1320,7 +1339,7 @@ function adminTableMessage(colspan, color, text) { var btn = document.getElementById('btn-test-tts'); var resultEl = document.getElementById('admin-tts-result'); var audioEl = document.getElementById('admin-tts-audio'); - if (btn) { btn.disabled = true; btn.innerHTML = ' Synthesizing...'; } + adminSetButtonHtml(btn, ' Synthesizing...', true); if (resultEl) resultEl.textContent = ''; if (audioEl) audioEl.style.display = 'none'; @@ -1331,7 +1350,7 @@ function adminTableMessage(colspan, color, text) { }) .then(function(r) { return r.json(); }) .then(function(data) { - if (btn) { btn.disabled = false; btn.innerHTML = ' Synthesize & Play'; } + adminSetButtonHtml(btn, ' Synthesize & Play', false); if (!data.success) { if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error'); return; @@ -1345,7 +1364,7 @@ function adminTableMessage(colspan, color, text) { if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?'); }) .catch(function(err) { - if (btn) { btn.disabled = false; btn.innerHTML = ' Synthesize & Play'; } + adminSetButtonHtml(btn, ' Synthesize & Play', false); if (resultEl) resultEl.textContent = 'Request failed: ' + err.message; }); } @@ -1445,7 +1464,7 @@ function adminTableMessage(colspan, color, text) { function setSTTDefault(modelId, btn) { var origText = btn ? btn.textContent : ''; - if (btn) { btn.textContent = '...'; btn.disabled = true; } + adminSetButtonText(btn, '...', true); fetch('/api/admin/config/' + encodeURIComponent('stt.model'), { method: 'PUT', headers: getAuthHeaders(), @@ -1453,12 +1472,13 @@ function adminTableMessage(colspan, color, text) { }) .then(function(r) { return r.json(); }) .then(function(data) { - if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = data.success ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); } + adminSetButtonText(btn, 'Set', false); + adminFlashButtonBackground(btn, data.success ? 'var(--green)' : ''); if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); } else showToast(data.error || 'Failed', 'error'); }) .catch(function() { - if (btn) { btn.textContent = origText; btn.disabled = false; } + adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } @@ -1639,7 +1659,7 @@ function adminTableMessage(colspan, color, text) { function setEmbeddingDefault(modelId, dims, btn) { var origText = btn ? btn.textContent : ''; - if (btn) { btn.textContent = '...'; btn.disabled = true; } + adminSetButtonText(btn, '...', true); var promises = [ fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId }) @@ -1655,12 +1675,13 @@ function adminTableMessage(colspan, color, text) { Promise.all(promises) .then(function(results) { var ok = results.every(function(r) { return r.success; }); - if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = ok ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); } + adminSetButtonText(btn, 'Set', false); + adminFlashButtonBackground(btn, ok ? 'var(--green)' : ''); if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); } else showToast(results[0].error || 'Failed', 'error'); }) .catch(function() { - if (btn) { btn.textContent = origText; btn.disabled = false; } + adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } @@ -1669,7 +1690,7 @@ function adminTableMessage(colspan, color, text) { var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test'; var resultEl = document.getElementById('admin-embed-result'); var btn = document.getElementById('btn-test-embedding'); - if (btn) { btn.disabled = true; btn.innerHTML = ''; } + adminSetButtonHtml(btn, '', true); if (resultEl) resultEl.textContent = 'Generating...'; fetch('/api/admin/config/embeddings/test', { @@ -1679,7 +1700,7 @@ function adminTableMessage(colspan, color, text) { }) .then(function(r) { return r.json(); }) .then(function(data) { - if (btn) { btn.disabled = false; btn.innerHTML = ' Generate'; } + adminSetButtonHtml(btn, ' Generate', false); if (!data.success) { if (resultEl) resultEl.innerHTML = 'Error: ' + esc(data.error || 'Failed') + ''; return; @@ -1693,7 +1714,7 @@ function adminTableMessage(colspan, color, text) { } }) .catch(function(err) { - if (btn) { btn.disabled = false; btn.innerHTML = ' Generate'; } + adminSetButtonHtml(btn, ' Generate', false); if (resultEl) resultEl.textContent = 'Request failed: ' + err.message; }); } diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 37edf19..573ede3 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -9,6 +9,7 @@ var { authMiddleware, adminMiddleware } = require('../middleware/auth'); var PROMPTS = require('../utils/prompts'); var logger = require('../utils/logger'); var { gatewayUrl } = require('../utils/errors'); +var { GOOGLE_TTS_VOICES, OPENAI_TTS_VOICES, getTTSEnvProvider, getLiteLLMHeaders, getLiteLLMTTSModels, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider'); router.use(authMiddleware); @@ -462,14 +463,8 @@ router.post('/config/image-models/test', async function(req, res) { router.get('/config/tts', async function(req, res) { try { var { isGoogleTTSConfigured } = require('../utils/ttsGoogle'); - var envProvider = process.env.TTS_PROVIDER || 'auto'; - var activeProvider = envProvider; - if (envProvider === 'auto') { - if (process.env.LITELLM_API_BASE) activeProvider = 'litellm'; - else if (isGoogleTTSConfigured()) activeProvider = 'google'; - else if (process.env.ELEVENLABS_API_KEY) activeProvider = 'elevenlabs'; - else activeProvider = 'none'; - } + var envProvider = getTTSEnvProvider(); + var activeProvider = getTTSProvider(); var dbVoice = await db.getSetting('tts.voice') || ''; var dbModel = await db.getSetting('tts.model') || ''; var envVoice = process.env.GOOGLE_TTS_VOICE || process.env.LITELLM_TTS_VOICE || ''; @@ -489,11 +484,7 @@ router.get('/config/tts', async function(req, res) { litellm: !!process.env.LITELLM_API_BASE, elevenlabs: !!process.env.ELEVENLABS_API_KEY }, - voices: { - google: ['en-US-Journey-F','en-US-Journey-D','en-US-Studio-O','en-US-Neural2-A','en-US-Neural2-C','en-US-Neural2-D','en-US-Neural2-E','en-US-Neural2-F','en-US-Neural2-G','en-US-Neural2-H','en-US-Neural2-I','en-US-Neural2-J'], - litellm: ['alloy','echo','fable','onyx','nova','shimmer','en-US-Journey-F','en-US-Journey-D'], - elevenlabs: ['pNInz6obpgDQGcFmaJgB'] - } + voices: getTTSVoiceLists() }); } catch (e) { res.status(500).json({ error: 'Request failed' }); } }); @@ -503,17 +494,9 @@ router.get('/config/tts/discover', async function(req, res) { try { var search = (req.query.q || '').toLowerCase().trim(); var axios = require('axios'); - var { isGoogleTTSConfigured } = require('../utils/ttsGoogle'); var discovered = []; - var envProvider = process.env.TTS_PROVIDER || 'auto'; - var provider = envProvider; - if (envProvider === 'auto') { - if (process.env.LITELLM_API_BASE) provider = 'litellm'; - else if (isGoogleTTSConfigured()) provider = 'google'; - else if (process.env.ELEVENLABS_API_KEY) provider = 'elevenlabs'; - else provider = 'none'; - } + var provider = getTTSProvider(); if (provider === 'google') { try { @@ -531,7 +514,7 @@ router.get('/config/tts/discover', async function(req, res) { } } catch (e) { logger.warn('Google TTS voice API failed: ' + e.message + ' — using built-in list'); - ['en-US-Journey-F','en-US-Journey-D','en-US-Studio-O','en-US-Neural2-A','en-US-Neural2-C','en-US-Neural2-D','en-US-Neural2-E','en-US-Neural2-F','en-US-Neural2-G','en-US-Neural2-H','en-US-Neural2-I','en-US-Neural2-J'].forEach(function(v) { + GOOGLE_TTS_VOICES.forEach(function(v) { discovered.push({ id: v, name: v, source: 'google-builtin' }); }); } @@ -539,30 +522,18 @@ router.get('/config/tts/discover', async function(req, res) { if (provider === 'litellm' && process.env.LITELLM_API_BASE) { // base URL handled by gatewayUrl() helper - var lHeaders = {}; - if (process.env.LITELLM_API_KEY) lHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; try { - var lResp = await axios.get(gatewayUrl('/models'), { headers: lHeaders, timeout: 10000 }); - if (lResp.data && lResp.data.data) { - lResp.data.data.forEach(function(m) { - var mid = (m.id || '').toLowerCase(); - if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('playai') !== -1) { - discovered.push({ id: m.id, name: m.id, source: 'gateway-api' }); - } - }); - } + var lResp = await axios.get(gatewayUrl('/models'), { headers: getLiteLLMHeaders(), timeout: 10000 }); + getLiteLLMTTSModels(lResp.data && lResp.data.data).forEach(function(id) { + discovered.push({ id: id, name: id, source: 'gateway-api' }); + }); } catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); } // OpenAI voices - ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'].forEach(function(v) { + OPENAI_TTS_VOICES.forEach(function(v) { discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'openai-voices' }); }); - // Vertex Gemini TTS voices - ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'].forEach(function(v) { - discovered.push({ id: v, name: v + ' (Vertex voice)', source: 'vertex-voices' }); - }); - // ElevenLabs voices — fetch from API if key available if (process.env.ELEVENLABS_API_KEY) { try { @@ -608,14 +579,7 @@ router.post('/config/tts/test', async function(req, res) { var { isGoogleTTSConfigured, synthesizeWithGoogleTTS } = require('../utils/ttsGoogle'); var axios = require('axios'); - var envProvider = process.env.TTS_PROVIDER || 'auto'; - var provider = envProvider; - if (envProvider === 'auto') { - if (process.env.LITELLM_API_BASE) provider = 'litellm'; - else if (isGoogleTTSConfigured()) provider = 'google'; - else if (process.env.ELEVENLABS_API_KEY) provider = 'elevenlabs'; - else provider = 'none'; - } + var provider = getTTSProvider(); if (provider === 'none') return res.json({ success: false, error: 'No TTS provider configured' }); var buffer, usedVoice = voice; @@ -627,22 +591,15 @@ router.post('/config/tts/test', async function(req, res) { var db = require('../db/database'); var adminModel = await db.getSetting('tts.model') || ''; var adminVoice = await db.getSetting('tts.voice') || ''; - if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } - var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; + if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } var resolvedVoice = voice || adminVoice || ''; - if (resolvedVoice) { - if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts'; - else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; - } var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1'; - var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') || rawModel.startsWith('vertex-') ? rawModel : 'openai/' + rawModel; + var ttsModel = rawModel; usedVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy'; // base URL handled by gatewayUrl() helper - var ttsHeaders = { 'Content-Type': 'application/json' }; - if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; var ttsResp = await axios.post(gatewayUrl('/audio/speech'), { model: ttsModel, voice: usedVoice, input: text }, - { headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 } + { headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 } ); buffer = Buffer.from(ttsResp.data); } else if (provider === 'elevenlabs') { diff --git a/src/routes/tts.js b/src/routes/tts.js index cfd3ec3..951e76a 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -1,10 +1,11 @@ const express = require('express'); const router = express.Router(); const axios = require('axios'); -const { synthesizeWithGoogleTTS, isGoogleTTSConfigured } = require('../utils/ttsGoogle'); +const { synthesizeWithGoogleTTS } = require('../utils/ttsGoogle'); const { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var { gatewayUrl } = require('../utils/errors'); +var { getLiteLLMHeaders, getTTSProvider } = require('../utils/ttsProvider'); // Provider priority (auto-detect): // TTS_PROVIDER=litellm → LiteLLM proxy at /v1/audio/speech (default when LITELLM_API_BASE is set) @@ -16,18 +17,6 @@ var { gatewayUrl } = require('../utils/errors'); // go straight into the OpenAI-compatible /v1/audio/speech body. No regex // dispatch on model/voice names; the LiteLLM config decides which provider // (OpenAI, ElevenLabs, Vertex, Azure) actually serves the request. -function getTTSProvider() { - var env = process.env.TTS_PROVIDER; - if (env === 'google') return 'google'; - if (env === 'litellm') return 'litellm'; - if (env === 'elevenlabs') return 'elevenlabs'; - // Auto-detect: litellm > google > elevenlabs - if (process.env.LITELLM_API_BASE) return 'litellm'; - if (isGoogleTTSConfigured()) return 'google'; - if (process.env.ELEVENLABS_API_KEY) return 'elevenlabs'; - return 'none'; -} - var ttsProvider = getTTSProvider(); console.log('🔊 TTS provider:', ttsProvider + (ttsProvider === 'google' ? ' (voice: ' + (process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F') + ')' : '')); @@ -49,12 +38,9 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' }); var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1'; var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy'; - var headers = { 'Content-Type': 'application/json' }; - if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; - var ttsResp = await fetch(gatewayUrl('/audio/speech'), { method: 'POST', - headers: headers, + headers: getLiteLLMHeaders('application/json'), body: JSON.stringify({ model: ttsModel, input: text, voice: ttsVoice }) }); if (!ttsResp.ok) { diff --git a/src/utils/ttsProvider.js b/src/utils/ttsProvider.js new file mode 100644 index 0000000..4b9c1e2 --- /dev/null +++ b/src/utils/ttsProvider.js @@ -0,0 +1,57 @@ +const { isGoogleTTSConfigured } = require('./ttsGoogle'); + +const GOOGLE_TTS_VOICES = ['en-US-Journey-F','en-US-Journey-D','en-US-Studio-O','en-US-Neural2-A','en-US-Neural2-C','en-US-Neural2-D','en-US-Neural2-E','en-US-Neural2-F','en-US-Neural2-G','en-US-Neural2-H','en-US-Neural2-I','en-US-Neural2-J']; +const OPENAI_TTS_VOICES = ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral']; +const ELEVENLABS_DEFAULT_VOICES = ['pNInz6obpgDQGcFmaJgB']; + +function getTTSProvider() { + var env = process.env.TTS_PROVIDER; + if (env === 'google') return 'google'; + if (env === 'litellm') return 'litellm'; + if (env === 'elevenlabs') return 'elevenlabs'; + if (process.env.LITELLM_API_BASE) return 'litellm'; + if (isGoogleTTSConfigured()) return 'google'; + if (process.env.ELEVENLABS_API_KEY) return 'elevenlabs'; + return 'none'; +} + +function getTTSEnvProvider() { + return process.env.TTS_PROVIDER || 'auto'; +} + +function getTTSVoiceLists() { + return { + google: GOOGLE_TTS_VOICES, + litellm: OPENAI_TTS_VOICES, + elevenlabs: ELEVENLABS_DEFAULT_VOICES + }; +} + +function isOpenAITTSModel(modelId) { + return /^openai[-/](tts-1|tts-1-hd|gpt-4o-mini-tts|gpt-audio)/.test(String(modelId || '').toLowerCase()); +} + +function getLiteLLMTTSModels(models) { + return (models || []) + .map(function(model) { return model && model.id ? model.id : ''; }) + .filter(isOpenAITTSModel); +} + +function getLiteLLMHeaders(contentType) { + var headers = {}; + if (contentType) headers['Content-Type'] = contentType; + if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY; + return headers; +} + +module.exports = { + ELEVENLABS_DEFAULT_VOICES, + GOOGLE_TTS_VOICES, + OPENAI_TTS_VOICES, + getTTSEnvProvider, + getLiteLLMHeaders, + getLiteLLMTTSModels, + getTTSProvider, + getTTSVoiceLists, + isOpenAITTSModel +}; diff --git a/test/tts-provider.test.js b/test/tts-provider.test.js new file mode 100644 index 0000000..e6c05e9 --- /dev/null +++ b/test/tts-provider.test.js @@ -0,0 +1,79 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'ELEVENLABS_API_KEY', 'GOOGLE_VERTEX_PROJECT']; + +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 respects explicit provider override', () => { + const ttsProvider = require('../src/utils/ttsProvider'); + withEnv({ TTS_PROVIDER: 'elevenlabs', LITELLM_API_BASE: 'https://llm.example.com' }, () => { + assert.equal(ttsProvider.getTTSEnvProvider(), 'elevenlabs'); + assert.equal(ttsProvider.getTTSProvider(), 'elevenlabs'); + }); +}); + +test('LiteLLM TTS voice list stays OpenAI-compatible', () => { + const ttsProvider = require('../src/utils/ttsProvider'); + assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [ + 'alloy', + 'echo', + 'fable', + 'onyx', + 'nova', + 'shimmer', + 'ash', + 'sage', + 'coral' + ]); +}); + +test('TTS model discovery keeps OpenAI speech models only', () => { + const ttsProvider = require('../src/utils/ttsProvider'); + assert.equal(ttsProvider.isOpenAITTSModel('openai/tts-1'), true); + assert.equal(ttsProvider.isOpenAITTSModel('openai-tts-1-hd'), true); + assert.equal(ttsProvider.isOpenAITTSModel('openai-gpt-4o-mini-tts'), true); + assert.equal(ttsProvider.isOpenAITTSModel('mistral-voxtral-mini-tts'), false); + assert.equal(ttsProvider.isOpenAITTSModel('vertex-gemini-2.5-flash-tts'), 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: 'vertex-gemini-2.5-flash-tts' }, + { id: 'gpt-4.1' } + ]), ['openai-tts-1', 'openai-gpt-4o-mini-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' + }); + }); +});