diff --git a/docker-compose.yml b/docker-compose.yml index aa9bb79..e6837a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,15 @@ services: - "127.0.0.1:3552:3000" env_file: - .env + - /root/litellm/.env environment: CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp REDIS_URL: redis://ped-ai-redis:6379 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: - scribe-logs:/app/data/logs - clinical-assistant-mcp-data:/app/mcp-data:ro @@ -24,6 +29,7 @@ services: - default - mcp-server_default - monitoring_default + - speech_net healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] interval: 30s @@ -80,3 +86,5 @@ networks: external: true monitoring_default: external: true + speech_net: + external: true diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 95d6757..ab403e8 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -11,7 +11,7 @@ var logger = require('../utils/logger'); var { gatewayUrl } = require('../utils/errors'); var { GOOGLE_TTS_VOICES, OPENAI_TTS_VOICES, getTTSEnvProvider, getLiteLLMTTSModels, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider'); var { getLiteLLMHeaders } = require('../utils/litellm'); -var { getSTTDependencies, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); +var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); router.use(authMiddleware); @@ -478,9 +478,9 @@ router.get('/config/tts', async function(req, res) { envVoice: envVoice, envModel: envModel, configured: { - google: isGoogleTTSConfigured(), + google: false, litellm: !!process.env.LITELLM_API_BASE, - elevenlabs: !!process.env.ELEVENLABS_API_KEY + elevenlabs: false }, voices: getTTSVoiceLists() }); @@ -519,32 +519,13 @@ router.get('/config/tts/discover', async function(req, res) { } if (provider === 'litellm' && process.env.LITELLM_API_BASE) { - // base URL handled by gatewayUrl() helper 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) { discovered.push({ id: id, name: id, source: 'gateway-api' }); }); } 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) { @@ -680,16 +661,17 @@ router.get('/config/stt/discover', async function(req, res) { if (provider === 'litellm' && process.env.LITELLM_API_BASE) { // base URL handled by gatewayUrl() helper try { - var sResp = await axios.get(gatewayUrl('/models'), { headers: getLiteLLMHeaders(), timeout: 10000 }); - if (sResp.data && sResp.data.data) { - sResp.data.data.forEach(function(m) { - var mid = (m.id || '').toLowerCase(); - 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' }); - } - }); - } + var liteLLMBase = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, ''); + var sResp = await axios.get(liteLLMBase + '/model/info', { headers: getLiteLLMHeaders(), timeout: 10000 }); + getLiteLLMSTTModels(sResp.data && sResp.data.data).forEach(function(id) { + discovered.push({ id: id, name: id, source: 'gateway-api' }); + }); } 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') { diff --git a/src/routes/tts.js b/src/routes/tts.js index 951e76a..dba6f13 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -5,10 +5,11 @@ 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'); +var { getTTSProvider } = require('../utils/ttsProvider'); +var { getLiteLLMHeaders } = require('../utils/litellm'); // 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=elevenlabs → ElevenLabs direct (not HIPAA, opt-in) // Auto: litellm > google > elevenlabs diff --git a/src/utils/litellm.js b/src/utils/litellm.js index 6096fdc..96b47ea 100644 --- a/src/utils/litellm.js +++ b/src/utils/litellm.js @@ -1,7 +1,8 @@ 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; + var key = process.env.LITELLM_API_KEY || process.env.LITELLM_MASTER_KEY; + if (key) headers.Authorization = 'Bearer ' + key; return headers; } diff --git a/src/utils/sttProvider.js b/src/utils/sttProvider.js index 803a084..9ce979f 100644 --- a/src/utils/sttProvider.js +++ b/src/utils/sttProvider.js @@ -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 = { AWS_STT_MODES, GOOGLE_STT_MODELS, @@ -48,6 +59,8 @@ module.exports = { LOCAL_WHISPER_MODELS, OPENAI_STT_MODELS, getSTTDependencies, + getLiteLLMSTTModels, getSTTModelLists, - getSTTProvider + getSTTProvider, + isLiteLLMSTTModel }; diff --git a/src/utils/ttsProvider.js b/src/utils/ttsProvider.js index 0bda99c..c3490fb 100644 --- a/src/utils/ttsProvider.js +++ b/src/utils/ttsProvider.js @@ -32,10 +32,15 @@ function isOpenAITTSModel(modelId) { 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) { return (models || []) - .map(function(model) { return model && model.id ? model.id : ''; }) - .filter(isOpenAITTSModel); + .filter(isLiteLLMTTSModel) + .map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); }); } module.exports = { @@ -47,5 +52,6 @@ module.exports = { getLiteLLMTTSModels, getTTSProvider, getTTSVoiceLists, + isLiteLLMTTSModel, isOpenAITTSModel }; diff --git a/test/stt-provider.test.js b/test/stt-provider.test.js index 2319f68..04d86a9 100644 --- a/test/stt-provider.test.js +++ b/test/stt-provider.test.js @@ -46,3 +46,31 @@ test('STT model lists preserve admin defaults', () => { 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']); +}); diff --git a/test/tts-provider.test.js b/test/tts-provider.test.js index e6c05e9..3f269e9 100644 --- a/test/tts-provider.test.js +++ b/test/tts-provider.test.js @@ -57,15 +57,28 @@ test('TTS model discovery keeps OpenAI speech models only', () => { 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', () => { 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: '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: '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', () => {