🕐 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

@ -76,6 +76,7 @@
"diff": "^7.0.0",
"eventsource": "^3.0.2",
"express": "^4.21.2",
"js-yaml": "^4.1.0",
"keyv": "^5.3.2",
"librechat-data-provider": "*",
"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`);
}
/**
* 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 './generators';
export * from './llm';
export * from './math';
export * from './openid';
export * from './tempChatRetention';
export { default as Tokenizer } from './tokenizer';
export * from './yaml';

View file

@ -0,0 +1,45 @@
/**
* Evaluates a mathematical expression provided as a string and returns the result.
*
* If the input is already a number, it returns the number as is.
* 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.
*
* @param str - The mathematical expression to evaluate, or 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 The result of the evaluated expression or the input number.
*
* @throws Throws an error if the input is not a string or number, contains invalid characters, or does not evaluate to a number.
*/
export function math(str: string | number, fallbackValue?: number): number {
const fallback = typeof fallbackValue !== 'undefined' && typeof fallbackValue === 'number';
if (typeof str !== 'string' && typeof str === 'number') {
return str;
} else if (typeof str !== 'string') {
if (fallback) {
return fallbackValue;
}
throw new Error(`str is ${typeof str}, but should be a string`);
}
const validStr = /^[+\-\d.\s*/%()]+$/.test(str);
if (!validStr) {
if (fallback) {
return fallbackValue;
}
throw new Error('Invalid characters in string');
}
const value = eval(str);
if (typeof value !== 'number') {
if (fallback) {
return fallbackValue;
}
throw new Error(`[math] str did not evaluate to a number but to a ${typeof value}`);
}
return value;
}

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;
}
}