mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 08:20:14 +01:00
📦 refactor: Request Message Sanitization for Smaller Final Response (#10792)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* refactor: implement sanitizeFileForTransmit and sanitizeMessageForTransmit functions for smaller payload to client transmission * refactor: enhance sanitizeMessageForTransmit to preserve empty files array and avoid mutating original message * refactor: update sanitizeMessageForTransmit to ensure immutability of files array and improve test clarity
This commit is contained in:
parent
711d21365d
commit
2d536dd0fa
6 changed files with 205 additions and 10 deletions
|
|
@ -1,6 +1,10 @@
|
||||||
const { sendEvent } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
const {
|
||||||
|
sendEvent,
|
||||||
|
sanitizeFileForTransmit,
|
||||||
|
sanitizeMessageForTransmit,
|
||||||
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
handleAbortError,
|
handleAbortError,
|
||||||
createAbortController,
|
createAbortController,
|
||||||
|
|
@ -224,13 +228,13 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
|
|
||||||
// Process files if needed
|
// Process files if needed (sanitize to remove large text fields before transmission)
|
||||||
if (req.body.files && client.options?.attachments) {
|
if (req.body.files && client.options?.attachments) {
|
||||||
userMessage.files = [];
|
userMessage.files = [];
|
||||||
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
|
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
|
||||||
for (let attachment of client.options.attachments) {
|
for (const attachment of client.options.attachments) {
|
||||||
if (messageFiles.has(attachment.file_id)) {
|
if (messageFiles.has(attachment.file_id)) {
|
||||||
userMessage.files.push({ ...attachment });
|
userMessage.files.push(sanitizeFileForTransmit(attachment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete userMessage.image_urls;
|
delete userMessage.image_urls;
|
||||||
|
|
@ -245,7 +249,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
requestMessage: userMessage,
|
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||||
responseMessage: finalResponse,
|
responseMessage: finalResponse,
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
|
|
@ -273,7 +277,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
requestMessage: userMessage,
|
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||||
responseMessage: finalResponse,
|
responseMessage: finalResponse,
|
||||||
error: { message: 'Request was aborted during completion' },
|
error: { message: 'Request was aborted during completion' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
|
const { countTokens, isEnabled, sendEvent, sanitizeMessageForTransmit } = require('@librechat/api');
|
||||||
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
|
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
|
||||||
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
||||||
const clearPendingReq = require('~/cache/clearPendingReq');
|
const clearPendingReq = require('~/cache/clearPendingReq');
|
||||||
|
|
@ -290,7 +290,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
||||||
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
|
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
requestMessage: userMessage,
|
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||||
responseMessage: responseMessage,
|
responseMessage: responseMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
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 { sendEvent, handleError, sanitizeMessageForTransmit } = 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');
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ const sendError = async (req, res, options, callback) => {
|
||||||
|
|
||||||
return sendEvent(res, {
|
return sendEvent(res, {
|
||||||
final: true,
|
final: true,
|
||||||
requestMessage: query?.[0] ? query[0] : requestMessage,
|
requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
|
||||||
responseMessage: errorMessage,
|
responseMessage: errorMessage,
|
||||||
conversation: convo,
|
conversation: convo,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,4 @@ export { default as Tokenizer, countTokens } from './tokenizer';
|
||||||
export * from './yaml';
|
export * from './yaml';
|
||||||
export * from './http';
|
export * from './http';
|
||||||
export * from './tokens';
|
export * from './tokens';
|
||||||
|
export * from './message';
|
||||||
|
|
|
||||||
122
packages/api/src/utils/message.spec.ts
Normal file
122
packages/api/src/utils/message.spec.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { sanitizeFileForTransmit, sanitizeMessageForTransmit } from './message';
|
||||||
|
|
||||||
|
describe('sanitizeFileForTransmit', () => {
|
||||||
|
it('should remove text field from file', () => {
|
||||||
|
const file = {
|
||||||
|
file_id: 'test-123',
|
||||||
|
filename: 'test.txt',
|
||||||
|
text: 'This is a very long text content that should be stripped',
|
||||||
|
bytes: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = sanitizeFileForTransmit(file);
|
||||||
|
|
||||||
|
expect(result.file_id).toBe('test-123');
|
||||||
|
expect(result.filename).toBe('test.txt');
|
||||||
|
expect(result.bytes).toBe(1000);
|
||||||
|
expect(result).not.toHaveProperty('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove _id and __v fields', () => {
|
||||||
|
const file = {
|
||||||
|
file_id: 'test-123',
|
||||||
|
_id: 'mongo-id',
|
||||||
|
__v: 0,
|
||||||
|
filename: 'test.txt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = sanitizeFileForTransmit(file);
|
||||||
|
|
||||||
|
expect(result.file_id).toBe('test-123');
|
||||||
|
expect(result).not.toHaveProperty('_id');
|
||||||
|
expect(result).not.toHaveProperty('__v');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify original file object', () => {
|
||||||
|
const file = {
|
||||||
|
file_id: 'test-123',
|
||||||
|
text: 'original text',
|
||||||
|
};
|
||||||
|
|
||||||
|
sanitizeFileForTransmit(file);
|
||||||
|
|
||||||
|
expect(file.text).toBe('original text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeMessageForTransmit', () => {
|
||||||
|
it('should remove fileContext from message', () => {
|
||||||
|
const message = {
|
||||||
|
messageId: 'msg-123',
|
||||||
|
text: 'Hello world',
|
||||||
|
fileContext: 'This is a very long context that should be stripped',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = sanitizeMessageForTransmit(message);
|
||||||
|
|
||||||
|
expect(result.messageId).toBe('msg-123');
|
||||||
|
expect(result.text).toBe('Hello world');
|
||||||
|
expect(result).not.toHaveProperty('fileContext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize files array', () => {
|
||||||
|
const message = {
|
||||||
|
messageId: 'msg-123',
|
||||||
|
files: [
|
||||||
|
{ file_id: 'file-1', text: 'long text 1', filename: 'a.txt' },
|
||||||
|
{ file_id: 'file-2', text: 'long text 2', filename: 'b.txt' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = sanitizeMessageForTransmit(message);
|
||||||
|
|
||||||
|
expect(result.files).toHaveLength(2);
|
||||||
|
expect(result.files?.[0].file_id).toBe('file-1');
|
||||||
|
expect(result.files?.[0].filename).toBe('a.txt');
|
||||||
|
expect(result.files?.[0]).not.toHaveProperty('text');
|
||||||
|
expect(result.files?.[1]).not.toHaveProperty('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined message', () => {
|
||||||
|
expect(sanitizeMessageForTransmit(null as unknown as object)).toBeNull();
|
||||||
|
expect(sanitizeMessageForTransmit(undefined as unknown as object)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle message without files', () => {
|
||||||
|
const message = {
|
||||||
|
messageId: 'msg-123',
|
||||||
|
text: 'Hello',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = sanitizeMessageForTransmit(message);
|
||||||
|
|
||||||
|
expect(result.messageId).toBe('msg-123');
|
||||||
|
expect(result.text).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new array reference for empty files array (immutability)', () => {
|
||||||
|
const message = {
|
||||||
|
messageId: 'msg-123',
|
||||||
|
files: [] as { file_id: string }[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = sanitizeMessageForTransmit(message);
|
||||||
|
|
||||||
|
expect(result.files).toEqual([]);
|
||||||
|
// New array reference ensures full immutability even for empty arrays
|
||||||
|
expect(result.files).not.toBe(message.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify original message object', () => {
|
||||||
|
const message = {
|
||||||
|
messageId: 'msg-123',
|
||||||
|
fileContext: 'original context',
|
||||||
|
files: [{ file_id: 'file-1', text: 'original text' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
sanitizeMessageForTransmit(message);
|
||||||
|
|
||||||
|
expect(message.fileContext).toBe('original context');
|
||||||
|
expect(message.files[0].text).toBe('original text');
|
||||||
|
});
|
||||||
|
});
|
||||||
68
packages/api/src/utils/message.ts
Normal file
68
packages/api/src/utils/message.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { TFile, TMessage } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
/** Fields to strip from files before client transmission */
|
||||||
|
const FILE_STRIP_FIELDS = ['text', '_id', '__v'] as const;
|
||||||
|
|
||||||
|
/** Fields to strip from messages before client transmission */
|
||||||
|
const MESSAGE_STRIP_FIELDS = ['fileContext'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips large/unnecessary fields from a file object before transmitting to client.
|
||||||
|
* Use this within existing loops when building file arrays to avoid extra iterations.
|
||||||
|
*
|
||||||
|
* @param file - The file object to sanitize
|
||||||
|
* @returns A new file object without the stripped fields
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Use in existing file processing loop:
|
||||||
|
* for (const attachment of client.options.attachments) {
|
||||||
|
* if (messageFiles.has(attachment.file_id)) {
|
||||||
|
* userMessage.files.push(sanitizeFileForTransmit(attachment));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function sanitizeFileForTransmit<T extends Partial<TFile>>(
|
||||||
|
file: T,
|
||||||
|
): Omit<T, (typeof FILE_STRIP_FIELDS)[number]> {
|
||||||
|
const sanitized = { ...file };
|
||||||
|
for (const field of FILE_STRIP_FIELDS) {
|
||||||
|
delete sanitized[field as keyof typeof sanitized];
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a message object before transmitting to client.
|
||||||
|
* Removes large fields like `fileContext` and strips `text` from embedded files.
|
||||||
|
*
|
||||||
|
* @param message - The message object to sanitize
|
||||||
|
* @returns A new message object safe for client transmission
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* sendEvent(res, {
|
||||||
|
* final: true,
|
||||||
|
* requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||||
|
* responseMessage: response,
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function sanitizeMessageForTransmit<T extends Partial<TMessage>>(
|
||||||
|
message: T,
|
||||||
|
): Omit<T, (typeof MESSAGE_STRIP_FIELDS)[number]> {
|
||||||
|
if (!message) {
|
||||||
|
return message as Omit<T, (typeof MESSAGE_STRIP_FIELDS)[number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = { ...message };
|
||||||
|
|
||||||
|
// Remove message-level fields
|
||||||
|
for (const field of MESSAGE_STRIP_FIELDS) {
|
||||||
|
delete sanitized[field as keyof typeof sanitized];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always create a new array when files exist to maintain full immutability
|
||||||
|
if (Array.isArray(sanitized.files)) {
|
||||||
|
sanitized.files = sanitized.files.map((file) => sanitizeFileForTransmit(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue