🕐 feat: Configurable Retention Period for Temporary Chats (#8056)

* feat: Add configurable retention period for temporary chats

* Addressing eslint errors

* Fix: failing test due to missing registration

* Update: variable name and use hours instead of days for chat retention

* Addressing comments

* chore: fix import order in Conversation.js

* chore: import order in Message.js

* chore: fix import order in config.ts

* chore: move common methods to packages/api to reduce potential for circular dependencies

* refactor: update temp chat retention config type to Partial<TCustomConfig>

* refactor: remove unused config variable from AppService and update loadCustomConfig tests with logger mock

* refactor: handle model undefined edge case by moving Session model initialization inside methods

---------

Co-authored-by: Rakshit Tiwari <rak1729e@gmail.com>
This commit is contained in:
Danny Avila 2025-06-25 17:16:26 -04:00 committed by GitHub
parent 3ab1bd65e5
commit cbda3cb529
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 372 additions and 135 deletions

View file

@ -1,7 +1,8 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { isEnabled, math } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider'); const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, math, removePorts } = require('~/server/utils');
const { deleteAllUserSessions } = require('~/models'); const { deleteAllUserSessions } = require('~/models');
const { removePorts } = require('~/server/utils');
const getLogStores = require('./getLogStores'); const getLogStores = require('./getLogStores');
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {}; const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};

View file

@ -1,7 +1,7 @@
const { Keyv } = require('keyv'); const { Keyv } = require('keyv');
const { isEnabled, math } = require('@librechat/api');
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider'); const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles'); const { logFile, violationFile } = require('./keyvFiles');
const { isEnabled, math } = require('~/server/utils');
const keyvRedis = require('./keyvRedis'); const keyvRedis = require('./keyvRedis');
const keyvMongo = require('./keyvMongo'); const keyvMongo = require('./keyvMongo');

View file

