mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 04:06:33 +01:00
feat: Implement moderation middleware with configurable categories and actions
This commit is contained in:
parent
30db34e737
commit
e8dffd35f3
3 changed files with 213 additions and 35 deletions
1
api/cache/getLogStores.js
vendored
1
api/cache/getLogStores.js
vendored
|
|
@ -78,6 +78,7 @@ const namespaces = {
|
||||||
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance(
|
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance(
|
||||||
ViolationTypes.ILLEGAL_MODEL_REQUEST,
|
ViolationTypes.ILLEGAL_MODEL_REQUEST,
|
||||||
),
|
),
|
||||||
|
[ViolationTypes.MODERATION]: createViolationInstance(ViolationTypes.MODERATION),
|
||||||
logins: createViolationInstance('logins'),
|
logins: createViolationInstance('logins'),
|
||||||
[CacheKeys.ABORT_KEYS]: abortKeys,
|
[CacheKeys.ABORT_KEYS]: abortKeys,
|
||||||
[CacheKeys.TOKEN_CONFIG]: tokenConfig,
|
[CacheKeys.TOKEN_CONFIG]: tokenConfig,
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,71 @@
|
||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
const { ErrorTypes } = require('librechat-data-provider');
|
const { ErrorTypes, ViolationTypes } = require('librechat-data-provider');
|
||||||
|
const { getCustomConfig } = require('~/server/services/Config');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const denyRequest = require('./denyRequest');
|
const denyRequest = require('./denyRequest');
|
||||||
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
/**
|
const DEFAULT_ACTIONS = Object.freeze({
|
||||||
* Middleware to moderate text content using OpenAI's moderation API
|
violation: 2,
|
||||||
* @param {Express.Request} req - Express request object
|
blockMessage: true,
|
||||||
* @param {Express.Response} res - Express response object
|
log: true,
|
||||||
* @param {Express.NextFunction} next - Express next middleware function
|
});
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
// Pre-compile threshold map for faster lookups
|
||||||
|
const DEFAULT_THRESHOLDS = new Map();
|
||||||
|
|
||||||
|
function formatViolation(violation) {
|
||||||
|
return {
|
||||||
|
category: violation.category,
|
||||||
|
score: Math.round(violation.score * 100) / 100,
|
||||||
|
threshold: violation.threshold,
|
||||||
|
severity: getSeverityLevel(violation.score),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeverityLevel(score) {
|
||||||
|
if (score >= 0.9) {
|
||||||
|
return 'HIGH';
|
||||||
|
}
|
||||||
|
if (score >= 0.7) {
|
||||||
|
return 'MEDIUM';
|
||||||
|
}
|
||||||
|
return 'LOW';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatViolationsLog(violations, userId = 'unknown') {
|
||||||
|
const violationsStr = violations
|
||||||
|
.map((v) => `${v.category}:${v.score}>${v.threshold}`)
|
||||||
|
.join(' | ');
|
||||||
|
|
||||||
|
return `userId=${userId} violations=[${violationsStr}]`;
|
||||||
|
}
|
||||||
|
|
||||||
async function moderateText(req, res, next) {
|
async function moderateText(req, res, next) {
|
||||||
if (!isEnabled(process.env.OPENAI_MODERATION)) {
|
if (!isEnabled(process.env.OPENAI_MODERATION)) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moderationKey = process.env.OPENAI_MODERATION_API_KEY;
|
||||||
|
if (!moderationKey) {
|
||||||
|
logger.error('Missing OpenAI moderation API key');
|
||||||
|
return denyRequest(req, res, { message: 'Moderation configuration error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text } = req.body;
|
||||||
|
if (!text?.length || typeof text !== 'string') {
|
||||||
|
return denyRequest(req, res, { type: ErrorTypes.VALIDATION, message: 'Invalid text input' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moderationKey = process.env.OPENAI_MODERATION_API_KEY;
|
const customConfig = await getCustomConfig();
|
||||||
|
|
||||||
if (!moderationKey) {
|
if (!moderateText.openai) {
|
||||||
logger.error('Missing OpenAI moderation API key');
|
moderateText.openai = new OpenAI({ apiKey: moderationKey });
|
||||||
return denyRequest(req, res, { message: 'Moderation configuration error' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openai = new OpenAI({
|
const response = await moderateText.openai.moderations.create({
|
||||||
apiKey: moderationKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { text } = req.body;
|
|
||||||
|
|
||||||
if (!text || typeof text !== 'string') {
|
|
||||||
return denyRequest(req, res, { type: ErrorTypes.VALIDATION, message: 'Invalid text input' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await openai.moderations.create({
|
|
||||||
model: 'omni-moderation-latest',
|
model: 'omni-moderation-latest',
|
||||||
input: text,
|
input: text,
|
||||||
});
|
});
|
||||||
|
|
@ -43,22 +74,47 @@ async function moderateText(req, res, next) {
|
||||||
throw new Error('Invalid moderation API response format');
|
throw new Error('Invalid moderation API response format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const flagged = response.results.some((result) => result.flagged);
|
const violations = checkViolations(response.results, customConfig).map(formatViolation);
|
||||||
|
|
||||||
if (flagged) {
|
if (violations.length === 0) {
|
||||||
return denyRequest(req, res, {
|
return next();
|
||||||
type: ErrorTypes.MODERATION,
|
|
||||||
message: 'Content violates moderation policies',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
const actions = Object.assign({}, DEFAULT_ACTIONS, customConfig?.moderation?.actions);
|
||||||
} catch (error) {
|
|
||||||
logger.error('Moderation error:', {
|
if (actions.log) {
|
||||||
error: error.message,
|
const userId = req.user?.id || 'anonymous';
|
||||||
stack: error.stack,
|
logger.warn(
|
||||||
status: error.response?.status,
|
'[moderateText] Content moderation violations: ' + formatViolationsLog(violations, userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actions.blockMessage) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.violation > 0) {
|
||||||
|
logViolation(req, res, ViolationTypes.MODERATION, { violations }, actions.violation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return denyRequest(req, res, {
|
||||||
|
type: ErrorTypes.MODERATION,
|
||||||
|
message: `Content violates moderation policies: ${violations
|
||||||
|
.map((v) => v.category)
|
||||||
|
.join(', ')}`,
|
||||||
|
violations: violations,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorDetails =
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
? { message: error.message }
|
||||||
|
: {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
status: error.response?.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.error('Moderation error:', errorDetails);
|
||||||
|
|
||||||
return denyRequest(req, res, {
|
return denyRequest(req, res, {
|
||||||
type: ErrorTypes.MODERATION,
|
type: ErrorTypes.MODERATION,
|
||||||
|
|
@ -67,4 +123,26 @@ async function moderateText(req, res, next) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkViolations(results, customConfig) {
|
||||||
|
const violations = [];
|
||||||
|
const categories = customConfig?.moderation?.categories || {};
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
for (const [category, score] of Object.entries(result.category_scores)) {
|
||||||
|
const categoryConfig = categories[category];
|
||||||
|
|
||||||
|
if (categoryConfig?.enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = categoryConfig?.threshold || DEFAULT_THRESHOLDS.get(category) || 0.7;
|
||||||
|
|
||||||
|
if (score >= threshold) {
|
||||||
|
violations.push({ category, score, threshold });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = moderateText;
|
module.exports = moderateText;
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,100 @@ export const rateLimitSchema = z.object({
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const moderationSchema = z
|
||||||
|
.object({
|
||||||
|
categories: z
|
||||||
|
.object({
|
||||||
|
sexual: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'sexual/minors': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
harassment: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'harassment/threatening': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
hate: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'hate/threatening': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
illicit: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'illicit/violent': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'self-harm': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'self-harm/intent': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'self-harm/instructions': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
violence: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
'violence/graphic': z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
actions: z
|
||||||
|
.object({
|
||||||
|
violation: z.number().default(2),
|
||||||
|
blockMessage: z.boolean().default(true),
|
||||||
|
log: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export enum EImageOutputType {
|
export enum EImageOutputType {
|
||||||
PNG = 'png',
|
PNG = 'png',
|
||||||
WEBP = 'webp',
|
WEBP = 'webp',
|
||||||
|
|
@ -487,6 +581,7 @@ export const configSchema = z.object({
|
||||||
prompts: true,
|
prompts: true,
|
||||||
}),
|
}),
|
||||||
fileStrategy: fileSourceSchema.default(FileSources.local),
|
fileStrategy: fileSourceSchema.default(FileSources.local),
|
||||||
|
moderation: moderationSchema.optional(),
|
||||||
registration: z
|
registration: z
|
||||||
.object({
|
.object({
|
||||||
socialLogins: z.array(z.string()).optional(),
|
socialLogins: z.array(z.string()).optional(),
|
||||||
|
|
@ -931,6 +1026,10 @@ export enum ViolationTypes {
|
||||||
* Verify Conversation Access violation.
|
* Verify Conversation Access violation.
|
||||||
*/
|
*/
|
||||||
CONVO_ACCESS = 'convo_access',
|
CONVO_ACCESS = 'convo_access',
|
||||||
|
/**
|
||||||
|
* Verify moderation LLM violation.
|
||||||
|
*/
|
||||||
|
MODERATION = 'moderation',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue