Compare commits
4 Commits
v0.8.1-rc2
...
refactor/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c974d07f | ||
|
|
e8dffd35f3 | ||
|
|
30db34e737 | ||
|
|
d3f549ab7b |
1
api/cache/getLogStores.js
vendored
1
api/cache/getLogStores.js
vendored
@@ -79,6 +79,7 @@ const namespaces = {
|
||||
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance(
|
||||
ViolationTypes.ILLEGAL_MODEL_REQUEST,
|
||||
),
|
||||
[ViolationTypes.MODERATION]: createViolationInstance(ViolationTypes.MODERATION),
|
||||
logins: createViolationInstance('logins'),
|
||||
[CacheKeys.ABORT_KEYS]: abortKeys,
|
||||
[CacheKeys.TOKEN_CONFIG]: tokenConfig,
|
||||
|
||||
@@ -1,41 +1,148 @@
|
||||
const axios = require('axios');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const OpenAI = require('openai');
|
||||
const { ErrorTypes, ViolationTypes } = require('librechat-data-provider');
|
||||
const { getCustomConfig } = require('~/server/services/Config');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const denyRequest = require('./denyRequest');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const DEFAULT_ACTIONS = Object.freeze({
|
||||
violation: 2,
|
||||
blockMessage: true,
|
||||
log: true,
|
||||
});
|
||||
|
||||
// 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) {
|
||||
if (process.env.OPENAI_MODERATION === 'true') {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
if (!isEnabled(process.env.OPENAI_MODERATION)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations',
|
||||
{
|
||||
input: text,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`,
|
||||
},
|
||||
},
|
||||
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 {
|
||||
const customConfig = await getCustomConfig();
|
||||
|
||||
if (!moderateText.openai) {
|
||||
moderateText.openai = new OpenAI({ apiKey: moderationKey });
|
||||
}
|
||||
|
||||
const response = await moderateText.openai.moderations.create({
|
||||
model: 'omni-moderation-latest',
|
||||
input: text,
|
||||
});
|
||||
|
||||
if (!Array.isArray(response.results)) {
|
||||
throw new Error('Invalid moderation API response format');
|
||||
}
|
||||
|
||||
const violations = checkViolations(response.results, customConfig).map(formatViolation);
|
||||
|
||||
if (violations.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const actions = Object.assign({}, DEFAULT_ACTIONS, customConfig?.moderation?.actions);
|
||||
|
||||
if (actions.log) {
|
||||
const userId = req.user?.id || 'anonymous';
|
||||
logger.warn(
|
||||
'[moderateText] Content moderation violations: ' + formatViolationsLog(violations, userId),
|
||||
);
|
||||
}
|
||||
|
||||
const results = response.data.results;
|
||||
const flagged = results.some((result) => result.flagged);
|
||||
if (!actions.blockMessage) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (flagged) {
|
||||
const type = ErrorTypes.MODERATION;
|
||||
const errorMessage = { type };
|
||||
return await denyRequest(req, res, errorMessage);
|
||||
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, {
|
||||
type: ErrorTypes.MODERATION,
|
||||
message: 'Content moderation check failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in moderateText:', error);
|
||||
const errorMessage = 'error in moderation check';
|
||||
return await denyRequest(req, res, errorMessage);
|
||||
}
|
||||
}
|
||||
next();
|
||||
return violations;
|
||||
}
|
||||
|
||||
module.exports = moderateText;
|
||||
|
||||
@@ -21,8 +21,8 @@ const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(moderateText);
|
||||
router.post('/abort', handleAbort());
|
||||
router.use(moderateText);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
|
||||
@@ -11,8 +11,8 @@ const {
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(moderateText);
|
||||
router.post('/abort', handleAbort());
|
||||
router.use(moderateText);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
|
||||
@@ -20,8 +20,8 @@ const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(moderateText);
|
||||
router.post('/abort', handleAbort());
|
||||
router.use(moderateText);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
|
||||
@@ -11,8 +11,8 @@ const {
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(moderateText);
|
||||
router.post('/abort', handleAbort());
|
||||
router.use(moderateText);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
|
||||
@@ -1,120 +1,140 @@
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
const yaml = require('js-yaml');
|
||||
const { CacheKeys, configSchema, EImageOutputType } = require('librechat-data-provider');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const loadYaml = require('~/utils/loadYaml');
|
||||
const { logger } = require('~/config');
|
||||
const axios = require('axios');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
|
||||
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');
|
||||
const CONFIG = {
|
||||
PROJECT_ROOT: path.resolve(__dirname, '..', '..', '..', '..'),
|
||||
CACHE_TTL: 1000 * 60 * 5, // 5 minutes
|
||||
HTTP_TIMEOUT: 5000, // 5 seconds
|
||||
MAX_RETRIES: 3,
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
const defaultConfigPath = path.resolve(CONFIG.PROJECT_ROOT, 'librechat.yaml');
|
||||
const CONFIG_URL_REGEX = /^https?:\/\//;
|
||||
const IMAGE_OUTPUT_ERROR = `Please specify a correct \`imageOutputType\` value (case-sensitive).
|
||||
Available options: ${Object.values(EImageOutputType).join(', ')}
|
||||
See: https://www.librechat.ai/docs/configuration/librechat_yaml`;
|
||||
|
||||
/**
|
||||
* Load custom configuration files and caches the object if the `cache` field at root is true.
|
||||
* Validation via parsing the config file with the config schema.
|
||||
* @function loadCustomConfig
|
||||
* @returns {Promise<TCustomConfig | null>} A promise that resolves to null or the custom config object.
|
||||
* */
|
||||
async function loadCustomConfig() {
|
||||
// Use CONFIG_PATH if set, otherwise fallback to defaultConfigPath
|
||||
const configPath = process.env.CONFIG_PATH || defaultConfigPath;
|
||||
|
||||
let customConfig;
|
||||
|
||||
if (/^https?:\/\//.test(configPath)) {
|
||||
try {
|
||||
const response = await axios.get(configPath);
|
||||
customConfig = response.data;
|
||||
} catch (error) {
|
||||
i === 0 && logger.error(`Failed to fetch the remote config file from ${configPath}`, error);
|
||||
i === 0 && i++;
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
customConfig = loadYaml(configPath);
|
||||
if (!customConfig) {
|
||||
i === 0 &&
|
||||
logger.info(
|
||||
'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://www.librechat.ai/docs/configuration/librechat_yaml\n\n',
|
||||
);
|
||||
i === 0 && i++;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (customConfig.reason || customConfig.stack) {
|
||||
i === 0 && logger.error('Config file YAML format is invalid:', customConfig);
|
||||
i === 0 && i++;
|
||||
return null;
|
||||
}
|
||||
// Cache management
|
||||
class ConfigCache {
|
||||
constructor() {
|
||||
this.data = null;
|
||||
this.timestamp = null;
|
||||
}
|
||||
|
||||
if (typeof customConfig === 'string') {
|
||||
try {
|
||||
customConfig = yaml.load(customConfig);
|
||||
} catch (parseError) {
|
||||
i === 0 && logger.info(`Failed to parse the YAML config from ${configPath}`, parseError);
|
||||
i === 0 && i++;
|
||||
return null;
|
||||
}
|
||||
set(data) {
|
||||
this.data = data;
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
const result = configSchema.strict().safeParse(customConfig);
|
||||
if (result?.error?.errors?.some((err) => err?.path && err.path?.includes('imageOutputType'))) {
|
||||
throw new Error(
|
||||
`
|
||||
Please specify a correct \`imageOutputType\` value (case-sensitive).
|
||||
get() {
|
||||
if (!this.data || !this.timestamp) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() - this.timestamp > CONFIG.CACHE_TTL) {
|
||||
this.clear();
|
||||
return null;
|
||||
}
|
||||
return this.data;
|
||||
}
|
||||
|
||||
The available options are:
|
||||
- ${EImageOutputType.JPEG}
|
||||
- ${EImageOutputType.PNG}
|
||||
- ${EImageOutputType.WEBP}
|
||||
|
||||
Refer to the latest config file guide for more information:
|
||||
https://www.librechat.ai/docs/configuration/librechat_yaml`,
|
||||
clear() {
|
||||
this.data = null;
|
||||
this.timestamp = null;
|
||||
}
|
||||
}
|
||||
|
||||
const configCache = new ConfigCache();
|
||||
|
||||
// Error handling
|
||||
class ConfigError extends Error {
|
||||
constructor(message, type) {
|
||||
super(message);
|
||||
this.name = 'ConfigError';
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
const validateConfig = (config, configPath) => {
|
||||
const result = configSchema.strict().safeParse(config);
|
||||
|
||||
if (result?.error?.errors?.some((err) => err?.path?.includes('imageOutputType'))) {
|
||||
throw new ConfigError(IMAGE_OUTPUT_ERROR, 'invalid_image_type');
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new ConfigError(
|
||||
`Invalid config at ${configPath}:\n${JSON.stringify(result.error, null, 2)}`,
|
||||
'validation_error',
|
||||
);
|
||||
}
|
||||
if (!result.success) {
|
||||
let errorMessage = `Invalid custom config file at ${configPath}:
|
||||
${JSON.stringify(result.error, null, 2)}`;
|
||||
|
||||
if (i === 0) {
|
||||
logger.error(errorMessage);
|
||||
const speechError = result.error.errors.find(
|
||||
(err) =>
|
||||
err.code === 'unrecognized_keys' &&
|
||||
(err.message?.includes('stt') || err.message?.includes('tts')),
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
if (speechError) {
|
||||
logger.warn(`
|
||||
The Speech-to-text and Text-to-speech configuration format has recently changed.
|
||||
If you're getting this error, please refer to the latest documentation:
|
||||
// HTTP config loading with retries
|
||||
const fetchConfig = async (url, retries = CONFIG.MAX_RETRIES) => {
|
||||
try {
|
||||
const { data } = await axios.get(url, { timeout: CONFIG.HTTP_TIMEOUT });
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (retries > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return fetchConfig(url, retries - 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
https://www.librechat.ai/docs/configuration/stt_tts`);
|
||||
}
|
||||
|
||||
i++;
|
||||
// Main function
|
||||
async function loadCustomConfig() {
|
||||
try {
|
||||
const cachedConfig = configCache.get();
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
const configPath = process.env.CONFIG_PATH || defaultConfigPath;
|
||||
let customConfig;
|
||||
|
||||
if (CONFIG_URL_REGEX.test(configPath)) {
|
||||
customConfig = await fetchConfig(configPath);
|
||||
} else {
|
||||
customConfig = loadYaml(configPath);
|
||||
if (!customConfig) {
|
||||
throw new ConfigError('Config file missing or invalid YAML format', 'invalid_yaml');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof customConfig === 'string') {
|
||||
customConfig = yaml.load(customConfig);
|
||||
}
|
||||
|
||||
const result = validateConfig(customConfig, configPath);
|
||||
|
||||
if (customConfig.cache) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
|
||||
}
|
||||
|
||||
if (result.data.modelSpecs) {
|
||||
customConfig.modelSpecs = result.data.modelSpecs;
|
||||
}
|
||||
|
||||
configCache.set(customConfig);
|
||||
logger.info('Config loaded successfully');
|
||||
logger.debug('Config details:', customConfig);
|
||||
|
||||
return customConfig;
|
||||
} catch (error) {
|
||||
logger.error(`Config loading failed: ${error.message}`);
|
||||
return null;
|
||||
} else {
|
||||
logger.info('Custom config file loaded:');
|
||||
logger.info(JSON.stringify(customConfig, null, 2));
|
||||
logger.debug('Custom config:', customConfig);
|
||||
}
|
||||
|
||||
if (customConfig.cache) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
|
||||
}
|
||||
|
||||
if (result.data.modelSpecs) {
|
||||
customConfig.modelSpecs = result.data.modelSpecs;
|
||||
}
|
||||
|
||||
return customConfig;
|
||||
}
|
||||
|
||||
module.exports = loadCustomConfig;
|
||||
|
||||
@@ -419,6 +419,100 @@ export const rateLimitSchema = z.object({
|
||||
.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 {
|
||||
PNG = 'png',
|
||||
WEBP = 'webp',
|
||||
@@ -471,6 +565,7 @@ export const configSchema = z.object({
|
||||
agents: true,
|
||||
}),
|
||||
fileStrategy: fileSourceSchema.default(FileSources.local),
|
||||
moderation: moderationSchema.optional(),
|
||||
actions: z
|
||||
.object({
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
@@ -929,6 +1024,10 @@ export enum ViolationTypes {
|
||||
* Verify Conversation Access violation.
|
||||
*/
|
||||
CONVO_ACCESS = 'convo_access',
|
||||
/**
|
||||
* Verify moderation LLM violation.
|
||||
*/
|
||||
MODERATION = 'moderation',
|
||||
/**
|
||||
* Tool Call Limit Violation.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user