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'
+ });
+ });
+});