pediatric-ai-scribe-v3/src/routes/adminConfig.js
2026-05-09 04:50:55 +02:00

951 lines
45 KiB
JavaScript

// ============================================================
// ADMIN CONFIG ROUTES — CMS for announcements, prompts, emails, flags
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
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, 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');
router.use(authMiddleware);
// ── GET announcement (any authenticated user) ──────────────────────────────
router.get('/config/announcement', async function(req, res) {
try {
var enabled = await db.getSetting('announcement.enabled');
var text = await db.getSetting('announcement.text');
var type = await db.getSetting('announcement.type');
res.json({
success: true,
enabled: enabled === 'true',
text: text || '',
type: type || 'info'
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
router.use(adminMiddleware);
// ── GET all config entries ─────────────────────────────────────────────────
router.get('/config', async function(req, res) {
try {
var rows = await db.all(
"SELECT key, value, updated_at FROM app_settings ORDER BY key",
[]
);
// Also include current in-memory prompts (may differ if not yet in DB)
var promptKeys = PROMPTS.getAllPrompts().map(function(p) { return p.key; });
var inDb = new Set(rows.filter(function(r) { return r.key.startsWith('prompt.'); }).map(function(r) { return r.key; }));
promptKeys.forEach(function(key) {
var dbKey = 'prompt.' + key;
if (!inDb.has(dbKey)) {
rows.push({ key: dbKey, value: PROMPTS[key], updated_at: null });
}
});
res.json({ success: true, config: rows });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST send test email ───────────────────────────────────────────────────
router.post('/config/test-email', async function(req, res) {
try {
var { to, template } = req.body;
if (!to) return res.status(400).json({ error: 'Recipient email required' });
var subjectKey = 'email.' + (template || 'verify') + '.subject';
var bodyKey = 'email.' + (template || 'verify') + '.body';
var subject = await db.getSetting(subjectKey) || 'Test email from Pediatric AI Scribe';
var bodyText = await db.getSetting(bodyKey) || 'This is a test email.';
var sendEmail = require('./auth').__sendEmail;
if (!sendEmail) {
return res.status(400).json({ error: 'Email not configured (SMTP_HOST missing)' });
}
var emailWrapper = require('./auth').__emailWrapper;
var btnHtml = require('./auth').__btnHtml;
var html = emailWrapper(
'<p style="margin:0 0 6px;font-size:18px;font-weight:600;color:#111827;">Test Email</p>' +
'<p style="color:#6b7280;margin:12px 0 20px;line-height:1.6;font-size:14px;">' + bodyText.replace(/\n/g, '<br>') + '</p>' +
btnHtml('#', 'Test Button')
);
var ok = await sendEmail(to, '[TEST] ' + subject, html);
res.json({ success: ok, message: ok ? 'Test email sent to ' + to : 'SMTP not configured' });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET prompt list (for editor UI) ───────────────────────────────────────
router.get('/config/prompts', async function(req, res) {
try {
var prompts = PROMPTS.getAllPrompts();
// Load any DB overrides
var dbRows = await db.all("SELECT key, value FROM app_settings WHERE key LIKE 'prompt.%'", []);
var dbMap = {};
dbRows.forEach(function(r) { dbMap[r.key] = r.value; });
prompts.forEach(function(p) {
var dbKey = 'prompt.' + p.key;
if (dbMap[dbKey]) p.value = dbMap[dbKey];
p.dbKey = dbKey;
});
res.json({ success: true, prompts: prompts });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST reset a prompt to hardcoded default ──────────────────────────────
router.post('/config/prompts/:key/reset', async function(req, res) {
try {
var key = req.params.key;
if (!Object.prototype.hasOwnProperty.call(PROMPTS, key) || typeof PROMPTS[key] !== 'string') {
return res.status(404).json({ error: 'Prompt not found' });
}
// Delete DB override so hardcoded default is used
await db.run("DELETE FROM app_settings WHERE key = ?", ['prompt.' + key]);
// Reload from hardcoded source (re-require)
delete require.cache[require.resolve('../utils/prompts')];
var fresh = require('../utils/prompts');
PROMPTS[key] = fresh[key] || PROMPTS[key];
logger.audit(req.user.id, 'admin_config_reset', 'Reset prompt to default: ' + key, req, { category: 'admin' });
res.json({ success: true, value: PROMPTS[key] });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST reset all non-prompt settings to defaults ──────────────────────
router.post('/config/reset-defaults', async function(req, res) {
try {
var defaults = [
['registration_enabled', 'true'],
['announcement.enabled', 'false'],
['announcement.text', ''],
['announcement.type', 'info'],
['feature.read_aloud', 'true'],
['feature.nextcloud', 'true'],
['feature.memories', 'true'],
['email.verify.subject', 'Verify your Pediatric AI Scribe account'],
['email.verify.body', 'Thank you for signing up! Click the button below to verify your email address. This link expires in 24 hours.'],
['email.reset.subject', 'Reset your password — Pediatric AI Scribe'],
['email.reset.body', 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed — your password has not been changed.'],
['site.name', 'Pediatric AI Scribe'],
['site.auto_delete_days', '7'],
];
for (var d = 0; d < defaults.length; d++) {
await db.setSetting(defaults[d][0], defaults[d][1]);
}
logger.audit(req.user.id, 'admin_config_reset_all', 'Reset all settings to defaults', req, { category: 'admin' });
res.json({ success: true, message: 'Settings reset to defaults (SMTP and custom models preserved)' });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET SMTP status ──────────────────────────────────────────────────────
router.get('/config/smtp/status', async function(req, res) {
try {
var host = await db.getSetting('smtp.host') || process.env.SMTP_HOST || '';
var user = await db.getSetting('smtp.user') || process.env.SMTP_USER || '';
var from = await db.getSetting('smtp.from') || process.env.SMTP_FROM || process.env.SMTP_USER || '';
var port = await db.getSetting('smtp.port') || process.env.SMTP_PORT || '587';
// Never return password
res.json({
success: true,
configured: !!host,
host: host,
port: port,
user: user,
from: from,
source: process.env.SMTP_HOST ? 'env' : (host ? 'database' : 'none')
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── PUT update SMTP settings ─────────────────────────────────────────────
// NOTE: This must come BEFORE the wildcard PUT /config/:key(*) below
router.put('/config/smtp', async function(req, res) {
try {
var { host, port, user, pass, from, secure } = req.body;
if (!host) return res.status(400).json({ error: 'SMTP host required' });
await db.setSetting('smtp.host', host.trim());
await db.setSetting('smtp.port', String(port || '587').trim());
await db.setSetting('smtp.user', (user || '').trim());
await db.setSetting('smtp.from', (from || user || '').trim());
await db.setSetting('smtp.secure', String(secure === true || secure === 'true'));
if (pass && pass.trim()) {
await db.setSetting('smtp.pass', pass.trim());
}
logger.audit(req.user.id, 'admin_smtp_update', 'Updated SMTP settings', req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── DELETE SMTP settings (clear DB override, fall back to env) ───────────
router.delete('/config/smtp', async function(req, res) {
try {
var keys = ['smtp.host', 'smtp.port', 'smtp.user', 'smtp.pass', 'smtp.from', 'smtp.secure'];
for (var k of keys) {
await db.run('DELETE FROM app_settings WHERE key = $1', [k]);
}
logger.audit(req.user.id, 'admin_smtp_clear', 'Cleared SMTP DB overrides', req, { category: 'admin' });
res.json({ success: true, message: 'SMTP DB settings cleared (env vars still apply if set)' });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ============================================================
// MODEL ROUTES — All must come BEFORE the wildcard PUT /config/:key(*)
// The wildcard uses :key(*) which matches slashes, so it would intercept
// /config/models/toggle and /config/models/default if registered after it.
// ============================================================
// ── GET models config ────────────────────────────────────────────────────
router.get('/config/models', async function(req, res) {
try {
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS, activeProvider } = require('../utils/models');
var providerModels;
switch (activeProvider) {
case 'bedrock': providerModels = BEDROCK_MODELS; break;
case 'azure': providerModels = AZURE_MODELS; break;
case 'vertex': providerModels = VERTEX_MODELS; break;
case 'litellm': providerModels = []; break; // LiteLLM: no built-ins — use discover
default: providerModels = OPENROUTER_MODELS;
}
var disabledRaw = await db.getSetting('models.disabled') || '[]';
var customRaw = await db.getSetting('models.custom') || '[]';
var defaultModel = await db.getSetting('models.default') || '';
var disabled, custom;
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
var models = providerModels.map(function(m) {
return Object.assign({}, m, { enabled: !disabled.includes(m.id) });
});
res.json({
success: true,
provider: activeProvider,
models: models,
custom: custom,
defaultModel: defaultModel,
litellmHint: activeProvider === 'litellm'
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── PUT toggle model enabled/disabled ────────────────────────────────────
router.put('/config/models/toggle', async function(req, res) {
try {
var { modelId, enabled } = req.body;
if (!modelId) return res.status(400).json({ error: 'modelId required' });
var disabledRaw = await db.getSetting('models.disabled') || '[]';
var disabled;
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
if (enabled) {
disabled = disabled.filter(function(id) { return id !== modelId; });
} else {
if (!disabled.includes(modelId)) disabled.push(modelId);
}
await db.setSetting('models.disabled', JSON.stringify(disabled));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── PUT set default model ─────────────────────────────────────────────────
router.put('/config/models/default', async function(req, res) {
try {
var { modelId } = req.body;
if (!modelId) return res.status(400).json({ error: 'modelId required' });
await db.setSetting('models.default', modelId.trim());
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST add custom model (manual entry) ─────────────────────────────────
router.post('/config/models/custom', async function(req, res) {
try {
var { id, name, cost, category } = req.body;
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
var trimmedId = id.trim();
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
if (!/^[a-zA-Z0-9._\-\/\:]+$/.test(trimmedId)) {
return res.status(400).json({ error: 'Invalid model ID format. Use only letters, numbers, dots, dashes, slashes, and colons.' });
}
// Check if model ID conflicts with a built-in model (all providers)
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS } = require('../utils/models');
var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS);
if (allBuiltIn.find(function(m) { return m.id === trimmedId; })) {
return res.status(400).json({ error: 'Model ID conflicts with a built-in model. Use the toggle to enable/disable built-in models.' });
}
var validCategories = ['free', 'fast', 'smart', 'premium'];
var cat = validCategories.includes(category) ? category : 'smart';
var customRaw = await db.getSetting('models.custom') || '[]';
var custom;
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
var existing = custom.find(function(m) { return m.id === trimmedId; });
custom = custom.filter(function(m) { return m.id !== trimmedId; });
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' });
await db.setSetting('models.custom', JSON.stringify(custom));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, existing ? 'admin_model_update' : 'admin_model_add', (existing ? 'Updated' : 'Added') + ' custom model: ' + trimmedId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── DELETE custom model ───────────────────────────────────────────────────
router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
try {
var modelId = req.params.modelId;
var customRaw = await db.getSetting('models.custom') || '[]';
var custom;
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
custom = custom.filter(function(m) { return m.id !== modelId; });
await db.setSetting('models.custom', JSON.stringify(custom));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST clear all custom/discovered models ───────────────────────────────
router.post('/config/models/clear-all', async function(req, res) {
try {
await db.setSetting('models.custom', '[]');
await db.setSetting('models.disabled', '[]');
await db.setSetting('models.default', '');
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_models_clear_all', 'Cleared all custom models and disabled list', req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET discover models from provider API ─────────────────────────────────
router.get('/config/models/discover', async function(req, res) {
try {
var { discoverModels } = require('../utils/ai');
var discovered = await discoverModels();
var search = (req.query.q || '').toLowerCase().trim();
if (search) {
discovered = discovered.filter(function(m) {
return m.id.toLowerCase().indexOf(search) !== -1 || m.name.toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST add discovered model to custom list ──────────────────────────────
router.post('/config/models/add-discovered', async function(req, res) {
try {
var { id, name, cost, category } = req.body;
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
var trimmedId = id.trim();
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
var validCategories = ['free', 'fast', 'smart', 'premium'];
var cat = validCategories.includes(category) ? category : 'smart';
var customRaw = await db.getSetting('models.custom') || '[]';
var custom;
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
custom = custom.filter(function(m) { return m.id !== trimmedId; });
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'DISCOVERED' });
await db.setSetting('models.custom', JSON.stringify(custom));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' });
res.json({ success: true, id: trimmedId });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test a model with a simple prompt ────────────────────────────────
router.post('/config/models/test', async function(req, res) {
try {
var { modelId } = req.body;
if (!modelId) return res.status(400).json({ error: 'modelId required' });
var { callAI } = require('../utils/ai');
var start = Date.now();
var result = await callAI(
[{ role: 'user', content: 'Reply with only the word: OK' }],
{ model: modelId, maxTokens: 20, temperature: 0, skipAllowlistCheck: true }
);
res.json({
success: true,
response: (result.content || '').trim(),
model: result.model,
provider: result.provider,
duration: Date.now() - start,
tokens: result.usage
});
} catch (e) {
res.json({ success: false, error: e.message });
}
});
// ── GET discover image-capable models from provider ───────────────────────
router.get('/config/image-models/discover', async function(req, res) {
try {
var axios = require('axios');
var discovered = [];
if (process.env.LITELLM_API_BASE) {
var resp = await axios.get(gatewayUrl('/models'), { headers: getLiteLLMHeaders(), timeout: 15000 });
var models = (resp.data && resp.data.data) || [];
models.forEach(function(m) {
var id = m.id || '';
if (/image|dall|gpt-image|imagen|flux|stable|sdxl|recraft|ideogram/i.test(id)) {
discovered.push({ id: id, name: id, source: 'litellm-api' });
}
});
}
var builtins = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'];
builtins.forEach(function(id) {
if (!discovered.find(function(m) { return m.id === id; })) discovered.push({ id: id, name: id, source: 'builtin' });
});
var search = (req.query.q || '').toLowerCase().trim();
if (search) discovered = discovered.filter(function(m) { return m.id.toLowerCase().indexOf(search) !== -1; });
res.json({ success: true, models: discovered, count: discovered.length });
} catch (e) {
res.status(500).json({ error: e.message || 'Request failed' });
}
});
// ── POST test image generation model ──────────────────────────────────────
router.post('/config/image-models/test', async function(req, res) {
try {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LiteLLM not configured' });
var modelId = String(req.body.modelId || '').trim();
if (!modelId) return res.status(400).json({ error: 'modelId required' });
var axios = require('axios');
var started = Date.now();
var imgResp = await axios.post(gatewayUrl('/images/generations'), {
model: modelId,
prompt: 'A simple child-friendly icon of lungs and an inhaler, flat medical illustration, no text',
size: '1024x1024'
}, { headers: getLiteLLMHeaders('application/json'), timeout: 120000 });
var item = imgResp.data && imgResp.data.data && imgResp.data.data[0] ? imgResp.data.data[0] : {};
res.json({
success: true,
model: modelId,
duration: Date.now() - started,
imageUrl: item.url || null,
base64: item.b64_json || null
});
} catch (e) {
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 500) : e.message;
res.json({ success: false, error: detail });
}
});
// ── GET TTS provider status, voice list, and DB overrides ────────────────
router.get('/config/tts', async function(req, res) {
try {
var { isGoogleTTSConfigured } = require('../utils/ttsGoogle');
var envProvider = getTTSEnvProvider();
var activeProvider = getTTSProvider();
var dbVoice = await db.getSetting('tts.voice') || '';
var dbModel = await db.getSetting('tts.model') || '';
var envVoice = process.env.GOOGLE_TTS_VOICE || process.env.LITELLM_TTS_VOICE || '';
var envModel = process.env.LITELLM_TTS_MODEL || '';
res.json({
success: true,
provider: activeProvider,
envProvider: envProvider,
currentVoice: dbVoice || envVoice,
currentModel: dbModel || envModel,
dbVoice: dbVoice,
dbModel: dbModel,
envVoice: envVoice,
envModel: envModel,
configured: {
google: false,
litellm: !!process.env.LITELLM_API_BASE,
elevenlabs: false
},
voices: getTTSVoiceLists()
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET discover TTS voices from provider ────────────────────────────────
router.get('/config/tts/discover', async function(req, res) {
try {
var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios');
var discovered = [];
var provider = getTTSProvider();
if (provider === 'google') {
try {
var GoogleAuth = require('google-auth-library').GoogleAuth;
var auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
var client = await auth.getClient();
var tokenData = await client.getAccessToken();
var gResp = await axios.get('https://texttospeech.googleapis.com/v1/voices?languageCode=en', {
headers: { 'Authorization': 'Bearer ' + tokenData.token }, timeout: 10000
});
if (gResp.data && gResp.data.voices) {
gResp.data.voices.forEach(function(v) {
discovered.push({ id: v.name, name: v.name + ' (' + (v.ssmlGender || 'NEUTRAL') + ')', gender: v.ssmlGender, source: 'google-api' });
});
}
} catch (e) {
logger.warn('Google TTS voice API failed: ' + e.message + ' — using built-in list');
GOOGLE_TTS_VOICES.forEach(function(v) {
discovered.push({ id: v, name: v, source: 'google-builtin' });
});
}
}
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 });
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) {
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, source: 'elevenlabs-api' });
});
}
} catch (e) { logger.warn('ElevenLabs voice list failed: ' + e.message); }
}
if (search) {
discovered = discovered.filter(function(d) {
return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, provider: provider, voices: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test TTS — returns base64 audio ─────────────────────────────────
router.post('/config/tts/test', async function(req, res) {
try {
var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500);
var voice = req.body.voice;
var { synthesizeWithGoogleTTS } = require('../utils/ttsGoogle');
var axios = require('axios');
var provider = getTTSProvider();
if (provider === 'none') return res.json({ success: false, error: 'No TTS provider configured' });
var buffer, usedVoice = voice;
if (provider === 'google') {
usedVoice = voice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
buffer = await synthesizeWithGoogleTTS(text, usedVoice);
} else if (provider === 'litellm') {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var db = require('../db/database');
var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || '';
if (!adminModel && adminVoice) {
try {
var liteLLMBase = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
var modelInfoResp = await axios.get(liteLLMBase + '/model/info', { headers: getLiteLLMHeaders(), timeout: 10000 });
if (getLiteLLMTTSModels(modelInfoResp.data && modelInfoResp.data.data).indexOf(adminVoice) !== -1) {
adminModel = adminVoice;
adminVoice = '';
}
} catch (e) { logger.warn('LiteLLM TTS mode lookup failed: ' + e.message); }
}
var resolvedVoice = voice || adminVoice || '';
var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1';
var ttsModel = rawModel;
usedVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
// base URL handled by gatewayUrl() helper
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
{ model: ttsModel, voice: usedVoice, input: text },
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
);
buffer = Buffer.from(ttsResp.data);
} else if (provider === 'elevenlabs') {
if (!process.env.ELEVENLABS_API_KEY) return res.json({ success: false, error: 'ElevenLabs not configured' });
usedVoice = voice || 'pNInz6obpgDQGcFmaJgB';
var elResp = await axios({
method: 'POST',
url: 'https://api.elevenlabs.io/v1/text-to-speech/' + usedVoice,
headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY, 'Content-Type': 'application/json' },
data: { text: text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.75 } },
responseType: 'arraybuffer'
});
buffer = Buffer.from(elResp.data);
}
res.json({ success: true, audio: buffer.toString('base64'), provider: provider, voice: usedVoice });
} catch (e) {
var detail = e.response && e.response.data
? (Buffer.isBuffer(e.response.data) ? e.response.data.toString('utf8').substring(0, 300) : JSON.stringify(e.response.data).substring(0, 300))
: e.message;
res.json({ success: false, error: detail });
}
});
// ── GET STT provider status and model list ─────────────────────────────────
router.get('/config/stt', async function(req, res) {
try {
var envProvider = process.env.TRANSCRIBE_PROVIDER || 'auto';
var configured = getSTTDependencies();
var activeProvider = getSTTProvider(configured);
var dbModel = await db.getSetting('stt.model') || '';
var envModel = process.env.GOOGLE_STT_MODEL || process.env.LITELLM_STT_MODEL || '';
res.json({
success: true,
provider: activeProvider,
envProvider: envProvider,
currentModel: dbModel || envModel,
dbModel: dbModel,
envModel: envModel,
configured: configured,
models: getSTTModelLists()
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET discover STT models from provider ────────────────────────────────
router.get('/config/stt/discover', async function(req, res) {
try {
var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios');
var { whisperClient } = require('../utils/ai');
var discovered = [];
var provider = getSTTProvider();
if (provider === 'google') {
['gemini-2.5-flash','gemini-2.0-flash','gemini-2.0-flash-lite','gemini-1.5-pro','gemini-1.5-flash'].forEach(function(m) {
discovered.push({ id: m, name: m, source: 'google-builtin' });
});
}
if (provider === 'aws') {
['standard','medical'].forEach(function(m) {
discovered.push({ id: m, name: 'AWS Transcribe ' + m.charAt(0).toUpperCase() + m.slice(1), source: 'aws-builtin' });
});
}
if (provider === 'openai' && whisperClient) {
try {
var oModels = await whisperClient.models.list();
oModels.data.forEach(function(m) {
if (m.id.toLowerCase().indexOf('whisper') !== -1 || m.id.toLowerCase().indexOf('transcri') !== -1) {
discovered.push({ id: m.id, name: m.id, source: 'openai-api' });
}
});
} catch(e) {
discovered.push({ id: 'whisper-1', name: 'whisper-1', source: 'openai-builtin' });
}
}
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
try {
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') {
var localModel = process.env.WHISPER_MODEL_SIZE || 'base';
discovered.push({ id: localModel, name: 'Local Whisper: ' + localModel + ' (configured)', source: 'local-env' });
['tiny','base','small','medium','large'].forEach(function(m) {
if (m !== localModel) discovered.push({ id: m, name: 'Local Whisper: ' + m, source: 'local-builtin' });
});
}
if (search) {
discovered = discovered.filter(function(d) {
return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, provider: provider, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test STT — accepts base64 audio, returns transcription ───────────
router.post('/config/stt/test', async function(req, res) {
try {
var { audioBase64, mimeType } = req.body;
if (!audioBase64) return res.json({ success: false, error: 'audioBase64 required' });
var audioBuffer = Buffer.from(audioBase64, 'base64');
var mime = mimeType || 'audio/webm';
var { transcribeWithGemini } = require('../utils/transcribeGoogle');
var { transcribeWithAWS } = require('../utils/transcribeAWS');
var { transcribeWithLocal } = require('../utils/transcribeLocal');
var { whisperClient } = require('../utils/ai');
var axios = require('axios');
var provider = getSTTProvider();
if (provider === 'none') return res.json({ success: false, error: 'No STT provider configured' });
var start = Date.now();
var text = '';
if (provider === 'google') {
var model = process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash';
text = await transcribeWithGemini(audioBuffer, mime, model);
} else if (provider === 'aws') {
text = await transcribeWithAWS(audioBuffer, mime);
} else if (provider === 'local') {
text = await transcribeWithLocal(audioBuffer, mime);
} else if (provider === 'litellm') {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var db = require('../db/database');
var adminSttModel = await db.getSetting('stt.model') || '';
var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
var isTranscriptionModel = false;
try {
var liteLLMBase = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
var modelInfoResp = await axios.get(liteLLMBase + '/model/info', { headers: getLiteLLMHeaders(), timeout: 10000 });
isTranscriptionModel = getLiteLLMSTTModels(modelInfoResp.data && modelInfoResp.data.data).indexOf(sttModel) !== -1;
} catch (e) { logger.warn('LiteLLM STT mode lookup failed: ' + e.message); }
if (isTranscriptionModel) {
var ext = mime.split('/')[1] || 'webm';
var file = new File([audioBuffer], 'audio.' + ext, { type: mime });
var form = new FormData();
form.append('file', file);
form.append('model', sttModel);
var sttResp = await fetch(gatewayUrl('/audio/transcriptions'), {
method: 'POST', headers: getLiteLLMHeaders(), body: form
}).then(function(r) { return r.json().then(function(d) { return { data: d }; }); });
text = sttResp.data && sttResp.data.text ? sttResp.data.text : '';
} else {
var sttResp = await axios.post(gatewayUrl('/chat/completions'), {
model: sttModel,
messages: [{ role: 'user', content: [
{ type: 'input_audio', input_audio: { data: audioBase64, format: mime.split('/')[1] || 'webm' } },
{ type: 'text', text: 'Transcribe this audio. Output the spoken words only.' }
]}]
}, { headers: getLiteLLMHeaders('application/json'), timeout: 60000 });
if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) {
text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || '';
}
}
} else if (provider === 'openai') {
if (!whisperClient) return res.json({ success: false, error: 'Whisper not configured' });
var file = new File([audioBuffer], 'audio.webm', { type: mime });
var wResult = await whisperClient.audio.transcriptions.create({
file: file, model: 'whisper-1', language: 'en', response_format: 'text'
});
text = typeof wResult === 'string' ? wResult : (wResult.text || '');
}
res.json({ success: true, text: text.trim(), provider: provider, duration: Date.now() - start });
} catch (e) {
res.json({ success: false, error: e.message });
}
});
// ── GET embedding model config ────────────────────────────────────────────
router.get('/config/embeddings', async function(req, res) {
try {
var { isEmbeddingsAvailable, DEFAULT_MODEL, DEFAULT_DIMS } = require('../utils/embeddings');
var dbModel = await db.getSetting('embeddings.model') || '';
var dbDims = await db.getSetting('embeddings.dimensions') || '';
var envModel = process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
var envDims = parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
var provider = 'none';
if (process.env.LITELLM_API_BASE) provider = 'litellm';
else if (process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.VERTEX_PROJECT || process.env.GOOGLE_VERTEX_PROJECT) provider = 'vertex';
else if (process.env.OPENAI_API_KEY) provider = 'openai';
res.json({
success: true,
provider: provider,
configured: isEmbeddingsAvailable(),
currentModel: dbModel || envModel,
currentDimensions: dbDims ? parseInt(dbDims) : envDims,
dbModel: dbModel,
dbDimensions: dbDims,
envModel: envModel,
envDimensions: envDims,
models: [
{ id: 'vertex_ai/text-embedding-005', name: 'Vertex text-embedding-005', dims: 768, tag: 'RECOMMENDED' },
{ id: 'vertex_ai/gemini-embedding-001', name: 'Vertex gemini-embedding-001', dims: 3072, tag: 'HIGH-DIM' },
{ id: 'vertex_ai/text-multilingual-embedding-002', name: 'Vertex multilingual-embedding-002', dims: 768, tag: 'MULTILINGUAL' },
{ id: 'text-embedding-3-small', name: 'OpenAI text-embedding-3-small', dims: 1536, tag: 'OPENAI' },
{ id: 'text-embedding-3-large', name: 'OpenAI text-embedding-3-large', dims: 3072, tag: 'OPENAI' },
{ id: 'text-embedding-ada-002', name: 'OpenAI text-embedding-ada-002', dims: 1536, tag: 'OPENAI' }
]
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET discover embedding models from provider ───────────────────────────
router.get('/config/embeddings/discover', async function(req, res) {
try {
var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios');
var discovered = [];
var provider = 'none';
if (process.env.LITELLM_API_BASE) provider = 'litellm';
else if (process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.VERTEX_PROJECT || process.env.GOOGLE_VERTEX_PROJECT) provider = 'vertex';
else if (process.env.OPENAI_API_KEY) provider = 'openai';
var knownVertex = [
{ id: 'vertex_ai/text-embedding-005', name: 'Vertex text-embedding-005', dims: 768 },
{ id: 'vertex_ai/gemini-embedding-001', name: 'Vertex gemini-embedding-001', dims: 3072 },
{ id: 'vertex_ai/text-multilingual-embedding-002', name: 'Vertex multilingual-embedding-002', dims: 768 },
{ id: 'text-embedding-005', name: 'text-embedding-005 (direct)', dims: 768 }
];
if (provider === 'litellm') {
try {
var liteLLMBase = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
var eResp = await axios.get(liteLLMBase + '/model/info', { headers: getLiteLLMHeaders(), timeout: 10000 });
getLiteLLMEmbeddingModels(eResp.data && eResp.data.data).forEach(function(m) {
discovered.push({ id: m.id, name: m.name, dims: m.dims, source: 'gateway-api' });
});
} catch(e) { logger.warn('LiteLLM embedding metadata discovery failed: ' + e.message); }
if (discovered.length === 0) {
knownVertex.forEach(function(m) {
if (!discovered.find(function(d) { return d.id === m.id; })) {
discovered.push(Object.assign({ source: 'builtin' }, m));
}
});
}
}
if (provider === 'vertex') {
knownVertex.forEach(function(m) {
discovered.push(Object.assign({ source: 'vertex-builtin' }, m));
});
}
if (provider === 'openai') {
try {
var OpenAI = require('openai').OpenAI;
var oc = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
var oMods = await oc.models.list();
oMods.data.forEach(function(m) {
if (m.id.toLowerCase().indexOf('embedding') !== -1) {
var dims = m.id === 'text-embedding-3-large' ? 3072 : m.id === 'text-embedding-3-small' ? 1536 : 1536;
discovered.push({ id: m.id, name: m.id, dims: dims, source: 'openai-api' });
}
});
} catch(e) {
[{ id: 'text-embedding-3-small', dims: 1536 }, { id: 'text-embedding-3-large', dims: 3072 }, { id: 'text-embedding-ada-002', dims: 1536 }].forEach(function(m) {
discovered.push({ id: m.id, name: m.id, dims: m.dims, source: 'openai-builtin' });
});
}
}
if (search) {
discovered = discovered.filter(function(d) {
return d.id.toLowerCase().indexOf(search) !== -1 || String(d.name).toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, provider: provider, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test embedding ───────────────────────────────────────────────────
router.post('/config/embeddings/test', async function(req, res) {
try {
var text = (req.body.text || 'Pediatric patient with fever').substring(0, 500);
var { generateEmbedding, DEFAULT_MODEL } = require('../utils/embeddings');
var db = require('../db/database');
var dbModel = await db.getSetting('embeddings.model') || '';
var start = Date.now();
var vector = await generateEmbedding(text);
var dims = Array.isArray(vector) ? vector.length : 0;
var sample = Array.isArray(vector) ? vector.slice(0, 8).map(function(v) { return v.toFixed(4); }) : [];
res.json({
success: true,
dimensions: dims,
sample: sample,
model: dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL,
duration: Date.now() - start
});
} catch (e) {
res.json({ success: false, error: e.message });
}
});
// ============================================================
// WILDCARD CONFIG — Must come AFTER all specific model routes above
// :key(*) matches slashes, so it would intercept /config/models/toggle
// and /config/models/default if registered before them.
// ============================================================
// ── PUT update a single config entry ──────────────────────────────────────
router.put('/config/:key(*)', async function(req, res) {
try {
var key = req.params.key;
var value = req.body.value;
if (value === undefined || value === null) {
return res.status(400).json({ error: 'value is required' });
}
// Security: only allow known key prefixes
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.'];
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
if (!isAllowed) {
return res.status(400).json({ error: 'Unknown config key' });
}
await db.setSetting(key, String(value));
// Update in-memory prompt immediately if it's a prompt key
if (key.startsWith('prompt.')) {
var promptKey = key.replace('prompt.', '');
PROMPTS.updatePrompt(promptKey, String(value));
}
logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
module.exports = router;