// ============================================================ // 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 promptCatalog = require('../utils/promptCatalog'); var promptRevisions = require('../utils/promptRevisions'); var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation'); var logger = require('../utils/logger'); var { gatewayUrl, serverError } = require('../utils/errors'); var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm'); var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); var { getLiteLLMEmbeddingModels } = require('../utils/embeddings'); router.use(authMiddleware); var lockdown = require('../utils/adminLockdown'); // One gate rather than a check in every write route, because that list grows // and a new route added later would quietly miss it. Reads always pass — // lockdown hides nothing, it only refuses changes. // // Under lockdown a write is refused unless it is day-to-day operation: // - invitations, which is running the service, not configuring it; // - the "test" endpoints, which send a probe and persist nothing; // - /config/:key, which decides per key — some keys stay editable, and that // route applies lockdown.isLocked() itself. // Everything else — model policy, SMTP, prompts, resets — is configuration. var OPERATIONAL_WRITE = /^\/invites(\/|$)|\/test(-email)?$|^\/config\/[^/]+$/; router.use(function(req, res, next) { if (!lockdown.enabled() || req.method === 'GET' || req.method === 'HEAD') return next(); if (OPERATIONAL_WRITE.test(req.path)) return next(); return res.status(403).json({ error: lockdown.refusal(req.path.replace(/^\/config\//, '')) }); }); function liteLLMBaseUrl() { return (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, ''); } function searchTerms(search) { return String(search || '').toLowerCase().split(/[\s,]+/).map(function(term) { return term.trim(); }).filter(Boolean); } function matchesDiscoverySearch(item, search, extra) { var terms = searchTerms(search); if (terms.length === 0) return true; var haystack = [item.id, item.name, item.source, item.kind, item.mode, item.capability, item.dims, extra].filter(Boolean).join(' ').toLowerCase(); return terms.some(function(term) { return haystack.indexOf(term) !== -1; }); } function liteLLMModelId(model) { return model && (model.model_name || model.id) ? (model.model_name || model.id) : ''; } function liteLLMModelMode(model) { return model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : ''; } // Can this model be shown a picture? Three answers, not two: true, false, and // "the gateway does not say". Only an explicit false is actionable — a model // the gateway has no metadata for is not thereby proven blind, and refusing // those would block most of the roster over missing metadata rather than over // a real incapability. async function liteLLMVisionSupport(modelId) { if (!modelId || !process.env.LITELLM_API_BASE) return null; try { var axios = require('axios'); var resp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 }); var models = (resp.data && resp.data.data) || []; for (var i = 0; i < models.length; i++) { if (liteLLMModelId(models[i]) !== modelId) continue; var info = models[i].model_info || {}; return typeof info.supports_vision === 'boolean' ? info.supports_vision : null; } return null; } catch (e) { // The gateway being unreachable is not evidence about the model. return null; } } async function probeLiteLLMEmbeddingDimensions(modelId) { try { var axios = require('axios'); var resp = await axios.post(gatewayUrl('/embeddings'), { model: modelId, input: 'dimension probe' }, { headers: getLiteLLMHeaders('application/json'), timeout: 30000 }); var embedding = resp.data && resp.data.data && resp.data.data[0] && resp.data.data[0].embedding; return Array.isArray(embedding) ? embedding.length : '?'; } catch (e) { logger.warn('LiteLLM embedding dimension probe failed for ' + modelId + ': ' + e.message); return '?'; } } // ── 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 budget = conversationBudget(process.env); 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, conversationBudget: budget, lockdown: lockdown.state() }); } catch (e) { res.status(e.statusCode || 500).json({ error: 'Request failed' }); } }); // ── Clinical assistant starter prompt pool ───────────────────────────────── router.get('/clinical-assistant/prompt-pool', async function(req, res) { try { var clinicalAssistant = require('./clinicalAssistant'); var meta = await clinicalAssistant.getPromptPoolMeta(); var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20); res.json({ success: true, meta: meta || null, snapshots: snapshots }); } catch (e) { res.status(500).json({ error: e.message || 'Prompt pool status failed' }); } }); router.get('/clinical-assistant/prompt-pool/snapshots', async function(req, res) { try { var clinicalAssistant = require('./clinicalAssistant'); var snapshots = await clinicalAssistant.getPromptPoolSnapshots(req.query.limit || 20); res.json({ success: true, snapshots: snapshots }); } catch (e) { res.status(500).json({ error: e.message || 'Prompt pool snapshot listing failed' }); } }); router.post('/clinical-assistant/prompt-pool/regenerate', async function(req, res) { try { var clinicalAssistant = require('./clinicalAssistant'); var examples = await clinicalAssistant.refreshPromptPool(true, req.user && req.user.id); var meta = await clinicalAssistant.getPromptPoolMeta(); var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20); res.json({ success: true, count: examples.length, meta: meta || null, snapshots: snapshots }); } catch (e) { res.status(500).json({ error: e.message || 'Prompt pool regeneration failed' }); } }); router.post('/clinical-assistant/prompt-pool/restore', async function(req, res) { try { var clinicalAssistant = require('./clinicalAssistant'); var id = Number(req.body && req.body.id); if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Snapshot id is required' }); var payload = await clinicalAssistant.restorePromptPoolSnapshot(id, req.user && req.user.id); if (!payload) return res.status(404).json({ error: 'Snapshot not found' }); var meta = await clinicalAssistant.getPromptPoolMeta(); var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20); res.json({ success: true, count: Array.isArray(payload.examples) ? payload.examples.length : 0, meta: meta || null, snapshots: snapshots }); } catch (e) { res.status(500).json({ error: e.message || 'Prompt pool restore 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( '

Test Email

' + '

' + bodyText.replace(/\n/g, '
') + '

' + 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' }); } }); // ── Global prompt catalogue and immutable revision history ───────────────── router.get('/config/prompts', async function(req, res) { try { res.json({ success: true, prompts: await promptRevisions.list(db) }); } catch (e) { promptRevisions.respondError(res, e); } }); router.get('/config/prompts/:key/history', async function(req, res) { try { res.json(Object.assign({ success: true }, await promptRevisions.history(db, req.params.key, req.query.limit))); } catch (e) { promptRevisions.respondError(res, e); } }); router.get('/config/prompts/:key/revisions/:id', async function(req, res) { try { res.json({ success: true, revision: await promptRevisions.read(db, req.params.key, req.params.id) }); } catch (e) { promptRevisions.respondError(res, e); } }); async function changePrompt(req, res, action, key) { try { var result = await promptRevisions.mutate(db, key, { action: action, value: req.body.value, expectedRevision: req.body.expectedRevision, revisionId: req.body.revisionId, actor: req.user.id }); logger.audit(req.user.id, 'admin_prompt_' + action, 'Updated global prompt: ' + key, req, { category: 'admin' }); res.json(Object.assign({ success: true }, result)); } catch (e) { promptRevisions.respondError(res, e); } } router.post('/config/prompts/:key/reset', function(req, res) { return changePrompt(req, res, 'reset', req.params.key); }); router.post('/config/prompts/:key/restore', function(req, res) { return changePrompt(req, res, 'restore', req.params.key); }); // ── 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, activeProvider } = require('../utils/models'); var providerModels; switch (activeProvider) { case 'bedrock': providerModels = BEDROCK_MODELS; break; case 'azure': providerModels = AZURE_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 require('../utils/models').getEffectiveDefaultModel(db); var disabled, custom; disabled = JSON.parse(disabledRaw); if (!Array.isArray(disabled)) throw new Error('Invalid disabled model settings'); custom = JSON.parse(customRaw); if (!Array.isArray(custom)) throw new Error('Invalid custom model settings'); custom = custom.map(function(m) { return Object.assign({}, m, { enabled: !disabled.includes(m.id) }); }); 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 (typeof modelId !== 'string' || !modelId.trim() || typeof enabled !== 'boolean') return res.status(400).json({ error: 'modelId and boolean enabled required' }); var modelPolicy = require('../utils/models'); var roster = modelPolicy.getAvailableModels().concat(JSON.parse(await db.getSetting('models.custom') || '[]')); if (!roster.some(function(m) { return m.id === modelId; })) return res.status(400).json({ error: 'Unknown model' }); var disabledRaw = await db.getSetting('models.disabled') || '[]'; var disabled; disabled = JSON.parse(disabledRaw); if (!Array.isArray(disabled)) throw new Error('Invalid disabled model settings'); 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)); // Disabling is as final as removing, from a caller's point of view: the // model stops being selectable, so it stops being allowed. if (!enabled) await forgetModelEverywhere(modelId); await require('../utils/models').reconcileDefaultModel(db); 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 (typeof modelId !== 'string' || !modelId.trim()) return res.status(400).json({ error: 'modelId required' }); var models = await require('../utils/models').getAvailableModelsWithOverrides(db); if (!models.some(function(m) { return m.id === modelId.trim(); })) return res.status(400).json({ error: 'Default model must be enabled' }); await db.setSetting('models.default', modelId.trim()); 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 } = 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 } = require('../utils/models'); var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_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 customRaw = await db.getSetting('models.custom') || '[]'; var custom; custom = JSON.parse(customRaw); if (!Array.isArray(custom)) throw new Error('Invalid custom model settings'); 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) }); await db.setSetting('models.custom', JSON.stringify(custom)); 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 ─────────────────────────────────────────────────── // A model that leaves the roster must leave every list that names it. Otherwise // clinical_assistant.allowed_models keeps offering an id the gateway no longer // has, and the only sign is a failed request at the moment someone asks a // clinical question. The lists are advisory copies of the roster; the roster is // the fact. async function forgetModelEverywhere(ids) { var gone = (Array.isArray(ids) ? ids : [ids]).filter(Boolean); if (!gone.length) return; for (var key of ['clinical_assistant.allowed_models', 'clinical_assistant.allowed_image_models', 'clinical_assistant.image_model_roster']) { var current = String(await db.getSetting(key, '') || ''); if (!current) continue; var kept = current.split(',').map(function (id) { return id.trim(); }) .filter(function (id) { return id && gone.indexOf(id) === -1; }); if (kept.join(',') !== current) await db.setSetting(key, kept.join(',')); } // A default that no longer exists is reconciled on the next read, but // clearing it here means the admin panel never shows it as current. var current = String(await db.getSetting('models.default', '') || ''); if (current && gone.indexOf(current) !== -1) await db.setSetting('models.default', ''); } 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; custom = JSON.parse(customRaw); if (!Array.isArray(custom)) throw new Error('Invalid custom model settings'); custom = custom.filter(function(m) { return m.id !== modelId; }); await db.setSetting('models.custom', JSON.stringify(custom)); await forgetModelEverywhere(modelId); await require('../utils/models').reconcileDefaultModel(db); 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', ''); // Nothing is on the roster any more, so nothing may remain allowed. for (var key of ['clinical_assistant.allowed_models', 'clinical_assistant.allowed_image_models', 'clinical_assistant.image_model_roster']) { await db.setSetting(key, ''); } 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 ───────────────────────────────── // Modes that are definitely not a chat model. The filter excludes what the // gateway says is something else, rather than requiring it to say 'chat': a // model with no metadata is unknown, not disqualified, and requiring a positive // 'chat' would hide every model the gateway has no mode for. var NON_CHAT_MODES = ['image_generation', 'rerank', 'audio_speech', 'audio_transcription', 'embedding', 'moderation']; async function nonChatModelIds() { if (!process.env.LITELLM_API_BASE) return new Set(); try { var axios = require('axios'); var resp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 }); var out = new Set(); ((resp.data && resp.data.data) || []).forEach(function (m) { var id = liteLLMModelId(m); if (id && NON_CHAT_MODES.indexOf(liteLLMModelMode(m)) !== -1) out.add(id); }); return out; } catch (e) { // No metadata means no filtering, which is what it did before. return new Set(); } } router.get('/config/models/discover', async function(req, res) { try { var { discoverModels } = require('../utils/ai'); var discovered = await discoverModels(); // /v1/models carries no mode, so rerankers, image and speech models all // arrived in a list meant for choosing a chat model. /model/info does carry // it, which is where every other discovery endpoint already looks. var exclude = await nonChatModelIds(); discovered = discovered.filter(function (m) { return !exclude.has(m.id); }); 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 } = 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 customRaw = await db.getSetting('models.custom') || '[]'; var custom; custom = JSON.parse(customRaw); if (!Array.isArray(custom)) throw new Error('Invalid custom model settings'); custom = custom.filter(function(m) { return m.id !== trimmedId; }); custom.push({ id: trimmedId, name: name.trim().substring(0, 100) }); await db.setSetting('models.custom', JSON.stringify(custom)); 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(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 15000 }); var models = (resp.data && resp.data.data) || []; models.forEach(function(m) { if (liteLLMModelMode(m) === 'image_generation') { var id = liteLLMModelId(m); if (id) discovered.push({ id: id, name: id, source: 'gateway-api', mode: 'image_generation', capability: 'image' }); } }); } var search = (req.query.q || '').toLowerCase().trim(); if (search) discovered = discovered.filter(function(m) { return matchesDiscoverySearch(m, search, 'image generation'); }); 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 envProvider = getTTSEnvProvider(); var activeProvider = getTTSProvider(); var dbVoice = await db.getSetting('tts.voice') || ''; var dbModel = await db.getSetting('tts.model') || ''; var envVoice = process.env.LITELLM_TTS_VOICE || ''; var envModel = process.env.LITELLM_TTS_MODEL || ''; var currentModel = dbModel || envModel; var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice }); var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) { return isLiteLLMTTSVoiceCompatible(currentModel, voice); }) || ''; res.json({ success: true, provider: activeProvider, envProvider: envProvider, currentVoice: currentVoice, currentModel: currentModel, dbVoice: dbVoice, dbModel: dbModel, envVoice: envVoice, envModel: envModel, configured: { litellm: !!process.env.LITELLM_API_BASE }, voices: { litellm: voices } }); } 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 === '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 lResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), 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 (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 axios = require('axios'); var provider = getTTSProvider(); if (provider === 'none') return res.json({ success: false, error: 'No TTS provider configured' }); if (provider !== 'litellm') return res.json({ success: false, error: 'TTS is configured for LiteLLM only' }); if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' }); var adminModel = await db.getSetting('tts.model') || ''; var adminVoice = await db.getSetting('tts.voice') || ''; var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || ''; var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice }); var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) { return isLiteLLMTTSVoiceCompatible(ttsModel, candidate); }) || ''; if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' }); var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel)); var ttsResp = await axios.post(gatewayUrl('/audio/speech'), payload, { headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 } ); var buffer = Buffer.from(ttsResp.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.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' }); } }); // ── Citation quality ───────────────────────────────────────────────────── // Answers whose citations pointed at nothing. Read-only, and it survives // lockdown because it is quality tracking, not configuration. router.get('/citation-audit', async function(req, res) { try { var rows = await db.all( "SELECT a.id, a.question, a.cited_count, a.source_count, a.unverifiable, " + " a.source_titles, a.created_at, u.email AS user_email " + "FROM citation_audit a LEFT JOIN users u ON u.id = a.user_id " + "WHERE a.expires_at > NOW() ORDER BY a.created_at DESC LIMIT 100", [] ); var totals = await db.get( "SELECT COUNT(*)::int AS answers, COALESCE(SUM(array_length(unverifiable, 1)), 0)::int AS markers " + "FROM citation_audit WHERE expires_at > NOW()", [] ); res.json({ success: true, rows: rows, totals: totals || { answers: 0, markers: 0 } }); } catch (e) { return serverError(res, 'Citation audit', e, 'Could not read citation quality'); } }); // ── Registration invites ───────────────────────────────────────────────── // A code is shown once, at creation. Only its hash and last four characters // are stored, so this endpoint is the only time it can be read. var invites = require('../utils/registrationInvites'); router.get('/invites', async function(req, res) { try { // The code itself, decrypted for display, so an invitation can be copied // again rather than only at the moment it was made. The cipher never leaves // the server. A row created before codes were kept simply has no code, and // its four-character hint is all there is to show. var rows = (await invites.list()).map(function (row) { var code = invites.decryptCode(row.code_cipher); delete row.code_cipher; return Object.assign(row, { code: code }); }); res.json({ success: true, invites: rows, inviteOnly: await invites.inviteOnly(), lockdown: lockdown.state() }); } catch (e) { return serverError(res, 'Invites list', e, 'Could not list invitations'); } }); router.post('/invites', async function(req, res) { try { var created = await invites.create(req.user.id, { days: req.body.days, note: req.body.note }); logger.audit(req.user.id, 'invite_create', 'Created a registration invite valid ' + created.days + ' days', req, { category: 'admin' }); res.json({ success: true, code: created.code, days: created.days }); } catch (e) { return serverError(res, 'Invite create', e, 'Could not create an invitation'); } }); router.post('/invites/:id/revoke', async function(req, res) { try { var done = await invites.revoke(req.params.id, req.user.id); if (!done) return res.status(400).json({ error: 'Only an unused, unrevoked invitation can be revoked' }); logger.audit(req.user.id, 'invite_revoke', 'Revoked invitation ' + req.params.id, req, { category: 'admin' }); res.json({ success: true }); } catch (e) { return serverError(res, 'Invite revoke', e, 'Could not revoke the invitation'); } }); // Clearing away spent invitations — used, or expired without being used. A code // that could still be redeemed is never deleted: that would take it off the list // without taking it out of anybody's inbox, leaving nothing to say who held it. // Revoke is what stops a live code, and it leaves the row behind, marked. router.delete('/invites/spent', async function(req, res) { try { var removed = await invites.removeSpent(); logger.audit(req.user.id, 'invite_delete', 'Cleared ' + removed + ' spent invitations', req, { category: 'admin' }); res.json({ success: true, removed: removed }); } catch (e) { return serverError(res, 'Invite clear', e, 'Could not clear spent invitations'); } }); router.delete('/invites/:id', async function(req, res) { try { if (!await invites.remove(req.params.id)) { // Said plainly rather than as "not found": the row is very likely there, // and the reason it cannot go is worth knowing. return res.status(409).json({ error: 'That invitation can still be used. Revoke it instead.' }); } logger.audit(req.user.id, 'invite_delete', 'Deleted invitation ' + req.params.id, req, { category: 'admin' }); res.json({ success: true }); } catch (e) { return serverError(res, 'Invite delete', e, 'Could not delete the invitation'); } }); // ── 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 discovered = []; var provider = getSTTProvider(); if (provider === 'litellm' && process.env.LITELLM_API_BASE) { try { var sResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), 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 (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 provider = getSTTProvider(); if (provider === 'none') return res.json({ success: false, error: 'No STT provider configured' }); if (provider !== 'litellm') return res.json({ success: false, error: 'STT is configured for LiteLLM only' }); if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' }); var start = Date.now(); var text = ''; var adminSttModel = await db.getSetting('stt.model') || ''; var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || ''; if (!sttModel) return res.json({ success: false, error: 'No LiteLLM STT model configured' }); 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 }); if (!sttResp.ok) throw new Error('LiteLLM transcription failed (HTTP ' + sttResp.status + ')'); var sttData = await sttResp.json(); if (!sttData || typeof sttData.text !== 'string') throw new Error('Invalid transcription response'); text = sttData.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'; 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: [] }); } 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'; if (provider === 'litellm') { try { var eResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 }); var embeddingModels = getLiteLLMEmbeddingModels(eResp.data && eResp.data.data); for (var i = 0; i < embeddingModels.length; i++) { var m = embeddingModels[i]; var dims = m.dims === '?' ? await probeLiteLLMEmbeddingDimensions(m.id) : m.dims; discovered.push({ id: m.id, name: m.name, dims: dims, source: 'gateway-api', mode: 'embedding', capability: 'embedding' }); } } catch(e) { logger.warn('LiteLLM embedding metadata discovery failed: ' + e.message); } } if (search) { discovered = discovered.filter(function(d) { return matchesDiscoverySearch(d, search, 'embedding vector'); }); } 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', 'registration_invite_only', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.', 'my_resources.']; var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); }); if (!isAllowed) { return res.status(400).json({ error: 'Unknown config key' }); } // Some settings stay editable under lockdown; the rest are refused here, // whatever the UI showed. if (lockdown.isLocked(key)) { return res.status(403).json({ error: lockdown.refusal(key) }); } // Model policy mutations must use the validated model endpoints. if (key.startsWith('models.')) return res.status(400).json({ error: 'Use the model configuration endpoints' }); if (key.startsWith('feature.') && !['true', 'false'].includes(String(value))) return res.status(400).json({ error: 'Feature value must be true or false' }); if (key === 'clinical_assistant.conversation_chars' && value !== '' && value != null) { // One validator for the whole budget: parseInt would have accepted // "120000abc". Empty stays legal and means "use the environment". try { conversationLimit(value); } catch (_) { return res.status(400).json({ error: 'Conversation budget must be an integer between 1000 and 1000000 UTF-16 code units, or empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS' }); } } // The slide reviewer is shown rendered images of the deck. A text-only model // cannot do that job: it would be sent pictures and fail at request time, on // every generation, with the administrator having had no warning at the one // moment they could have chosen differently. Refused only when the gateway // states supports_vision === false; an unknown is left to the administrator, // which is how it worked before there was any check at all. if ((key === 'my_resources.review_model' || key === 'clinical_assistant.vision_model') && String(value).trim()) { var canSee = await liteLLMVisionSupport(String(value).trim()); if (canSee === false) { return res.status(400).json({ error: String(value).trim() + ' is a text-only model, so it cannot be shown ' + 'an image. Choose a model the gateway reports as vision-capable.' }); } } if (key === 'clinical_assistant.preview_enabled' && !['true', 'false'].includes(String(value))) { return res.status(400).json({ error: 'Preview mode must be true or false' }); } if (key === 'clinical_assistant.show_sources' && !['true', 'false'].includes(String(value))) { return res.status(400).json({ error: 'Show sources must be true or false' }); } if (key === 'registration_invite_only' && !['true', 'false'].includes(String(value))) { return res.status(400).json({ error: 'Invite-only must be true or false' }); } // The admin's image-model roster: comma-separated gateway model ids. if (key === 'clinical_assistant.image_model_roster') { var rosterIds = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean); if (rosterIds.length > 100 || rosterIds.some(function(id) { return id.length > 200 || /[\s<>"'`]/.test(id); })) { return res.status(400).json({ error: 'Image model list must be up to 100 model ids' }); } } if (key.startsWith('prompt.') || promptCatalog.find(key)) { if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' }); return changePrompt(req, res, 'save', key); } await db.setSetting(key, 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' }); } }); // ── Web search ────────────────────────────────────────────── // Its own routes rather than the generic config setter, because the key must be // masked on read and preserved when the field is left blank — the same handling // the OIDC client secret gets. var WEBSEARCH_KEYS = ['websearch.enabled', 'websearch.provider', 'websearch.api_key', 'websearch.base_url', 'pubmed.enabled', 'pubmed.api_key', 'pubmed.contact_email']; router.get('/websearch', adminMiddleware, async function (req, res) { try { var out = {}; for (var i = 0; i < WEBSEARCH_KEYS.length; i++) { out[WEBSEARCH_KEYS[i]] = await db.getSetting(WEBSEARCH_KEYS[i], '') || ''; } // Never send the key back. Enough tail to recognise which one is set. ['websearch.api_key', 'pubmed.api_key'].forEach(function (k) { if (out[k]) out[k] = '••••••••' + out[k].slice(-4); }); res.json({ success: true, config: out }); } catch (err) { logger.error('GET /admin/websearch', err.message); res.status(500).json({ error: 'Could not load web search settings' }); } }); router.put('/websearch', adminMiddleware, async function (req, res) { try { var providers = require('../utils/webSearch').PROVIDERS; var provider = String(req.body.provider || 'tavily'); if (providers.indexOf(provider) === -1) return res.status(400).json({ error: 'Unknown provider' }); await db.setSetting('websearch.enabled', String(req.body.enabled) === 'true' ? 'true' : 'false'); await db.setSetting('websearch.provider', provider); await db.setSetting('websearch.base_url', String(req.body.baseUrl || '').trim().slice(0, 500)); // A blank field means "leave it alone", so editing the provider does not // silently wipe the key that was already working. await db.setSetting('pubmed.enabled', String(req.body.pubmedEnabled) === 'true' ? 'true' : 'false'); await db.setSetting('pubmed.contact_email', String(req.body.pubmedEmail || '').trim().slice(0, 200)); // A blank field means "leave it alone", so editing anything else does not // silently wipe a key that was already working. The mask can never be saved // back as a key. var key = String(req.body.apiKey || '').trim(); if (key && key.indexOf('•') === -1) await db.setSetting('websearch.api_key', key.slice(0, 400)); var pmKey = String(req.body.pubmedApiKey || '').trim(); if (pmKey && pmKey.indexOf('•') === -1) await db.setSetting('pubmed.api_key', pmKey.slice(0, 400)); res.json({ success: true }); } catch (err) { logger.error('PUT /admin/websearch', err.message); res.status(500).json({ error: 'Could not save web search settings' }); } }); router.post('/websearch/test', adminMiddleware, async function (req, res) { try { var query = String(req.body.query || 'paediatric bronchiolitis guideline'); // Both sources, so one press says which of them actually works. var web = await require('../utils/webSearch').search(query); var pubmed = await require('../utils/pubmedSearch').search(query); res.json({ success: !web.reason || !pubmed.reason, web: { provider: web.provider || null, count: web.results.length, reason: web.reason || null, sample: web.results.slice(0, 2).map(function (r) { return { title: r.title, url: r.url }; }) }, pubmed: { count: pubmed.results.length, reason: pubmed.reason || null, sample: pubmed.results.slice(0, 2).map(function (r) { return { title: r.title, pmid: r.pmid }; }) } }); } catch (err) { res.status(502).json({ success: false, reason: err.message }); } }); module.exports = router;