@ -1,4 +1,6 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { getMessages, deleteMessages } = require('./Message'); const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models'); const { Conversation } = require('~/db/models');
@ -98,10 +100,15 @@ module.exports = {
update.conversationId = newConversationId; update.conversationId = newConversationId;
} }
if (req.body.isTemporary) { if (req?.body?.isTemporary) {
const expiredAt = new Date(); try {
expiredAt.setDate(expiredAt.getDate() + 30); const customConfig = await getCustomConfig();
update.expiredAt = expiredAt; update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else { } else {
update.expiredAt = null; update.expiredAt = null;
} }

View file

@ -1,5 +1,7 @@
const { z } = require('zod'); const { z } = require('zod');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { Message } = require('~/db/models'); const { Message } = require('~/db/models');
const idSchema = z.string().uuid(); const idSchema = z.string().uuid();
@ -54,9 +56,14 @@ async function saveMessage(req, params, metadata) {
}; };
if (req?.body?.isTemporary) { if (req?.body?.isTemporary) {
const expiredAt = new Date(); try {
expiredAt.setDate(expiredAt.getDate() + 30); const customConfig = await getCustomConfig();
update.expiredAt = expiredAt; update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else { } else {
update.expiredAt = null; update.expiredAt = null;
} }

View file

@ -1,17 +1,17 @@
const cookies = require('cookie'); const cookies = require('cookie');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client'); const openIdClient = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { const {
registerUser,
resetPassword,
setAuthTokens,
requestPasswordReset, requestPasswordReset,
setOpenIDAuthTokens, setOpenIDAuthTokens,
resetPassword,
setAuthTokens,
registerUser,
} = require('~/server/services/AuthService'); } = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models'); const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies'); const { getOpenIdConfig } = require('~/strategies');
const { isEnabled } = require('~/server/utils');
const registrationController = async (req, res) => { const registrationController = async (req, res) => {
try { try {

View file

@ -1,3 +1,5 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getResponseSender } = require('librechat-data-provider'); const { getResponseSender } = require('librechat-data-provider');
const { const {
handleAbortError, handleAbortError,
@ -10,9 +12,8 @@ const {
clientRegistry, clientRegistry,
requestDataMap, requestDataMap,
} = require('~/server/cleanup'); } = require('~/server/cleanup');
const { sendMessage, createOnProgress } = require('~/server/utils'); const { createOnProgress } = require('~/server/utils');
const { saveMessage } = require('~/models'); const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const EditController = async (req, res, next, initializeClient) => { const EditController = async (req, res, next, initializeClient) => {
let { let {
@ -198,7 +199,7 @@ const EditController = async (req, res, next, initializeClient) => {
const finalUserMessage = reqDataContext.userMessage; const finalUserMessage = reqDataContext.userMessage;
const finalResponseMessage = { ...response }; const finalResponseMessage = { ...response };
sendMessage(res, { sendEvent(res, {
final: true, final: true,
conversation, conversation,
title: conversation.title, title: conversation.title,

View file

@ -1,10 +1,10 @@
// errorHandler.js // errorHandler.js
const { logger } = require('~/config'); const { logger } = require('@librechat/data-schemas');
const getLogStores = require('~/cache/getLogStores');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider'); const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { sendResponse } = require('~/server/middleware/error');
const { recordUsage } = require('~/server/services/Threads'); const { recordUsage } = require('~/server/services/Threads');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
const { sendResponse } = require('~/server/utils'); const getLogStores = require('~/cache/getLogStores');
/** /**
* @typedef {Object} ErrorHandlerContext * @typedef {Object} ErrorHandlerContext
@ -75,7 +75,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) { } else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${ const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants' endpoint === 'azureAssistants'
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.' ? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: '' : ''
}`; }`;
return sendResponse(req, res, messageData, errorMessage); return sendResponse(req, res, messageData, errorMessage);

View file

@ -1,3 +1,5 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider'); const { Constants } = require('librechat-data-provider');
const { const {
handleAbortError, handleAbortError,
@ -5,9 +7,7 @@ const {
cleanupAbortController, cleanupAbortController,
} = require('~/server/middleware'); } = require('~/server/middleware');
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup'); const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { sendMessage } = require('~/server/utils');
const { saveMessage } = require('~/models'); const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const AgentController = async (req, res, next, initializeClient, addTitle) => { const AgentController = async (req, res, next, initializeClient, addTitle) => {
let { let {
@ -206,7 +206,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
// Create a new response object with minimal copies // Create a new response object with minimal copies
const finalResponse = { ...response }; const finalResponse = { ...response };
sendMessage(res, { sendEvent(res, {
final: true, final: true,
conversation, conversation,
title: conversation.title, title: conversation.title,

View file

@ -1,4 +1,7 @@
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { const {
Time, Time,
Constants, Constants,
@ -19,20 +22,20 @@ const {
addThreadMetadata, addThreadMetadata,
saveAssistantMessage, saveAssistantMessage,
} = require('~/server/services/Threads'); } = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService'); const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor'); const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts'); const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
const { createRun, StreamRunManager } = require('~/server/services/Runs'); const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants'); const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody'); const { createRunBody } = require('~/server/services/createRunBody');
const { sendResponse } = require('~/server/middleware/error');
const { getTransactions } = require('~/models/Transaction'); const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils'); const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers'); const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/** /**
* @route POST / * @route POST /
@ -471,7 +474,7 @@ const chatV1 = async (req, res) => {
await Promise.all(promises); await Promise.all(promises);
const sendInitialResponse = () => { const sendInitialResponse = () => {
sendMessage(res, { sendEvent(res, {
sync: true, sync: true,
conversationId, conversationId,
// messages: previousMessages, // messages: previousMessages,
@ -587,7 +590,7 @@ const chatV1 = async (req, res) => {
iconURL: endpointOption.iconURL, iconURL: endpointOption.iconURL,
}; };
sendMessage(res, { sendEvent(res, {
final: true, final: true,
conversation, conversation,
requestMessage: { requestMessage: {

View file

@ -1,4 +1,7 @@
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { const {
Time, Time,
Constants, Constants,
@ -22,15 +25,14 @@ const { createErrorHandler } = require('~/server/controllers/assistants/errors')
const validateAuthor = require('~/server/middleware/assistants/validateAuthor'); const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs'); const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants'); const { addTitle } = require('~/server/services/Endpoints/assistants');
const { sendMessage, sleep, countTokens } = require('~/server/utils');
const { createRunBody } = require('~/server/services/createRunBody'); const { createRunBody } = require('~/server/services/createRunBody');
const { getTransactions } = require('~/models/Transaction'); const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils'); const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers'); const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/** /**
* @route POST / * @route POST /
@ -309,7 +311,7 @@ const chatV2 = async (req, res) => {
await Promise.all(promises); await Promise.all(promises);
const sendInitialResponse = () => { const sendInitialResponse = () => {
sendMessage(res, { sendEvent(res, {
sync: true, sync: true,
conversationId, conversationId,
// messages: previousMessages, // messages: previousMessages,
@ -432,7 +434,7 @@ const chatV2 = async (req, res) => {
iconURL: endpointOption.iconURL, iconURL: endpointOption.iconURL,
}; };
sendMessage(res, { sendEvent(res, {
final: true, final: true,
conversation, conversation,
requestMessage: { requestMessage: {

View file

@ -1,10 +1,10 @@
// errorHandler.js // errorHandler.js
const { sendResponse } = require('~/server/utils'); const { logger } = require('@librechat/data-schemas');
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider'); const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { getConvo } = require('~/models/Conversation');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads'); const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
const { sendResponse } = require('~/server/middleware/error');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
/** /**
* @typedef {Object} ErrorHandlerContext * @typedef {Object} ErrorHandlerContext
@ -78,7 +78,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) { } else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${ const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants' endpoint === 'azureAssistants'
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.' ? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: '' : ''
}`; }`;
return sendResponse(req, res, messageData, errorMessage); return sendResponse(req, res, messageData, errorMessage);

View file

@ -1,13 +1,13 @@
// abortMiddleware.js const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider'); const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq'); const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
const { spendTokens } = require('~/models/spendTokens'); const { spendTokens } = require('~/models/spendTokens');
const abortControllers = require('./abortControllers'); const abortControllers = require('./abortControllers');
const { saveMessage, getConvo } = require('~/models'); const { saveMessage, getConvo } = require('~/models');
const { abortRun } = require('./abortRun'); const { abortRun } = require('./abortRun');
const { logger } = require('~/config');
const abortDataMap = new WeakMap(); const abortDataMap = new WeakMap();
@ -101,7 +101,7 @@ async function abortMessage(req, res) {
cleanupAbortController(abortKey); cleanupAbortController(abortKey);
if (res.headersSent && finalEvent) { if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent); return sendEvent(res, finalEvent);
} }
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
@ -174,7 +174,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
* @param {string} responseMessageId * @param {string} responseMessageId
*/ */
const onStart = (userMessage, responseMessageId) => { const onStart = (userMessage, responseMessageId) => {
sendMessage(res, { message: userMessage, created: true }); sendEvent(res, { message: userMessage, created: true });
const abortKey = userMessage?.conversationId ?? req.user.id; const abortKey = userMessage?.conversationId ?? req.user.id;
getReqData({ abortKey }); getReqData({ abortKey });

View file

@ -1,11 +1,11 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider'); const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { checkMessageGaps, recordUsage } = require('~/server/services/Threads'); const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
const { deleteMessages } = require('~/models/Message'); const { deleteMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { sendMessage } = require('~/server/utils');
const { logger } = require('~/config');
const three_minutes = 1000 * 60 * 3; const three_minutes = 1000 * 60 * 3;
@ -34,7 +34,7 @@ async function abortRun(req, res) {
const [thread_id, run_id] = runValues.split(':'); const [thread_id, run_id] = runValues.split(':');
if (!run_id) { if (!run_id) {
logger.warn('[abortRun] Couldn\'t find run for cancel request', { thread_id }); logger.warn("[abortRun] Couldn't find run for cancel request", { thread_id });
return res.status(204).send({ message: 'Run not found' }); return res.status(204).send({ message: 'Run not found' });
} else if (run_id === 'cancelled') { } else if (run_id === 'cancelled') {
logger.warn('[abortRun] Run already cancelled', { thread_id }); logger.warn('[abortRun] Run already cancelled', { thread_id });
@ -93,7 +93,7 @@ async function abortRun(req, res) {
}; };
if (res.headersSent && finalEvent) { if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent); return sendEvent(res, finalEvent);
} }
res.json(finalEvent); res.json(finalEvent);

View file

@ -1,6 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { sendEvent } = require('@librechat/api');
const { getResponseSender, Constants } = require('librechat-data-provider'); const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendMessage, sendError } = require('~/server/utils'); const { sendError } = require('~/server/middleware/error');
const { saveMessage } = require('~/models'); const { saveMessage } = require('~/models');
/** /**
@ -36,7 +37,7 @@ const denyRequest = async (req, res, errorMessage) => {
isCreatedByUser: true, isCreatedByUser: true,
text, text,
}; };
sendMessage(res, { message: userMessage, created: true }); sendEvent(res, { message: userMessage, created: true });
const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT; const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;

View file

@ -1,31 +1,9 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { logger } = require('@librechat/data-schemas');
const { parseConvo } = require('librechat-data-provider'); const { parseConvo } = require('librechat-data-provider');
const { sendEvent, handleError } = require('@librechat/api');
const { saveMessage, getMessages } = require('~/models/Message'); const { saveMessage, getMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
const { logger } = require('~/config');
/**
* Sends error data in Server Sent Events format and ends the response.
* @param {object} res - The server response.
* @param {string} message - The error message.
*/
const handleError = (res, message) => {
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
res.end();
};
/**
* Sends message data in Server Sent Events format.
* @param {Express.Response} res - - The server response.
* @param {string | Object} message - The message to be sent.
* @param {'message' | 'error' | 'cancel'} event - [Optional] The type of event. Default is 'message'.
*/
const sendMessage = (res, message, event = 'message') => {
if (typeof message === 'string' && message.length === 0) {
return;
}
res.write(`event: ${event}\ndata: ${JSON.stringify(message)}\n\n`);
};
/** /**
* Processes an error with provided options, saves the error message and sends a corresponding SSE response * Processes an error with provided options, saves the error message and sends a corresponding SSE response
@ -91,7 +69,7 @@ const sendError = async (req, res, options, callback) => {
convo = parseConvo(errorMessage); convo = parseConvo(errorMessage);
} }
return sendMessage(res, { return sendEvent(res, {
final: true, final: true,
requestMessage: query?.[0] ? query[0] : requestMessage, requestMessage: query?.[0] ? query[0] : requestMessage,
responseMessage: errorMessage, responseMessage: errorMessage,
@ -120,12 +98,10 @@ const sendResponse = (req, res, data, errorMessage) => {
if (errorMessage) { if (errorMessage) {
return sendError(req, res, { ...data, text: errorMessage }); return sendError(req, res, { ...data, text: errorMessage });
} }
return sendMessage(res, data); return sendEvent(res, data);
}; };
module.exports = { module.exports = {
sendResponse,
handleError,
sendMessage,
sendError, sendError,
sendResponse,
}; };

View file

@ -1,4 +1,7 @@
const { klona } = require('klona'); const { klona } = require('klona');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { const {
StepTypes, StepTypes,
RunStatus, RunStatus,
@ -11,11 +14,10 @@ const {
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process'); const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService'); const { processRequiredActions } = require('~/server/services/ToolService');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { RunManager, waitForRun } = require('~/server/services/Runs'); const { RunManager, waitForRun } = require('~/server/services/Runs');
const { processMessages } = require('~/server/services/Threads'); const { processMessages } = require('~/server/services/Threads');
const { createOnProgress } = require('~/server/utils');
const { TextStream } = require('~/app/clients'); const { TextStream } = require('~/app/clients');
const { logger } = require('~/config');
/** /**
* Sorts, processes, and flattens messages to a single string. * Sorts, processes, and flattens messages to a single string.
@ -64,7 +66,7 @@ async function createOnTextProgress({
}; };
logger.debug('Content data:', contentData); logger.debug('Content data:', contentData);
sendMessage(openai.res, contentData); sendEvent(openai.res, contentData);
}; };
} }

View file

@ -1,5 +1,6 @@
const { isUserProvided } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider'); const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided, generateConfig } = require('~/server/utils'); const { generateConfig } = require('~/server/utils/handleText');
const { const {
OPENAI_API_KEY: openAIApiKey, OPENAI_API_KEY: openAIApiKey,

View file

@ -1,18 +1,18 @@
const path = require('path'); const path = require('path');
const {
CacheKeys,
configSchema,
EImageOutputType,
validateSettingDefinitions,
agentParamSettings,
paramSettings,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
const axios = require('axios'); const axios = require('axios');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const keyBy = require('lodash/keyBy'); const keyBy = require('lodash/keyBy');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
CacheKeys,
configSchema,
paramSettings,
EImageOutputType,
agentParamSettings,
validateSettingDefinitions,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');

View file

@ -1,6 +1,9 @@
jest.mock('axios'); jest.mock('axios');
jest.mock('~/cache/getLogStores'); jest.mock('~/cache/getLogStores');
jest.mock('~/utils/loadYaml'); jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
loadYaml: jest.fn(),
}));
jest.mock('librechat-data-provider', () => { jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider'); const actual = jest.requireActual('librechat-data-provider');
return { return {
@ -30,11 +33,22 @@ jest.mock('librechat-data-provider', () => {
}; };
}); });
jest.mock('@librechat/data-schemas', () => {
return {
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
};
});
const axios = require('axios'); const axios = require('axios');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const loadCustomConfig = require('./loadCustomConfig'); const loadCustomConfig = require('./loadCustomConfig');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
describe('loadCustomConfig', () => { describe('loadCustomConfig', () => {
const mockSet = jest.fn(); const mockSet = jest.fn();

View file

@ -1,3 +1,6 @@
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { const {
Constants, Constants,
StepTypes, StepTypes,
@ -8,9 +11,8 @@ const {
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process'); const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService'); const { processRequiredActions } = require('~/server/services/ToolService');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { processMessages } = require('~/server/services/Threads'); const { processMessages } = require('~/server/services/Threads');
const { logger } = require('~/config'); const { createOnProgress } = require('~/server/utils');
/** /**
* Implements the StreamRunManager functionality for managing the streaming * Implements the StreamRunManager functionality for managing the streaming
@ -126,7 +128,7 @@ class StreamRunManager {
conversationId: this.finalMessage.conversationId, conversationId: this.finalMessage.conversationId,
}; };
sendMessage(this.res, contentData); sendEvent(this.res, contentData);
} }
/* <------------------ Misc. Helpers ------------------> */ /* <------------------ Misc. Helpers ------------------> */
@ -302,7 +304,7 @@ class StreamRunManager {
for (const d of delta[key]) { for (const d of delta[key]) {
if (typeof d === 'object' && !Object.prototype.hasOwnProperty.call(d, 'index')) { if (typeof d === 'object' && !Object.prototype.hasOwnProperty.call(d, 'index')) {
logger.warn('Expected an object with an \'index\' for array updates but got:', d); logger.warn("Expected an object with an 'index' for array updates but got:", d);
continue; continue;
} }

View file

@ -7,9 +7,9 @@ const {
defaultAssistantsVersion, defaultAssistantsVersion,
defaultAgentCapabilities, defaultAgentCapabilities,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { sendEvent } = require('@librechat/api');
const { Providers } = require('@librechat/agents'); const { Providers } = require('@librechat/agents');
const partialRight = require('lodash/partialRight'); const partialRight = require('lodash/partialRight');
const { sendMessage } = require('./streamResponse');
/** Helper function to escape special characters in regex /** Helper function to escape special characters in regex
* @param {string} string - The string to escape. * @param {string} string - The string to escape.
@ -37,7 +37,7 @@ const createOnProgress = (
basePayload.text = basePayload.text + chunk; basePayload.text = basePayload.text + chunk;
const payload = Object.assign({}, basePayload, rest); const payload = Object.assign({}, basePayload, rest);
sendMessage(res, payload); sendEvent(res, payload);
if (_onProgress) { if (_onProgress) {
_onProgress(payload); _onProgress(payload);
} }
@ -50,7 +50,7 @@ const createOnProgress = (
const sendIntermediateMessage = (res, payload, extraTokens = '') => { const sendIntermediateMessage = (res, payload, extraTokens = '') => {
basePayload.text = basePayload.text + extraTokens; basePayload.text = basePayload.text + extraTokens;
const message = Object.assign({}, basePayload, payload); const message = Object.assign({}, basePayload, payload);
sendMessage(res, message); sendEvent(res, message);
if (i === 0) { if (i === 0) {
basePayload.initial = false; basePayload.initial = false;
} }

View file

@ -1,11 +1,9 @@
const streamResponse = require('./streamResponse');
const removePorts = require('./removePorts'); const removePorts = require('./removePorts');
const countTokens = require('./countTokens'); const countTokens = require('./countTokens');
const handleText = require('./handleText'); const handleText = require('./handleText');
const sendEmail = require('./sendEmail'); const sendEmail = require('./sendEmail');
const queue = require('./queue'); const queue = require('./queue');
const files = require('./files'); const files = require('./files');
const math = require('./math');
/** /**
* Check if email configuration is set * Check if email configuration is set
@ -28,7 +26,6 @@ function checkEmailConfig() {
} }
module.exports = { module.exports = {
...streamResponse,
checkEmailConfig, checkEmailConfig,
...handleText, ...handleText,
countTokens, countTokens,
@ -36,5 +33,4 @@ module.exports = {
sendEmail, sendEmail,
...files, ...files,
...queue, ...queue,
math,
}; };

View file

@ -1,11 +1,9 @@
const loadYaml = require('./loadYaml');
const tokenHelpers = require('./tokens'); const tokenHelpers = require('./tokens');
const deriveBaseURL = require('./deriveBaseURL'); const deriveBaseURL = require('./deriveBaseURL');
const extractBaseURL = require('./extractBaseURL'); const extractBaseURL = require('./extractBaseURL');
const findMessageContent = require('./findMessageContent'); const findMessageContent = require('./findMessageContent');
module.exports = { module.exports = {
loadYaml,
deriveBaseURL, deriveBaseURL,
extractBaseURL, extractBaseURL,
...tokenHelpers, ...tokenHelpers,

View file

@ -1,13 +0,0 @@
const fs = require('fs');
const yaml = require('js-yaml');
function loadYaml(filepath) {
try {
let fileContents = fs.readFileSync(filepath, 'utf8');
return yaml.load(fileContents);
} catch (e) {
return e;
}
}
module.exports = loadYaml;

View file

@ -73,6 +73,8 @@ interface:
bookmarks: true bookmarks: true
multiConvo: true multiConvo: true
agents: true agents: true
# Temporary chat retention period in hours (default: 720, min: 1, max: 8760)
# temporaryChatRetention: 1
# Example Cloudflare turnstile (optional) # Example Cloudflare turnstile (optional)
#turnstile: #turnstile:

1
package-lock.json generated
View file

@ -46573,6 +46573,7 @@
"diff": "^7.0.0", "diff": "^7.0.0",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"express": "^4.21.2", "express": "^4.21.2",
"js-yaml": "^4.1.0",
"keyv": "^5.3.2", "keyv": "^5.3.2",
"librechat-data-provider": "*", "librechat-data-provider": "*",
"node-fetch": "2.7.0", "node-fetch": "2.7.0",

View file

@ -76,6 +76,7 @@
"diff": "^7.0.0", "diff": "^7.0.0",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"express": "^4.21.2", "express": "^4.21.2",
"js-yaml": "^4.1.0",
"keyv": "^5.3.2", "keyv": "^5.3.2",
"librechat-data-provider": "*", "librechat-data-provider": "*",
"node-fetch": "2.7.0", "node-fetch": "2.7.0",

View file

@ -14,3 +14,13 @@ export function sendEvent(res: ServerResponse, event: ServerSentEvent): void {
} }
res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`); res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
} }
/**
* Sends error data in Server Sent Events format and ends the response.
* @param res - The server response.
* @param message - The error message.
*/
export function handleError(res: ServerResponse, message: string): void {
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
res.end();
}

View file

@ -6,5 +6,8 @@ export * from './events';
export * from './files'; export * from './files';
export * from './generators'; export * from './generators';
export * from './llm'; export * from './llm';
export * from './math';
export * from './openid'; export * from './openid';
export * from './tempChatRetention';
export { default as Tokenizer } from './tokenizer'; export { default as Tokenizer } from './tokenizer';
export * from './yaml';

View file

@ -5,14 +5,14 @@
* If the input is not a string or contains invalid characters, an error is thrown. * If the input is not a string or contains invalid characters, an error is thrown.
* If the evaluated result is not a number, an error is thrown. * If the evaluated result is not a number, an error is thrown.
* *
* @param {string|number} str - The mathematical expression to evaluate, or a number. * @param str - The mathematical expression to evaluate, or a number.
* @param {number} [fallbackValue] - The default value to return if the input is not a string or number, or if the evaluated result is not a number. * @param fallbackValue - The default value to return if the input is not a string or number, or if the evaluated result is not a number.
* *
* @returns {number} The result of the evaluated expression or the input number. * @returns The result of the evaluated expression or the input number.
* *
* @throws {Error} Throws an error if the input is not a string or number, contains invalid characters, or does not evaluate to a number. * @throws Throws an error if the input is not a string or number, contains invalid characters, or does not evaluate to a number.
*/ */
function math(str, fallbackValue) { export function math(str: string | number, fallbackValue?: number): number {
const fallback = typeof fallbackValue !== 'undefined' && typeof fallbackValue === 'number'; const fallback = typeof fallbackValue !== 'undefined' && typeof fallbackValue === 'number';
if (typeof str !== 'string' && typeof str === 'number') { if (typeof str !== 'string' && typeof str === 'number') {
return str; return str;
@ -43,5 +43,3 @@ function math(str, fallbackValue) {
return value; return value;
} }
module.exports = math;

View file

@ -0,0 +1,133 @@
import {
MIN_RETENTION_HOURS,
MAX_RETENTION_HOURS,
DEFAULT_RETENTION_HOURS,
getTempChatRetentionHours,
createTempChatExpirationDate,
} from './tempChatRetention';
import type { TCustomConfig } from 'librechat-data-provider';
describe('tempChatRetention', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
delete process.env.TEMP_CHAT_RETENTION_HOURS;
});
afterAll(() => {
process.env = originalEnv;
});
describe('getTempChatRetentionHours', () => {
it('should return default retention hours when no config or env var is set', () => {
const result = getTempChatRetentionHours();
expect(result).toBe(DEFAULT_RETENTION_HOURS);
});
it('should use environment variable when set', () => {
process.env.TEMP_CHAT_RETENTION_HOURS = '48';
const result = getTempChatRetentionHours();
expect(result).toBe(48);
});
it('should use config value when set', () => {
const config: Partial<TCustomConfig> = {
interface: {
temporaryChatRetention: 12,
},
};
const result = getTempChatRetentionHours(config);
expect(result).toBe(12);
});
it('should prioritize config over environment variable', () => {
process.env.TEMP_CHAT_RETENTION_HOURS = '48';
const config: Partial<TCustomConfig> = {
interface: {
temporaryChatRetention: 12,
},
};
const result = getTempChatRetentionHours(config);
expect(result).toBe(12);
});
it('should enforce minimum retention period', () => {
const config: Partial<TCustomConfig> = {
interface: {
temporaryChatRetention: 0,
},
};
const result = getTempChatRetentionHours(config);
expect(result).toBe(MIN_RETENTION_HOURS);
});
it('should enforce maximum retention period', () => {
const config: Partial<TCustomConfig> = {
interface: {
temporaryChatRetention: 10000,
},
};
const result = getTempChatRetentionHours(config);
expect(result).toBe(MAX_RETENTION_HOURS);
});
it('should handle invalid environment variable', () => {
process.env.TEMP_CHAT_RETENTION_HOURS = 'invalid';
const result = getTempChatRetentionHours();
expect(result).toBe(DEFAULT_RETENTION_HOURS);
});
it('should handle invalid config value', () => {
const config: Partial<TCustomConfig> = {
interface: {
temporaryChatRetention: 'invalid' as unknown as number,
},
};
const result = getTempChatRetentionHours(config);
expect(result).toBe(DEFAULT_RETENTION_HOURS);
});
});
describe('createTempChatExpirationDate', () => {
it('should create expiration date with default retention period', () => {
const result = createTempChatExpirationDate();
const expectedDate = new Date();
expectedDate.setHours(expectedDate.getHours() + DEFAULT_RETENTION_HOURS);
// Allow for small time differences in test execution
const timeDiff = Math.abs(result.getTime() - expectedDate.getTime());
expect(timeDiff).toBeLessThan(1000); // Less than 1 second difference
});
it('should create expiration date with custom retention period', () => {
const config: Partial<TCustomConfig> = {
interface: {
temporaryChatRetention: 12,
},
};
const result = createTempChatExpirationDate(config);
const expectedDate = new Date();
expectedDate.setHours(expectedDate.getHours() + 12);
// Allow for small time differences in test execution
const timeDiff = Math.abs(result.getTime() - expectedDate.getTime());
expect(timeDiff).toBeLessThan(1000); // Less than 1 second difference
});
it('should return a Date object', () => {
const result = createTempChatExpirationDate();
expect(result).toBeInstanceOf(Date);
});
it('should return a future date', () => {
const now = new Date();
const result = createTempChatExpirationDate();
expect(result.getTime()).toBeGreaterThan(now.getTime());
});
});
});

View file

@ -0,0 +1,77 @@
import { logger } from '@librechat/data-schemas';
import type { TCustomConfig } from 'librechat-data-provider';
/**
* Default retention period for temporary chats in hours
*/
export const DEFAULT_RETENTION_HOURS = 24 * 30; // 30 days
/**
* Minimum allowed retention period in hours
*/
export const MIN_RETENTION_HOURS = 1;
/**
* Maximum allowed retention period in hours (1 year = 8760 hours)
*/
export const MAX_RETENTION_HOURS = 8760;
/**
* Gets the temporary chat retention period from environment variables or config
* @param config - The custom configuration object
* @returns The retention period in hours
*/
export function getTempChatRetentionHours(config?: Partial<TCustomConfig> | null): number {
let retentionHours = DEFAULT_RETENTION_HOURS;
// Check environment variable first
if (process.env.TEMP_CHAT_RETENTION_HOURS) {
const envValue = parseInt(process.env.TEMP_CHAT_RETENTION_HOURS, 10);
if (!isNaN(envValue)) {
retentionHours = envValue;
} else {
logger.warn(
`Invalid TEMP_CHAT_RETENTION_HOURS environment variable: ${process.env.TEMP_CHAT_RETENTION_HOURS}. Using default: ${DEFAULT_RETENTION_HOURS} hours.`,
);
}
}
// Check config file (takes precedence over environment variable)
if (config?.interface?.temporaryChatRetention !== undefined) {
const configValue = config.interface.temporaryChatRetention;
if (typeof configValue === 'number' && !isNaN(configValue)) {
retentionHours = configValue;
} else {
logger.warn(
`Invalid temporaryChatRetention in config: ${configValue}. Using ${retentionHours} hours.`,
);
}
}
// Validate the retention period
if (retentionHours < MIN_RETENTION_HOURS) {
logger.warn(
`Temporary chat retention period ${retentionHours} is below minimum ${MIN_RETENTION_HOURS} hours. Using minimum value.`,
);
retentionHours = MIN_RETENTION_HOURS;
} else if (retentionHours > MAX_RETENTION_HOURS) {
logger.warn(
`Temporary chat retention period ${retentionHours} exceeds maximum ${MAX_RETENTION_HOURS} hours. Using maximum value.`,
);
retentionHours = MAX_RETENTION_HOURS;
}
return retentionHours;
}
/**
* Creates an expiration date for temporary chats
* @param config - The custom configuration object
* @returns The expiration date
*/
export function createTempChatExpirationDate(config?: Partial<TCustomConfig>): Date {
const retentionHours = getTempChatRetentionHours(config);
const expiredAt = new Date();
expiredAt.setHours(expiredAt.getHours() + retentionHours);
return expiredAt;
}

View file

@ -0,0 +1,11 @@
import fs from 'fs';
import yaml from 'js-yaml';
export function loadYaml(filepath: string) {
try {
const fileContents = fs.readFileSync(filepath, 'utf8');
return yaml.load(fileContents);
} catch (e) {
return e;
}
}

View file

@ -510,6 +510,7 @@ export const intefaceSchema = z
prompts: z.boolean().optional(), prompts: z.boolean().optional(),
agents: z.boolean().optional(), agents: z.boolean().optional(),
temporaryChat: z.boolean().optional(), temporaryChat: z.boolean().optional(),
temporaryChatRetention: z.number().min(1).max(8760).optional(),
runCode: z.boolean().optional(), runCode: z.boolean().optional(),
webSearch: z.boolean().optional(), webSearch: z.boolean().optional(),
}) })

View file

@ -13,14 +13,10 @@ export class SessionError extends Error {
} }
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expires = REFRESH_TOKEN_EXPIRY const expires = REFRESH_TOKEN_EXPIRY ? eval(REFRESH_TOKEN_EXPIRY) : 1000 * 60 * 60 * 24 * 7; // 7 days default
? eval(REFRESH_TOKEN_EXPIRY)
: 1000 * 60 * 60 * 24 * 7; // 7 days default
// Factory function that takes mongoose instance and returns the methods // Factory function that takes mongoose instance and returns the methods
export function createSessionMethods(mongoose: typeof import('mongoose')) { export function createSessionMethods(mongoose: typeof import('mongoose')) {
const Session = mongoose.models.Session;
/** /**
* Creates a new session for a user * Creates a new session for a user
*/ */
@ -33,13 +29,14 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
} }
try { try {
const session = new Session({ const Session = mongoose.models.Session;
const currentSession = new Session({
user: userId, user: userId,
expiration: options.expiration || new Date(Date.now() + expires), expiration: options.expiration || new Date(Date.now() + expires),
}); });
const refreshToken = await generateRefreshToken(session); const refreshToken = await generateRefreshToken(currentSession);
return { session, refreshToken }; return { session: currentSession, refreshToken };
} catch (error) { } catch (error) {
logger.error('[createSession] Error creating session:', error); logger.error('[createSession] Error creating session:', error);
throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED'); throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED');
@ -54,6 +51,7 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
options: t.SessionQueryOptions = { lean: true }, options: t.SessionQueryOptions = { lean: true },
): Promise<t.ISession | null> { ): Promise<t.ISession | null> {
try { try {
const Session = mongoose.models.Session;
const query: Record<string, unknown> = {}; const query: Record<string, unknown> = {};
if (!params.refreshToken && !params.userId && !params.sessionId) { if (!params.refreshToken && !params.userId && !params.sessionId) {
@ -109,6 +107,7 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
newExpiration?: Date, newExpiration?: Date,
): Promise<t.ISession> { ): Promise<t.ISession> {
try { try {
const Session = mongoose.models.Session;
const sessionDoc = typeof session === 'string' ? await Session.findById(session) : session; const sessionDoc = typeof session === 'string' ? await Session.findById(session) : session;
if (!sessionDoc) { if (!sessionDoc) {
@ -128,6 +127,7 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
*/ */
async function deleteSession(params: t.DeleteSessionParams): Promise<{ deletedCount?: number }> { async function deleteSession(params: t.DeleteSessionParams): Promise<{ deletedCount?: number }> {
try { try {
const Session = mongoose.models.Session;
if (!params.refreshToken && !params.sessionId) { if (!params.refreshToken && !params.sessionId) {
throw new SessionError( throw new SessionError(
'Either refreshToken or sessionId is required', 'Either refreshToken or sessionId is required',
@ -166,6 +166,7 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
options: t.DeleteAllSessionsOptions = {}, options: t.DeleteAllSessionsOptions = {},
): Promise<{ deletedCount?: number }> { ): Promise<{ deletedCount?: number }> {
try { try {
const Session = mongoose.models.Session;
if (!userId) { if (!userId) {
throw new SessionError('User ID is required', 'INVALID_USER_ID'); throw new SessionError('User ID is required', 'INVALID_USER_ID');
} }
@ -237,6 +238,7 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
*/ */
async function countActiveSessions(userId: string): Promise<number> { async function countActiveSessions(userId: string): Promise<number> {
try { try {
const Session = mongoose.models.Session;
if (!userId) { if (!userId) {
throw new SessionError('User ID is required', 'INVALID_USER_ID'); throw new SessionError('User ID is required', 'INVALID_USER_ID');
} }