diff --git a/public/js/admin.js b/public/js/admin.js
index 144a2db..95c6843 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1275,12 +1275,12 @@ function adminFlashButtonBackground(btn, color) {
}
var items = data.voices || [];
if (items.length === 0) {
- container.innerHTML = '
No voices found' + (search ? ' matching "' + esc(search) + '"' : '') + '
';
+ container.innerHTML = 'No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '
';
return;
}
- container.innerHTML = 'Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')
' +
+ container.innerHTML = 'Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')
' +
items.slice(0, 100).map(function(v) {
- var isModel = (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1;
+ var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1;
var setType = isModel ? 'model' : 'voice';
var badge = isModel ? 'MODEL' : 'VOICE';
return '' +
diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js
index 2811920..4390508 100644
--- a/src/routes/adminConfig.js
+++ b/src/routes/adminConfig.js
@@ -9,7 +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, getTTSEnvProvider, getLiteLLMTTSModels, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
+var { GOOGLE_TTS_VOICES, getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSModels, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
@@ -520,13 +520,19 @@ router.get('/config/tts/discover', async function(req, res) {
}
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
+ var dbVoice = await db.getSetting('tts.voice') || '';
+ var dbModel = await db.getSetting('tts.model') || '';
+ var currentVoice = dbVoice || process.env.LITELLM_TTS_VOICE || '';
+ var currentModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
+ var modelInfo = [];
try {
var liteLLMBase = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
var lResp = await axios.get(liteLLMBase + '/model/info', { headers: getLiteLLMHeaders(), timeout: 10000 });
- getLiteLLMTTSModels(lResp.data && lResp.data.data).forEach(function(id) {
- discovered.push({ id: id, name: id, source: 'gateway-api' });
- });
+ modelInfo = lResp.data && lResp.data.data ? lResp.data.data : [];
} catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); }
+ getLiteLLMTTSDiscoveryItems(modelInfo, { currentModel: currentModel, currentVoice: currentVoice }).forEach(function(item) {
+ discovered.push(item);
+ });
}
if (provider === 'elevenlabs' && process.env.ELEVENLABS_API_KEY) {
diff --git a/src/utils/ttsProvider.js b/src/utils/ttsProvider.js
index e278de9..ab6b35e 100644
--- a/src/utils/ttsProvider.js
+++ b/src/utils/ttsProvider.js
@@ -39,11 +39,36 @@ function getLiteLLMTTSModels(models) {
.map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); });
}
+function pushUniqueTTSItem(items, item) {
+ if (!item || !item.id) return;
+ if (items.some(function(existing) { return existing.id === item.id && existing.kind === item.kind; })) return;
+ items.push(item);
+}
+
+function getLiteLLMTTSDiscoveryItems(models, opts) {
+ opts = opts || {};
+ var items = [];
+ getLiteLLMTTSModels(models).forEach(function(id) {
+ pushUniqueTTSItem(items, { id: id, name: id, source: 'gateway-api', kind: 'model' });
+ });
+ if (opts.currentModel) {
+ pushUniqueTTSItem(items, { id: opts.currentModel, name: opts.currentModel, source: 'configured-model', kind: 'model' });
+ }
+ if (opts.currentVoice) {
+ pushUniqueTTSItem(items, { id: opts.currentVoice, name: opts.currentVoice, source: 'configured-voice', kind: 'voice' });
+ }
+ OPENAI_TTS_VOICES.forEach(function(voice) {
+ pushUniqueTTSItem(items, { id: voice, name: voice, source: 'openai-compatible', kind: 'voice' });
+ });
+ return items;
+}
+
module.exports = {
ELEVENLABS_DEFAULT_VOICES,
GOOGLE_TTS_VOICES,
OPENAI_TTS_VOICES,
getTTSEnvProvider,
+ getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders,
getLiteLLMTTSModels,
getTTSProvider,
diff --git a/test/tts-provider.test.js b/test/tts-provider.test.js
index b6791ab..219ec4d 100644
--- a/test/tts-provider.test.js
+++ b/test/tts-provider.test.js
@@ -77,6 +77,35 @@ test('LiteLLM TTS model extraction filters gateway model objects', () => {
]), ['local-chatterbox-turbo', 'custom-provider-model']);
});
+test('LiteLLM TTS discovery includes metadata models and configured fallbacks', () => {
+ const ttsProvider = require('../src/utils/ttsProvider');
+ const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
+ { model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } },
+ { model_name: 'not-tts-by-name-only' },
+ { model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }
+ ], {
+ currentModel: 'local-kokoro-tts',
+ currentVoice: 'sherpa/kokoro:am_adam'
+ });
+ assert.deepEqual(items.slice(0, 3), [
+ { id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model' },
+ { id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' },
+ { id: 'alloy', name: 'alloy', source: 'openai-compatible', kind: 'voice' }
+ ]);
+ assert.equal(items.some(function(item) { return item.id === 'not-tts-by-name-only'; }), false);
+});
+
+test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
+ const ttsProvider = require('../src/utils/ttsProvider');
+ assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
+ currentModel: 'local-kokoro-tts',
+ currentVoice: 'sherpa/kokoro:am_adam'
+ }).slice(0, 2), [
+ { id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'configured-model', kind: 'model' },
+ { id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' }
+ ]);
+});
+
test('LiteLLM headers include optional content type without exposing key value', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {