fix litellm speech model discovery

This commit is contained in:
Daniel 2026-05-09 04:12:57 +02:00
parent 18061b45bd
commit c1b6a5176b
8 changed files with 92 additions and 40 deletions

View file

@ -6,10 +6,15 @@ services:
- "127.0.0.1:3552:3000" - "127.0.0.1:3552:3000"
env_file: env_file:
- .env - .env
- /root/litellm/.env
environment: environment:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
REDIS_URL: redis://ped-ai-redis:6379 REDIS_URL: redis://ped-ai-redis:6379
LOKI_URL: http://monitoring-loki:3100 LOKI_URL: http://monitoring-loki:3100
LITELLM_API_BASE: http://litellm:4000
TTS_PROVIDER: litellm
LITELLM_TTS_MODEL: local-kokoro-tts
LITELLM_TTS_VOICE: sherpa/kokoro:am_adam
volumes: volumes:
- scribe-logs:/app/data/logs - scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro - clinical-assistant-mcp-data:/app/mcp-data:ro
@ -24,6 +29,7 @@ services:
- default - default
- mcp-server_default - mcp-server_default
- monitoring_default - monitoring_default
- speech_net
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
@ -80,3 +86,5 @@ networks:
external: true external: true
monitoring_default: monitoring_default:
external: true external: true
speech_net:
external: true

View file

@ -11,7 +11,7 @@ var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var { GOOGLE_TTS_VOICES, OPENAI_TTS_VOICES, getTTSEnvProvider, getLiteLLMTTSModels, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider'); var { GOOGLE_TTS_VOICES, OPENAI_TTS_VOICES, getTTSEnvProvider, getLiteLLMTTSModels, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm'); var { getLiteLLMHeaders } = require('../utils/litellm');
var { getSTTDependencies, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
router.use(authMiddleware); router.use(authMiddleware);
@ -478,9 +478,9 @@ router.get('/config/tts', async function(req, res) {
envVoice: envVoice, envVoice: envVoice,
envModel: envModel, envModel: envModel,
configured: { configured: {
google: isGoogleTTSConfigured(), google: false,
litellm: !!process.env.LITELLM_API_BASE, litellm: !!process.env.LITELLM_API_BASE,
elevenlabs: !!process.env.ELEVENLABS_API_KEY elevenlabs: false
}, },
voices: getTTSVoiceLists() voices: getTTSVoiceLists()
}); });
@ -519,32 +519,13 @@ router.get('/config/tts/discover', async function(req, res) {
} }
if (provider === 'litellm' && process.env.LITELLM_API_BASE) { if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
// base URL handled by gatewayUrl() helper
try { try {
var lResp = await axios.get(gatewayUrl('/models'), { headers: getLiteLLMHeaders(), timeout: 10000 }); 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) { getLiteLLMTTSModels(lResp.data && lResp.data.data).forEach(function(id) {
discovered.push({ id: id, name: id, source: 'gateway-api' }); discovered.push({ id: id, name: id, source: 'gateway-api' });
}); });
} catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); } } catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); }
// OpenAI voices
OPENAI_TTS_VOICES.forEach(function(v) {
discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'openai-voices' });
});
// ElevenLabs voices — fetch from API if key available
if (process.env.ELEVENLABS_API_KEY) {
try {
var elResp = await axios.get('https://api.elevenlabs.io/v1/voices', {
headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY }, timeout: 10000
});
if (elResp.data && elResp.data.voices) {
elResp.data.voices.forEach(function(v) {
discovered.push({ id: v.voice_id, name: v.name + ' (ElevenLabs)', source: 'elevenlabs-api' });
});
}
} catch (e) { logger.warn('ElevenLabs voice fetch failed: ' + e.message); }
}
} }
if (provider === 'elevenlabs' && process.env.ELEVENLABS_API_KEY) { if (provider === 'elevenlabs' && process.env.ELEVENLABS_API_KEY) {
@ -680,16 +661,17 @@ router.get('/config/stt/discover', async function(req, res) {
if (provider === 'litellm' && process.env.LITELLM_API_BASE) { if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
// base URL handled by gatewayUrl() helper // base URL handled by gatewayUrl() helper
try { try {
var sResp = await axios.get(gatewayUrl('/models'), { headers: getLiteLLMHeaders(), timeout: 10000 }); var liteLLMBase = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
if (sResp.data && sResp.data.data) { var sResp = await axios.get(liteLLMBase + '/model/info', { headers: getLiteLLMHeaders(), timeout: 10000 });
sResp.data.data.forEach(function(m) { getLiteLLMSTTModels(sResp.data && sResp.data.data).forEach(function(id) {
var mid = (m.id || '').toLowerCase(); discovered.push({ id: id, name: id, source: 'gateway-api' });
if (mid.indexOf('whisper') !== -1 || mid.indexOf('parakeet') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1 || mid.indexOf('scribe') !== -1 || mid.indexOf('nova') !== -1 || mid.indexOf('deepgram') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('chirp') !== -1) { });
discovered.push({ id: m.id, name: m.id, source: 'gateway-api' });
}
});
}
} catch(e) { logger.warn('STT discovery failed: ' + e.message); } } catch(e) { logger.warn('STT discovery failed: ' + e.message); }
if (discovered.length === 0) {
getSTTModelLists().litellm.forEach(function(id) {
discovered.push({ id: id, name: id, source: 'configured-default' });
});
}
} }
if (provider === 'local') { if (provider === 'local') {

View file

@ -5,10 +5,11 @@ const { synthesizeWithGoogleTTS } = require('../utils/ttsGoogle');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var { getLiteLLMHeaders, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm');
// Provider priority (auto-detect): // Provider priority (auto-detect):
// TTS_PROVIDER=litellm → LiteLLM proxy at /v1/audio/speech (default when LITELLM_API_BASE is set) // TTS_PROVIDER=litellm → LiteLLM proxy at /v1/audio/speech
// TTS_PROVIDER=google → Google Cloud TTS direct (HIPAA eligible, opt-in) // TTS_PROVIDER=google → Google Cloud TTS direct (HIPAA eligible, opt-in)
// TTS_PROVIDER=elevenlabs → ElevenLabs direct (not HIPAA, opt-in) // TTS_PROVIDER=elevenlabs → ElevenLabs direct (not HIPAA, opt-in)
// Auto: litellm > google > elevenlabs // Auto: litellm > google > elevenlabs

View file

@ -1,7 +1,8 @@
function getLiteLLMHeaders(contentType) { function getLiteLLMHeaders(contentType) {
var headers = {}; var headers = {};
if (contentType) headers['Content-Type'] = contentType; if (contentType) headers['Content-Type'] = contentType;
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY; var key = process.env.LITELLM_API_KEY || process.env.LITELLM_MASTER_KEY;
if (key) headers.Authorization = 'Bearer ' + key;
return headers; return headers;
} }

View file

@ -41,6 +41,17 @@ function getSTTModelLists() {
}; };
} }
function isLiteLLMSTTModel(model) {
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
return mode === 'audio_transcription';
}
function getLiteLLMSTTModels(models) {
return (models || [])
.filter(isLiteLLMSTTModel)
.map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); });
}
module.exports = { module.exports = {
AWS_STT_MODES, AWS_STT_MODES,
GOOGLE_STT_MODELS, GOOGLE_STT_MODELS,
@ -48,6 +59,8 @@ module.exports = {
LOCAL_WHISPER_MODELS, LOCAL_WHISPER_MODELS,
OPENAI_STT_MODELS, OPENAI_STT_MODELS,
getSTTDependencies, getSTTDependencies,
getLiteLLMSTTModels,
getSTTModelLists, getSTTModelLists,
getSTTProvider getSTTProvider,
isLiteLLMSTTModel
}; };

View file

@ -32,10 +32,15 @@ function isOpenAITTSModel(modelId) {
return /^openai[-/](tts-1|tts-1-hd|gpt-4o-mini-tts|gpt-audio)/.test(String(modelId || '').toLowerCase()); return /^openai[-/](tts-1|tts-1-hd|gpt-4o-mini-tts|gpt-audio)/.test(String(modelId || '').toLowerCase());
} }
function isLiteLLMTTSModel(model) {
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
return mode === 'audio_speech';
}
function getLiteLLMTTSModels(models) { function getLiteLLMTTSModels(models) {
return (models || []) return (models || [])
.map(function(model) { return model && model.id ? model.id : ''; }) .filter(isLiteLLMTTSModel)
.filter(isOpenAITTSModel); .map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); });
} }
module.exports = { module.exports = {
@ -47,5 +52,6 @@ module.exports = {
getLiteLLMTTSModels, getLiteLLMTTSModels,
getTTSProvider, getTTSProvider,
getTTSVoiceLists, getTTSVoiceLists,
isLiteLLMTTSModel,
isOpenAITTSModel isOpenAITTSModel
}; };

View file

@ -46,3 +46,31 @@ test('STT model lists preserve admin defaults', () => {
local: ['tiny', 'base', 'small', 'medium', 'large'] local: ['tiny', 'base', 'small', 'medium', 'large']
}); });
}); });
test('LiteLLM STT discovery prefers audio_transcription metadata', () => {
const sttProvider = require('../src/utils/sttProvider');
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'custom-asr', model_info: { mode: 'audio_transcription' } }), true);
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }), true);
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } }), false);
});
test('LiteLLM STT discovery ignores names when metadata is absent', () => {
const sttProvider = require('../src/utils/sttProvider');
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'groq-whisper-large-v3' }), false);
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'mistral-voxtral-mini-transcribe' }), false);
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'openrouter-nova-pro-v1' }), false);
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'openrouter-gemini-2.5-flash' }), false);
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'openrouter-gpt-audio' }), false);
assert.equal(sttProvider.isLiteLLMSTTModel({ id: 'local-chatterbox-turbo' }), false);
});
test('LiteLLM STT model extraction filters by metadata only', () => {
const sttProvider = require('../src/utils/sttProvider');
assert.deepEqual(sttProvider.getLiteLLMSTTModels([
{ model_name: 'custom-asr', model_info: { mode: 'audio_transcription' } },
{ model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } },
{ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } },
{ id: 'groq-whisper-large-v3' },
{ id: 'gpt-4.1' }
]), ['custom-asr', 'local-parakeet-v3']);
});

View file

@ -57,15 +57,28 @@ test('TTS model discovery keeps OpenAI speech models only', () => {
assert.equal(ttsProvider.isOpenAITTSModel('vertex-gemini-2.5-flash-tts'), false); assert.equal(ttsProvider.isOpenAITTSModel('vertex-gemini-2.5-flash-tts'), false);
}); });
test('TTS model discovery uses audio_speech metadata only', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-chatterbox-turbo' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-kokoro-tts' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } }), true);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'custom-provider-model', model_info: { mode: 'audio_speech' } }), true);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }), false);
});
test('LiteLLM TTS model extraction filters gateway model objects', () => { test('LiteLLM TTS model extraction filters gateway model objects', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSModels([ assert.deepEqual(ttsProvider.getLiteLLMTTSModels([
{ id: 'openai-tts-1' }, { id: 'openai-tts-1' },
{ id: 'mistral-voxtral-mini-tts' }, { id: 'mistral-voxtral-mini-tts' },
{ id: 'openai-gpt-4o-mini-tts' }, { id: 'openai-gpt-4o-mini-tts' },
{ id: 'local-chatterbox-turbo' },
{ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } },
{ id: 'custom-provider-model', model_info: { mode: 'audio_speech' } },
{ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } },
{ id: 'vertex-gemini-2.5-flash-tts' }, { id: 'vertex-gemini-2.5-flash-tts' },
{ id: 'gpt-4.1' } { id: 'gpt-4.1' }
]), ['openai-tts-1', 'openai-gpt-4o-mini-tts']); ]), ['local-chatterbox-turbo', 'custom-provider-model']);
}); });
test('LiteLLM headers include optional content type without exposing key value', () => { test('LiteLLM headers include optional content type without exposing key value', () => {