mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-20 17:13:44 +00:00
🚏 fix: Route Admin-Configured Document Types to RAG /text on Agent Upload (#14345)
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
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (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
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🩹 fix: Route configured document types to RAG /text on agent upload Restores pre-#11900 behavior for 'Upload as Text': when an admin narrows fileConfig.text.supportedMimeTypes to a non-permissive allowlist that includes a document type (docx/xlsx/pdf/ods/odt) and a RAG API is configured, the file is sent to RAG /text instead of the built-in document parser. The permissive default catch-all is excluded via isPermissiveMimeConfig, so RAG deployments that never customized text handling keep the built-in parser. When RAG is unreachable, parseText's new allowNativeFallback:false makes it throw so the upload falls back to the built-in document parser rather than degrading a docx/pdf to raw native-text bytes. Fixes #14245 * style: sort imports in text.spec.ts (CI import-order) * 🩹 fix: Scope RAG fallback catch to extraction only, not persistence The configured-text branch wrapped both parseText and createTextFile in the fallback try, so a persistence failure after a successful RAG extraction (size guard, db.createFile, agent-resource mutation) was misread as RAG-unavailable and retried with the built-in document parser, masking the real error and risking a duplicate agent-resource mutation. Only the RAG extraction is now in the fallback catch; a persistence failure surfaces as itself. Addresses Codex P2 on #14345.
This commit is contained in:
parent
f4a0e0c194
commit
3337bde050
4 changed files with 246 additions and 16 deletions
|
|
@ -17,6 +17,7 @@ const {
|
|||
isAssistantsEndpoint,
|
||||
getEndpointFileConfig,
|
||||
documentParserMimeTypes,
|
||||
isPermissiveMimeConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { logger, runAsSystem } = require('@librechat/data-schemas');
|
||||
const {
|
||||
|
|
@ -797,8 +798,25 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
appConfig?.ocr != null &&
|
||||
fileConfig.checkType(file.mimetype, fileConfig.ocr?.supportedMimeTypes || []);
|
||||
|
||||
const isDocumentParserEligible = documentParserMimeTypes.some((regex) =>
|
||||
regex.test(file.mimetype),
|
||||
);
|
||||
|
||||
/**
|
||||
* When an admin narrows `fileConfig.text.supportedMimeTypes` to a non-permissive allowlist that
|
||||
* includes a document type and a RAG API is configured, honor that intent by sending the file to
|
||||
* RAG `/text` instead of the built-in document parser. The permissive default catch-all is
|
||||
* excluded via `isPermissiveMimeConfig`, so RAG deployments that never customized text handling
|
||||
* keep the built-in parser introduced in #11900.
|
||||
*/
|
||||
const shouldUseConfiguredText =
|
||||
!!process.env.RAG_API_URL &&
|
||||
isDocumentParserEligible &&
|
||||
!isPermissiveMimeConfig(fileConfig.text?.supportedMimeTypes) &&
|
||||
fileConfig.checkType(file.mimetype, fileConfig.text?.supportedMimeTypes || []);
|
||||
|
||||
const shouldUseDocumentParser =
|
||||
!shouldUseConfiguredOCR && documentParserMimeTypes.some((regex) => regex.test(file.mimetype));
|
||||
!shouldUseConfiguredOCR && !shouldUseConfiguredText && isDocumentParserEligible;
|
||||
|
||||
const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser;
|
||||
|
||||
|
|
@ -861,6 +879,38 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
throw new Error(`File type ${file.mimetype} is not supported for text parsing.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A document type the admin routed to configured text extraction: prefer RAG `/text`, but fall
|
||||
* back to the built-in document parser (not raw native text) when RAG is unavailable, so a
|
||||
* transient outage doesn't degrade a docx/pdf to unreadable bytes. Only the RAG extraction is
|
||||
* inside the fallback catch: a downstream persistence failure (size guard, DB, agent-resource
|
||||
* mutation) must surface as itself, not trigger a second extraction attempt.
|
||||
*/
|
||||
if (shouldUseConfiguredText) {
|
||||
let configuredText;
|
||||
try {
|
||||
configuredText = await parseText({ req, file, file_id, allowNativeFallback: false });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[processAgentFileUpload] Configured RAG text extraction unavailable for "${file.originalname}", using built-in document parser:`,
|
||||
err,
|
||||
);
|
||||
const documentText = await resolveDocumentText();
|
||||
if (!documentText) {
|
||||
throw new Error(
|
||||
`Unable to extract text from "${file.originalname}". RAG text extraction was unavailable and the built-in parser produced no result.`,
|
||||
);
|
||||
}
|
||||
const { text, bytes, filepath: docFileURL } = documentText;
|
||||
return await createTextFile({ text, bytes, filepath: docFileURL });
|
||||
}
|
||||
return await createTextFile({
|
||||
text: configuredText.text,
|
||||
bytes: configuredText.bytes,
|
||||
type: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
const { text, bytes } = await parseText({ req, file, file_id });
|
||||
return await createTextFile({ text, bytes, type: file.mimetype });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,11 +188,12 @@ const mockRes = {
|
|||
json: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({
|
||||
checkType: (mime, types) => (types ?? []).includes(mime),
|
||||
const makeFileConfig = ({ ocrSupportedMimeTypes = [], textSupportedMimeTypes = [] } = {}) => ({
|
||||
checkType: (mime, types) =>
|
||||
(types ?? []).some((t) => (typeof t === 'string' ? t === mime : t.test(mime))),
|
||||
ocr: { supportedMimeTypes: ocrSupportedMimeTypes },
|
||||
stt: { supportedMimeTypes: [] },
|
||||
text: { supportedMimeTypes: [] },
|
||||
text: { supportedMimeTypes: textSupportedMimeTypes },
|
||||
});
|
||||
|
||||
const setupStoredFileUpload = (result = {}) => {
|
||||
|
|
@ -392,6 +393,100 @@ describe('processAgentFileUpload', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('configured text (RAG) routing for document MIME types', () => {
|
||||
const DOCX_TEXT_REGEX = [
|
||||
/^application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document$/,
|
||||
];
|
||||
let originalRagUrl;
|
||||
|
||||
beforeEach(() => {
|
||||
originalRagUrl = process.env.RAG_API_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalRagUrl === undefined) {
|
||||
delete process.env.RAG_API_URL;
|
||||
} else {
|
||||
process.env.RAG_API_URL = originalRagUrl;
|
||||
}
|
||||
});
|
||||
|
||||
test('routes a document type to RAG /text (no native fallback) when admin narrows text config and RAG is set', async () => {
|
||||
process.env.RAG_API_URL = 'http://rag-api.test';
|
||||
mergeFileConfig.mockReturnValue(makeFileConfig({ textSupportedMimeTypes: DOCX_TEXT_REGEX }));
|
||||
const { parseText } = require('@librechat/api');
|
||||
parseText.mockResolvedValueOnce({ text: 'rag extracted', bytes: 13 });
|
||||
const req = makeReq({ mimetype: DOCX_MIME, ocrConfig: null });
|
||||
|
||||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||||
|
||||
expect(parseText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowNativeFallback: false }),
|
||||
);
|
||||
expect(getStrategyFunctions).not.toHaveBeenCalledWith(FileSources.document_parser);
|
||||
});
|
||||
|
||||
test('keeps the built-in document parser when text config is the permissive default', async () => {
|
||||
process.env.RAG_API_URL = 'http://rag-api.test';
|
||||
mergeFileConfig.mockReturnValue(
|
||||
makeFileConfig({ textSupportedMimeTypes: [/^[\w.-]+\/[\w.-]+$/] }),
|
||||
);
|
||||
const { parseText } = require('@librechat/api');
|
||||
const req = makeReq({ mimetype: DOCX_MIME, ocrConfig: null });
|
||||
|
||||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||||
|
||||
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||||
expect(parseText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('keeps the built-in document parser when RAG_API_URL is not configured', async () => {
|
||||
delete process.env.RAG_API_URL;
|
||||
mergeFileConfig.mockReturnValue(makeFileConfig({ textSupportedMimeTypes: DOCX_TEXT_REGEX }));
|
||||
const { parseText } = require('@librechat/api');
|
||||
const req = makeReq({ mimetype: DOCX_MIME, ocrConfig: null });
|
||||
|
||||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||||
|
||||
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||||
expect(parseText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('falls back to the built-in document parser (not native text) when RAG is unavailable', async () => {
|
||||
process.env.RAG_API_URL = 'http://rag-api.test';
|
||||
mergeFileConfig.mockReturnValue(makeFileConfig({ textSupportedMimeTypes: DOCX_TEXT_REGEX }));
|
||||
const { parseText } = require('@librechat/api');
|
||||
parseText.mockRejectedValueOnce(new Error('native fallback is disabled'));
|
||||
const req = makeReq({ mimetype: DOCX_MIME, ocrConfig: null });
|
||||
|
||||
await expect(
|
||||
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(parseText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowNativeFallback: false }),
|
||||
);
|
||||
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||||
});
|
||||
|
||||
test('surfaces a persistence failure without retrying via the document parser', async () => {
|
||||
process.env.RAG_API_URL = 'http://rag-api.test';
|
||||
mergeFileConfig.mockReturnValue(makeFileConfig({ textSupportedMimeTypes: DOCX_TEXT_REGEX }));
|
||||
const { parseText } = require('@librechat/api');
|
||||
parseText.mockResolvedValueOnce({ text: 'rag extracted', bytes: 13 });
|
||||
// RAG extraction succeeds, but persisting the result fails.
|
||||
db.createFile.mockRejectedValueOnce(new Error('DB down'));
|
||||
const req = makeReq({ mimetype: DOCX_MIME, ocrConfig: null });
|
||||
|
||||
await expect(
|
||||
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||||
).rejects.toThrow('DB down');
|
||||
|
||||
// The persistence failure must not trigger a second extraction via the built-in parser.
|
||||
expect(getStrategyFunctions).not.toHaveBeenCalledWith(FileSources.document_parser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text size guard', () => {
|
||||
test('throws before writing to MongoDB when extracted text exceeds 15MB', async () => {
|
||||
const oversizedText = 'x'.repeat(15 * 1024 * 1024 + 1);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FileSources } from 'librechat-data-provider';
|
||||
import { Readable } from 'stream';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
|
|
@ -368,5 +368,76 @@ describe('text', () => {
|
|||
expect.objectContaining({ timeout: 300000 }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('allowNativeFallback: false', () => {
|
||||
const docFile = {
|
||||
...mockFile,
|
||||
mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
originalname: 'report.docx',
|
||||
} as Express.Multer.File;
|
||||
|
||||
it('should throw instead of native parsing when RAG_API_URL is not defined', async () => {
|
||||
await expect(
|
||||
parseText({
|
||||
req: mockReq,
|
||||
file: docFile,
|
||||
file_id: mockFileId,
|
||||
allowNativeFallback: false,
|
||||
}),
|
||||
).rejects.toThrow('native fallback is disabled');
|
||||
expect(mockedReadFileAsString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw instead of native parsing when the health check fails', async () => {
|
||||
process.env.RAG_API_URL = 'http://rag-api.test';
|
||||
mockedAxios.get.mockRejectedValue(new Error('Health check failed'));
|
||||
|
||||
await expect(
|
||||
parseText({
|
||||
req: mockReq,
|
||||
file: docFile,
|
||||
file_id: mockFileId,
|
||||
allowNativeFallback: false,
|
||||
}),
|
||||
).rejects.toThrow('native fallback is disabled');
|
||||
expect(mockedReadFileAsString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw instead of native parsing when the RAG text call fails', async () => {
|
||||
process.env.RAG_API_URL = 'http://rag-api.test';
|
||||
mockedAxios.get.mockResolvedValue({ status: 200, statusText: 'OK' });
|
||||
mockedAxios.post.mockRejectedValue(new Error('RAG boom'));
|
||||
|
||||
await expect(
|
||||
parseText({
|
||||
req: mockReq,
|
||||
file: docFile,
|
||||
file_id: mockFileId,
|
||||
allowNativeFallback: false,
|
||||
}),
|
||||
).rejects.toThrow('native fallback is disabled');
|
||||
expect(mockedReadFileAsString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the RAG result when RAG is available', async () => {
|
||||
process.env.RAG_API_URL = 'http://rag-api.test';
|
||||
const mockText = 'extracted docx text';
|
||||
mockedAxios.get.mockResolvedValue({ status: 200, statusText: 'OK' });
|
||||
mockedAxios.post.mockResolvedValue({ data: { text: mockText } });
|
||||
|
||||
const result = await parseText({
|
||||
req: mockReq,
|
||||
file: docFile,
|
||||
file_id: mockFileId,
|
||||
allowNativeFallback: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: mockText,
|
||||
bytes: Buffer.byteLength(mockText, 'utf8'),
|
||||
source: FileSources.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,25 +34,39 @@ function isMarkdownFile(file: Express.Multer.File): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse text using RAG API, falls back to native text parsing
|
||||
* Attempts to parse text using RAG API, falls back to native text parsing.
|
||||
* @param params - The parameters object
|
||||
* @param params.req - The Express request object
|
||||
* @param params.file - The uploaded file
|
||||
* @param params.file_id - The file ID
|
||||
* @param params.allowNativeFallback - When false, throw instead of falling back to native parsing
|
||||
* if the RAG API is unavailable. Callers handling document types (docx/pdf/etc.) set this so a
|
||||
* RAG outage can be routed to the built-in document parser rather than degraded to raw bytes.
|
||||
* @returns
|
||||
*/
|
||||
export async function parseText({
|
||||
req,
|
||||
file,
|
||||
file_id,
|
||||
allowNativeFallback = true,
|
||||
}: {
|
||||
req: ServerRequest;
|
||||
file: Express.Multer.File;
|
||||
file_id: string;
|
||||
allowNativeFallback?: boolean;
|
||||
}): Promise<{ text: string; bytes: number; source: string }> {
|
||||
if (!process.env.RAG_API_URL) {
|
||||
logger.debug('[parseText] RAG_API_URL not defined, falling back to native text parsing');
|
||||
const nativeFallback = (): Promise<{ text: string; bytes: number; source: string }> => {
|
||||
if (!allowNativeFallback) {
|
||||
throw new Error(
|
||||
`[parseText] RAG text extraction unavailable for "${file.originalname}" and native fallback is disabled`,
|
||||
);
|
||||
}
|
||||
return parseTextNative(file);
|
||||
};
|
||||
|
||||
if (!process.env.RAG_API_URL) {
|
||||
logger.debug('[parseText] RAG_API_URL not defined');
|
||||
return nativeFallback();
|
||||
}
|
||||
|
||||
if (isMarkdownFile(file)) {
|
||||
|
|
@ -64,8 +78,8 @@ export async function parseText({
|
|||
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
logger.debug('[parseText] No user ID provided, falling back to native text parsing');
|
||||
return parseTextNative(file);
|
||||
logger.debug('[parseText] No user ID provided');
|
||||
return nativeFallback();
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -73,15 +87,15 @@ export async function parseText({
|
|||
timeout: 10000,
|
||||
});
|
||||
if (healthResponse?.statusText !== 'OK' && healthResponse?.status !== 200) {
|
||||
logger.debug('[parseText] RAG API health check failed, falling back to native parsing');
|
||||
return parseTextNative(file);
|
||||
logger.debug('[parseText] RAG API health check failed');
|
||||
return nativeFallback();
|
||||
}
|
||||
} catch (healthError) {
|
||||
logAxiosError({
|
||||
message: '[parseText] RAG API health check failed, falling back to native parsing:',
|
||||
message: '[parseText] RAG API health check failed:',
|
||||
error: healthError,
|
||||
});
|
||||
return parseTextNative(file);
|
||||
return nativeFallback();
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -115,10 +129,10 @@ export async function parseText({
|
|||
};
|
||||
} catch (error) {
|
||||
logAxiosError({
|
||||
message: '[parseText] RAG API text parsing failed, falling back to native parsing',
|
||||
message: '[parseText] RAG API text parsing failed',
|
||||
error,
|
||||
});
|
||||
return parseTextNative(file);
|
||||
return nativeFallback();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue