fix: auth/API logging to Loki, TTS voice auto-detection, STT ElevenLabs support

- Add logger.audit/access calls to auth route (login, login_failed,
  login_blocked, register, password_changed, 2fa_backup_code_used,
  2fa_backup_codes_regenerated) — these previously only wrote to DB
  via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
  ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
  (Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
  to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
  redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
  isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
  endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
This commit is contained in:
Daniel 2026-04-19 21:26:33 +02:00
parent b09276faf5
commit e5f7167b8d
6 changed files with 51 additions and 40 deletions

View file

@ -566,7 +566,7 @@ router.post('/config/tts/test', async function(req, res) {
if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; }
var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'];
var resolvedVoice = voice || adminVoice || '';
if (!adminModel && resolvedVoice) {
if (resolvedVoice) {
if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts';
else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3';
}
@ -770,7 +770,7 @@ router.post('/config/stt/test', async function(req, res) {
var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
// base URL handled by gatewayUrl() helper
var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel);
var isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/i.test(sttModel);
if (isTranscriptionModel) {
var ext = mime.split('/')[1] || 'webm';

View file

@ -11,6 +11,7 @@ const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
var logger = require('../utils/logger');
// Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent)
async function checkPwnedPassword(password) {
@ -229,6 +230,7 @@ router.post('/register', async (req, res) => {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
logger.audit(userId, 'register', role === 'admin' ? 'First user — auto admin' : 'standard user', req, { category: 'auth' });
notifyNewRegistration(email, name);
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
@ -332,6 +334,7 @@ router.post('/login', async (req, res) => {
}
if (!valid) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){});
logger.access(user.id, 'login_failed', req, false);
return res.status(401).json({ error: 'Invalid credentials' });
}
@ -343,6 +346,7 @@ router.post('/login', async (req, res) => {
if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){});
logger.access(user.id, 'login_blocked', req, false);
return res.status(401).json({ error: 'Invalid credentials' });
}
@ -361,11 +365,13 @@ router.post('/login', async (req, res) => {
if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' });
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){});
logger.audit(user.id, '2fa_backup_code_used', 'Backup code consumed at login', req, { category: 'auth' });
}
}
var token = signAuthToken(user.id, req);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
logger.access(user.id, 'login', req, true);
// Create session record
var sessionId = generateSessionId();
@ -468,6 +474,7 @@ router.post('/2fa/backup-codes', authMiddleware, async (req, res) => {
var hashes = await hashBackupCodes(codes);
await db.run('UPDATE users SET totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, '2fa_backup_codes_regenerated', req.ip]).catch(function(){});
logger.audit(req.user.id, '2fa_backup_codes_regenerated', 'Backup codes regenerated', req, { category: 'auth' });
// Return plaintext codes — this is the ONLY time they are visible.
res.json({ success: true, codes: codes, message: 'Save these somewhere safe — they cannot be shown again.' });
} catch (err) { console.error('[Auth] 2FA backup codes error:', err.message); res.status(500).json({ error: 'Request failed' }); }
@ -626,6 +633,7 @@ router.post('/change-password', authMiddleware, async (req, res) => {
}
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]);
logger.audit(req.user.id, 'password_changed', 'Password changed', req, { category: 'auth' });
notifyPasswordChanged(req.user.id);
var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' };

View file

@ -96,7 +96,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
var mimeType = req.file.mimetype || 'audio/webm';
// Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.)
var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel);
var isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/i.test(sttModel);
if (isTranscriptionModel) {
// Use /v1/audio/transcriptions endpoint (OpenAI-compatible)

View file

@ -42,13 +42,12 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
// tts.voice may contain a model ID (from discovery "Set" button) — detect and use as model
if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; }
// Auto-detect model from voice name when no explicit model is set
// Auto-detect model from voice — ensures model matches the voice's provider
var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'];
var OPENAI_VOICES = ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'];
var resolvedVoice = userVoice || adminVoice || '';
if (!adminModel && resolvedVoice) {
if (resolvedVoice) {
if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts';
else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; // ElevenLabs voice IDs are long alphanumeric
else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3';
}
if (ttsProvider === 'google') {

View file

@ -450,12 +450,13 @@ async function callAI(messages, options) {
var duration = Date.now() - startTime;
logger.info('AI call success', {
provider: activeProvider,
model: result.model,
var usage = result.usage || {};
logger.apiCall(null, activeProvider + '/' + (result.model || model), {
model: result.model || model,
tokensInput: usage.prompt_tokens || 0,
tokensOutput: usage.completion_tokens || 0,
duration: duration,
outputLength: result.content.length,
tokens: result.usage
statusCode: 200
});
result.duration = duration;

View file

@ -3,6 +3,8 @@ const path = require('path');
const db = require('../db/database');
const { redact } = require('./redact');
const queues = require('./auditQueue');
var parseUA;
try { parseUA = require('./sessions').parseUserAgent; } catch(e) { parseUA = function(ua) { return ua || 'unknown'; }; }
var LOG_DIR = path.join(__dirname, '../../data/logs');
if (!fs.existsSync(LOG_DIR)) {
@ -26,6 +28,7 @@ function pushToLoki(labels, message) {
}).catch(function() {}); // fire-and-forget
}
function estimateCost(model, inputTokens, outputTokens) {
if (!model || !inputTokens) return 0;
var rates = {
@ -61,16 +64,17 @@ var logger = {
duration_ms: e.duration || null,
status: e.status || 'success'
});
pushToLoki(
{ type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' },
JSON.stringify({
userId: userId, action: action, details: safeDetails,
ip: req ? req.ip : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
sessionId: req ? (req.sessionId || null) : null,
status: e.status || 'success'
})
);
var auditLabels = { type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' };
var auditMsg = JSON.stringify({
userId: userId, action: action, details: safeDetails,
ip: req ? req.ip : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
device: req ? parseUA(req.headers['user-agent']) : null,
sessionId: req ? (req.sessionId || null) : null,
model: e.model || null,
status: e.status || 'success'
});
pushToLoki(auditLabels, auditMsg);
},
apiCall: function(userId, endpoint, data) {
@ -91,14 +95,13 @@ var logger = {
ip_address: d.ip || null,
error: d.error || null
});
pushToLoki(
{ type: 'api_call', endpoint: endpoint, model: d.model || '' },
JSON.stringify({
userId: userId, endpoint: endpoint, model: d.model,
tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration,
ip: d.ip || null, status: d.statusCode || 200
})
);
var apiLabels = { type: 'api_call', endpoint: endpoint, model: d.model || '' };
var apiMsg = JSON.stringify({
userId: userId, endpoint: endpoint, model: d.model,
tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration,
ip: d.ip || null, status: d.statusCode || 200
});
pushToLoki(apiLabels, apiMsg);
},
access: function(userId, action, req, success) {
@ -109,16 +112,16 @@ var logger = {
user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
success: success ? true : false
});
pushToLoki(
{ type: 'access', action: action },
JSON.stringify({
userId: userId, action: action,
ip: req ? req.ip : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
sessionId: req ? (req.sessionId || null) : null,
success: success
})
);
var accessLabels = { type: 'access', action: action };
var accessMsg = JSON.stringify({
userId: userId, action: action,
ip: req ? req.ip : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
device: req ? parseUA(req.headers['user-agent']) : null,
sessionId: req ? (req.sessionId || null) : null,
success: success
});
pushToLoki(accessLabels, accessMsg);
},
file: function(level, message, data) {