pediatric-ai-scribe-v3/src/routes/adminConfig.js
Daniel db83255c58 feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.

Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
  private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
  saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
  audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
  downgrade a real session; if the setting cannot be read, authentication is
  required.
- Actions needing an account are hidden rather than offered and refused.

Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:12:36 +02:00

882 lines
42 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 promptCatalog = require('../utils/promptCatalog');
var promptRevisions = require('../utils/promptRevisions');
var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation');
var logger = require('../utils/logger');
var { gatewayUrl } = 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);
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) : '';
}
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 });
} 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(
'<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' }); }
});
// ── 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, 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 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));
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, 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 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 ───────────────────────────────────────────────────
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 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', '');
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 } = 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' }); }
});
// ── 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', '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' });
}
// 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' });
}
}
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.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' }); }
});
module.exports = router;