diff --git a/package-lock.json b/package-lock.json index ba644fa0..f66a7a43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "marked": "^18.0.2", "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.7.0", - "nodemailer": "9.0.1", + "nodemailer": "^9.1.1", "openai": "^4.73.0", "openid-client": "^6.8.2", "pdf-parse": "^1.1.1", @@ -5958,9 +5958,9 @@ } }, "node_modules/nodemailer": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", - "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz", + "integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index da48cf04..f64f2985 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "marked": "^18.0.2", "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.7.0", - "nodemailer": "9.0.1", + "nodemailer": "^9.1.1", "openai": "^4.73.0", "openid-client": "^6.8.2", "pdf-parse": "^1.1.1", diff --git a/src/routes/userPreferences.js b/src/routes/userPreferences.js index 08e12c4c..7c2b0a47 100644 --- a/src/routes/userPreferences.js +++ b/src/routes/userPreferences.js @@ -6,7 +6,7 @@ var express = require('express'); var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); -var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); +var { getSTTModelLists, getSTTProvider, discoverSTTModels } = require('../utils/sttProvider'); var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider'); router.use(authMiddleware); @@ -57,7 +57,16 @@ router.get('/preferences/options', async function(req, res) { var dbModel = await db.getSetting('tts.model') || ''; var dbVoice = await db.getSetting('tts.voice') || ''; var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || ''; - var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; }); + // Offer what the gateway really has. The built-in list is a last resort: + // its ids do not resolve on every deployment, and a user who picked one got + // "Invalid model name" on every recording, because the user's choice wins + // over the admin default in /api/transcribe. + var sttIds = await discoverSTTModels(); + if (!sttIds.length) sttIds = getSTTModelLists().litellm.slice(); + var adminSttModel = await db.getSetting('stt.model') || process.env.LITELLM_STT_MODEL || ''; + var sttModels = sttIds.map(function(model) { + return { value: model, label: model + (model === adminSttModel ? ' (default)' : '') }; + }); var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; }); res.json({ diff --git a/src/utils/sttProvider.js b/src/utils/sttProvider.js index 0569f4a2..f22e5dd4 100644 --- a/src/utils/sttProvider.js +++ b/src/utils/sttProvider.js @@ -33,8 +33,35 @@ function getLiteLLMSTTModels(models) { .map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); }); } +// What the gateway actually offers, so Settings cannot present a user with a +// model that does not exist. The hardcoded list above is only a last resort: +// on this deployment none of its six ids resolve, and picking one returns +// "Invalid model name" from /audio/transcriptions. +var sttDiscoveryCache = { at: 0, models: [] }; +var STT_DISCOVERY_TTL_MS = 5 * 60 * 1000; + +async function discoverSTTModels(options) { + var now = Date.now(); + var fresh = !(options && options.force) && (now - sttDiscoveryCache.at) < STT_DISCOVERY_TTL_MS; + if (fresh && sttDiscoveryCache.models.length) return sttDiscoveryCache.models.slice(); + if (getSTTProvider() !== 'litellm' || !process.env.LITELLM_API_BASE) return []; + try { + var axios = require('axios'); + var { getLiteLLMAdminHeaders } = require('./litellm'); + var base = String(process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, ''); + var resp = await axios.get(base + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 }); + var ids = getLiteLLMSTTModels(resp.data && resp.data.data); + if (ids.length) sttDiscoveryCache = { at: now, models: ids }; + return ids; + } catch (e) { + // A gateway hiccup must not empty the picker; the caller falls back. + return sttDiscoveryCache.models.slice(); + } +} + module.exports = { LITELLM_STT_MODELS, + discoverSTTModels, getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, diff --git a/test/transcription-memory-policy.test.js b/test/transcription-memory-policy.test.js index 243c7903..80953db6 100644 --- a/test/transcription-memory-policy.test.js +++ b/test/transcription-memory-policy.test.js @@ -75,3 +75,21 @@ test('audio backup settings render without dynamic HTML templates', () => { assert.match(renderer, /document\.createElement\('button'\)/); assert.match(renderer, /textContent =/); }); + +// The Settings picker offered six hardcoded ids. On this gateway none of them +// resolve, and /api/transcribe prefers the user's choice over the admin +// default — so choosing one broke every recording with "Invalid model name". +test('the STT picker offers what the gateway has, not a hardcoded list', () => { + const stt = read('src/utils/sttProvider.js'); + const prefs = read('src/routes/userPreferences.js'); + + assert.match(stt, /async function discoverSTTModels\(options\)/); + assert.match(stt, /getLiteLLMSTTModels\(resp\.data && resp\.data\.data\)/, 'filtered by audio_transcription mode'); + assert.match(stt, /STT_DISCOVERY_TTL_MS = 5 \* 60 \* 1000;/, 'cached, so a user-facing page does not hit the gateway every load'); + assert.match(stt, /module\.exports = \{[\s\S]{0,80}discoverSTTModels,/); + + assert.match(prefs, /var sttIds = await discoverSTTModels\(\);/); + assert.match(prefs, /if \(!sttIds\.length\) sttIds = getSTTModelLists\(\)\.litellm\.slice\(\);/, + 'the built-in list survives only as a fallback'); + assert.match(prefs, /model === adminSttModel \? ' \(default\)' : ''/, 'the admin default is marked'); +});