From 3337bde05081511b81353291b7b0c3d7a7ee5ff5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 20 Jul 2026 22:46:28 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=8F=20fix:=20Route=20Admin-Configured?= =?UTF-8?q?=20Document=20Types=20to=20RAG=20/text=20on=20Agent=20Upload=20?= =?UTF-8?q?(#14345)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🩹 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. --- api/server/services/Files/process.js | 52 ++++++++++- api/server/services/Files/process.spec.js | 101 +++++++++++++++++++++- packages/api/src/files/text.spec.ts | 73 +++++++++++++++- packages/api/src/files/text.ts | 36 +++++--- 4 files changed, 246 insertions(+), 16 deletions(-) diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index f6d92ac5af..5aa3443fe8 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -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 }); } diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index 1521f873bf..c5998fee4c 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -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); diff --git a/packages/api/src/files/text.spec.ts b/packages/api/src/files/text.spec.ts index f04330f14e..de8e86c510 100644 --- a/packages/api/src/files/text.spec.ts +++ b/packages/api/src/files/text.spec.ts @@ -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, + }); + }); + }); }); }); diff --git a/packages/api/src/files/text.ts b/packages/api/src/files/text.ts index eecf664258..5617de8429 100644 --- a/packages/api/src/files/text.ts +++ b/packages/api/src/files/text.ts @@ -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(); } }