🚏 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

* 🩹 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:
Danny Avila 2026-07-20 22:46:28 -04:00 • committed by GitHub
parent f4a0e0c194
commit 3337bde050
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 246 additions and 16 deletions

View file

@ -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 });
}

View file

@ -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);

View file

@ -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,
});
});
});
});
});

View file

@ -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();
}
}