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"
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

View file

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

View file

@ -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

View file

@ -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;
}

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 = {
AWS_STT_MODES,
GOOGLE_STT_MODELS,
@ -48,6 +59,8 @@ module.exports = {
LOCAL_WHISPER_MODELS,
OPENAI_STT_MODELS,
getSTTDependencies,
getLiteLLMSTTModels,
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());
}
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
};

View file

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

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);
});
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', () => {