🛟 fix: Keep File Uploads Alive With SSE Heartbeats (#14295)
Some checks are pending
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
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
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
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run

* fix: Use SSE to upload files in order to avoid idle timeouts.  Idle timeouts can occur for example from gateways and other services like cloudfare when uploading large files.  For example during rag processing the file is uploaded to librechat which then sends it to rag.  While librechat is waiting for the embeddings to come back from rag the file upload is sitting idle.  Gateways tend to want to cancel the upload with an http 408 , 504, or 524.  This change uses SSE to perform the upload so that while librechat is sending the file to rag, it consistently sends back a heartbeat event to the client to keep the connection alive.  This is especially useful when utilizing  EMBEDDING_BATCH_SIZE in librechat rag which will allow rag to process signifigantly larger files without running out of memory.

* added tests to packages\api\src\files\sse.spec.ts in order to test the new sse.ts

* fix: Harden SSE file upload lifecycle

* style: Sort data provider imports

---------

Co-authored-by: Marc Amick <MarcAmick@jhu.edu>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
MarcAmick 2026-07-21 19:27:09 -04:00 committed by GitHub
parent 7406f5d79e
commit ade02054c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 919 additions and 37 deletions

View file

@ -276,6 +276,7 @@ router.get('/', async function (req, res) {
: 0,
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED),
};
const webSearch = buildWebSearchConfig(appConfig);

View file

@ -5,6 +5,8 @@ const {
logAxiosError,
refreshS3FileUrls,
handleFilesUsageRequest,
shouldUseUploadSse,
startUploadSseStream,
resolveUploadErrorMessage,
verifyAgentUploadPermission,
} = require('@librechat/api');
@ -633,6 +635,15 @@ router.post('/', async (req, res) => {
const metadata = req.body;
let cleanup = true;
/** Opened only once auth/validation has passed, right before the potentially
* long-running upload processing begins see `startUploadSseStream`. */
let sseStream = null;
const openSseStreamIfRequested = () => {
if (shouldUseUploadSse(req)) {
sseStream = startUploadSseStream(res);
}
};
try {
filterFile({ req });
@ -640,7 +651,8 @@ router.post('/', async (req, res) => {
metadata.file_id = req.file_id;
if (isAssistantsEndpoint(metadata.endpoint)) {
return await processFileUpload({ req, res, metadata });
openSseStreamIfRequested();
return await processFileUpload({ req, res, metadata, sseStream });
}
let skipUploadAuth = false;
@ -663,7 +675,8 @@ router.post('/', async (req, res) => {
}
}
return await processAgentFileUpload({ req, res, metadata });
openSseStreamIfRequested();
return await processAgentFileUpload({ req, res, metadata, sseStream });
} catch (error) {
const message = resolveUploadErrorMessage(error);
logger.error('[/files] Error processing file:', error);
@ -674,7 +687,23 @@ router.post('/', async (req, res) => {
} catch (error) {
logger.error('[/files] Error deleting file:', error);
}
res.status(500).json({ message });
let errorStatusCode = 500;
if (error.userErrorStatusCode) {
errorStatusCode = error.userErrorStatusCode;
}
if (sseStream) {
sseStream.sendError({
message,
code: errorStatusCode,
temp_file_id: metadata.temp_file_id,
tool_resource: metadata.tool_resource,
display_to_user: true,
});
} else {
res.status(errorStatusCode).json({ message });
}
} finally {
if (cleanup) {
try {
@ -685,6 +714,9 @@ router.post('/', async (req, res) => {
} else {
logger.debug('[/files] File processing completed without cleanup');
}
if (sseStream) {
sseStream.close();
}
}
});

View file

@ -2,7 +2,12 @@ const path = require('path');
const fs = require('fs').promises;
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { verifyAgentUploadPermission, resolveUploadErrorMessage } = require('@librechat/api');
const {
shouldUseUploadSse,
startUploadSseStream,
resolveUploadErrorMessage,
verifyAgentUploadPermission,
} = require('@librechat/api');
const { isAssistantsEndpoint } = require('librechat-data-provider');
const {
processAgentFileUpload,
@ -18,6 +23,15 @@ router.post('/', async (req, res) => {
const metadata = req.body;
const appConfig = req.config;
/** Opened only once auth/validation has passed, right before the potentially
* long-running upload processing begins see `startUploadSseStream`. */
let sseStream = null;
const openSseStreamIfRequested = () => {
if (shouldUseUploadSse(req)) {
sseStream = startUploadSseStream(res);
}
};
try {
filterFile({ req, image: true });
@ -35,10 +49,12 @@ router.post('/', async (req, res) => {
if (denied) {
return;
}
return await processAgentFileUpload({ req, res, metadata });
openSseStreamIfRequested();
return await processAgentFileUpload({ req, res, metadata, sseStream });
}
await processImageFile({ req, res, metadata });
openSseStreamIfRequested();
await processImageFile({ req, res, metadata, sseStream });
} catch (error) {
// TODO: delete remote file if it exists
logger.error('[/files/images] Error processing file:', error);
@ -55,7 +71,17 @@ router.post('/', async (req, res) => {
} catch (error) {
logger.error('[/files/images] Error deleting file:', error);
}
res.status(500).json({ message });
if (sseStream) {
sseStream.sendError({
message,
code: 500,
temp_file_id: metadata.temp_file_id,
tool_resource: metadata.tool_resource,
display_to_user: true,
});
} else {
res.status(500).json({ message });
}
} finally {
try {
await fs.unlink(req.file.path);
@ -63,6 +89,9 @@ router.post('/', async (req, res) => {
} catch {
logger.debug('[/files/images] Temp. image upload file already deleted');
}
if (sseStream) {
sseStream.close();
}
}
});

View file

@ -24,6 +24,7 @@ const {
sanitizeFilename,
parseText,
processAudioFile,
sendUploadSuccess,
getStorageMetadata,
sweepExpiredFiles: sweepExpiredFilesWithDeps,
startExpiredFileSweep: startExpiredFileSweepWithDeps,
@ -447,9 +448,10 @@ const processFileURL = async ({
* @param {Express.Response} [params.res] - The Express response object.
* @param {ImageMetadata} params.metadata - Additional metadata for the file.
* @param {boolean} params.returnFile - Whether to return the file metadata or return response as normal.
* @param {import('@librechat/api').UploadSseStream | null} [params.sseStream] - Active upload SSE stream, if enabled.
* @returns {Promise<void>}
*/
const processImageFile = async ({ req, res, metadata, returnFile = false }) => {
const processImageFile = async ({ req, res, metadata, returnFile = false, sseStream }) => {
const { file } = req;
const appConfig = req.config;
const source = getFileStrategy(appConfig, { isImage: true });
@ -487,7 +489,7 @@ const processImageFile = async ({ req, res, metadata, returnFile = false }) => {
if (returnFile) {
return result;
}
res.status(200).json({ message: 'File uploaded and processed successfully', ...result });
sendUploadSuccess(res, sseStream, 'File uploaded and processed successfully', result);
};
/**
@ -554,9 +556,10 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true })
* @param {ServerRequest} params.req - The Express request object.
* @param {Express.Response} params.res - The Express response object.
* @param {FileMetadata} params.metadata - Additional metadata for the file.
* @param {import('@librechat/api').UploadSseStream | null} [params.sseStream] - Active upload SSE stream, if enabled.
* @returns {Promise<void>}
*/
const processFileUpload = async ({ req, res, metadata }) => {
const processFileUpload = async ({ req, res, metadata, sseStream }) => {
const appConfig = req.config;
const isAssistantUpload = isAssistantsEndpoint(metadata.endpoint);
const assistantSource =
@ -649,7 +652,7 @@ const processFileUpload = async ({ req, res, metadata }) => {
},
true,
);
res.status(200).json({ message: 'File uploaded and processed successfully', ...result });
sendUploadSuccess(res, sseStream, 'File uploaded and processed successfully', result);
};
/**
@ -661,9 +664,10 @@ const processFileUpload = async ({ req, res, metadata }) => {
* @param {ServerRequest} params.req - The Express request object.
* @param {Express.Response} params.res - The Express response object.
* @param {FileMetadata} params.metadata - Additional metadata for the file.
* @param {import('@librechat/api').UploadSseStream | null} [params.sseStream] - Active upload SSE stream, if enabled.
* @returns {Promise<void>}
*/
const processAgentFileUpload = async ({ req, res, metadata }) => {
const processAgentFileUpload = async ({ req, res, metadata, sseStream }) => {
const { file } = req;
const appConfig = req.config;
const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata;
@ -787,9 +791,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
});
}
const result = await db.createFile(fileInfo, true);
return res
.status(200)
.json({ message: 'Agent file uploaded and processed successfully', ...result });
sendUploadSuccess(res, sseStream, 'Agent file uploaded and processed successfully', result);
};
const fileConfig = mergeFileConfig(appConfig.fileConfig);
@ -1035,7 +1037,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
const result = await db.createFile(fileInfo, true);
res.status(200).json({ message: 'Agent file uploaded and processed successfully', ...result });
sendUploadSuccess(res, sseStream, 'Agent file uploaded and processed successfully', result);
};
/**

View file

@ -41,6 +41,13 @@ jest.mock('@librechat/api', () => {
sanitizeFilename: jest.fn((n) => n),
parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }),
processAudioFile: jest.fn(),
sendUploadSuccess: jest.fn((res, sseStream, message, result) => {
if (sseStream) {
sseStream.sendData({ message, ...result });
return;
}
res.status(200).json({ message, ...result });
}),
getStorageMetadata: jest.fn(() => ({})),
getRetentionExpiry,
getAgentFileRetentionExpiry: jest.fn(({ req, messageAttachment, toolResource }) => {