simplify TTS provider handling
This commit is contained in:
parent
e7b706a4fa
commit
d6dccd2c65
5 changed files with 193 additions and 93 deletions
|
|
@ -11,6 +11,24 @@ function adminTableMessage(colspan, color, text) {
|
|||
return '<tr><td colspan="' + colspan + '" style="text-align:center;color:' + color + ';padding:20px;">' + adminEscapeHtml(text) + '</td></tr>';
|
||||
}
|
||||
|
||||
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 = '<i class="fas fa-spinner fa-spin"></i> Synthesizing...'; }
|
||||
adminSetButtonHtml(btn, '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-play"></i> Synthesize & Play'; }
|
||||
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> 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 = '<i class="fas fa-play"></i> Synthesize & Play'; }
|
||||
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> 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 = '<i class="fas fa-spinner fa-spin"></i>'; }
|
||||
adminSetButtonHtml(btn, '<i class="fas fa-spinner fa-spin"></i>', 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 = '<i class="fas fa-code-branch"></i> Generate'; }
|
||||
adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
|
||||
if (!data.success) {
|
||||
if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>';
|
||||
return;
|
||||
|
|
@ -1693,7 +1714,7 @@ function adminTableMessage(colspan, color, text) {
|
|||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-code-branch"></i> Generate'; }
|
||||
adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
|
||||
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
57
src/utils/ttsProvider.js
Normal file
57
src/utils/ttsProvider.js
Normal file
|
|
@ -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
|
||||
};
|
||||
79
test/tts-provider.test.js
Normal file
79
test/tts-provider.test.js
Normal file
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue