simplify TTS provider handling

This commit is contained in:
Daniel 2026-05-08 06:00:33 +02:00
parent e7b706a4fa
commit d6dccd2c65
5 changed files with 193 additions and 93 deletions

View file

@ -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 &amp; Play'; }
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; 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 &amp; Play'; }
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; 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;
});
}

View file

@ -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') {

View file

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