diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 110d2fdd57..faf3905349 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -1,6 +1,10 @@ -const { sendEvent } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { Constants } = require('librechat-data-provider'); +const { + sendEvent, + sanitizeFileForTransmit, + sanitizeMessageForTransmit, +} = require('@librechat/api'); const { handleAbortError, createAbortController, @@ -224,13 +228,13 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => { conversation.title = 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) { userMessage.files = []; 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)) { - userMessage.files.push({ ...attachment }); + userMessage.files.push(sanitizeFileForTransmit(attachment)); } } delete userMessage.image_urls; @@ -245,7 +249,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => { final: true, conversation, title: conversation.title, - requestMessage: userMessage, + requestMessage: sanitizeMessageForTransmit(userMessage), responseMessage: finalResponse, }); res.end(); @@ -273,7 +277,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => { final: true, conversation, title: conversation.title, - requestMessage: userMessage, + requestMessage: sanitizeMessageForTransmit(userMessage), responseMessage: finalResponse, error: { message: 'Request was aborted during completion' }, }); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 9ceff1bc02..1f762ca808 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -1,5 +1,5 @@ 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 { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const clearPendingReq = require('~/cache/clearPendingReq'); @@ -290,7 +290,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => { title: conversation && !conversation.title ? null : conversation?.title || 'New Chat', final: true, conversation, - requestMessage: userMessage, + requestMessage: sanitizeMessageForTransmit(userMessage), responseMessage: responseMessage, }; }; diff --git a/api/server/middleware/error.js b/api/server/middleware/error.js index 270663a46c..fef7e60ef7 100644 --- a/api/server/middleware/error.js +++ b/api/server/middleware/error.js @@ -1,7 +1,7 @@ const crypto = require('crypto'); const { logger } = require('@librechat/data-schemas'); 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 { getConvo } = require('~/models/Conversation'); @@ -71,7 +71,7 @@ const sendError = async (req, res, options, callback) => { return sendEvent(res, { final: true, - requestMessage: query?.[0] ? query[0] : requestMessage, + requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage), responseMessage: errorMessage, conversation: convo, }); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 76f11289cb..2aa02bcdd3 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -21,3 +21,4 @@ export { default as Tokenizer, countTokens } from './tokenizer'; export * from './yaml'; export * from './http'; export * from './tokens'; +export * from './message'; diff --git a/packages/api/src/utils/message.spec.ts b/packages/api/src/utils/message.spec.ts new file mode 100644 index 0000000000..144ebc1a92 --- /dev/null +++ b/packages/api/src/utils/message.spec.ts @@ -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'); + }); +}); diff --git a/packages/api/src/utils/message.ts b/packages/api/src/utils/message.ts new file mode 100644 index 0000000000..312826b6ba --- /dev/null +++ b/packages/api/src/utils/message.ts @@ -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>( + file: T, +): Omit { + 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>( + message: T, +): Omit { + if (!message) { + return message as Omit; + } + + 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; +}