Merge branch 'main' into feature/stored-prompt-id-responses-api

This commit is contained in:
Rashmith Tula 2026-02-27 16:09:50 +05:30 committed by GitHub
commit 378242763b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2726 additions and 473 deletions

View file

@ -65,6 +65,9 @@ CONSOLE_JSON=false
DEBUG_LOGGING=true
DEBUG_CONSOLE=false
# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect)
# MEM_DIAG=true
#=============#
# Permissions #
#=============#
@ -510,6 +513,9 @@ OPENID_ADMIN_ROLE_TOKEN_KIND=
OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name
OPENID_NAME_CLAIM=
# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID)
# When not set, defaults to: email -> preferred_username -> upn
OPENID_EMAIL_CLAIM=
# Optional audience parameter for OpenID authorization requests
OPENID_AUDIENCE=

View file

@ -42,8 +42,14 @@ jobs:
- name: Install Data Schemas Package
run: npm run build:data-schemas
- name: Install API Package
run: npm run build:api
- name: Build API Package & Detect Circular Dependencies
run: |
output=$(npm run build:api 2>&1)
echo "$output"
if echo "$output" | grep -q "Circular depend"; then
echo "Error: Circular dependency detected in @librechat/api!"
exit 1
fi
- name: Create empty auth.json file
run: |

View file

@ -27,8 +27,8 @@
</p>
<p align="center">
<a href="https://railway.app/template/b5k2mn?referralCode=HI9hWz">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="30">
<a href="https://railway.com/deploy/b5k2mn?referralCode=HI9hWz">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="30">
</a>
<a href="https://zeabur.com/templates/0X2ZY8">
<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30"/>

View file

@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas');
const {
countTokens,
getBalanceConfig,
buildMessageFiles,
extractFileContext,
encodeAndFormatAudios,
encodeAndFormatVideos,
@ -670,6 +671,14 @@ class BaseClient {
}
if (!isEdited && !this.skipSaveUserMessage) {
const reqFiles = this.options.req?.body?.files;
if (reqFiles && Array.isArray(this.options.attachments)) {
const files = buildMessageFiles(reqFiles, this.options.attachments);
if (files.length > 0) {
userMessage.files = files;
}
delete userMessage.image_urls;
}
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
this.savedMessageIds.add(userMessage.messageId);
if (typeof opts?.getReqData === 'function') {

View file

@ -928,4 +928,123 @@ describe('BaseClient', () => {
expect(result.remainingContextTokens).toBe(2); // 25 - 20 - 3(assistant label)
});
});
describe('sendMessage file population', () => {
const attachment = {
file_id: 'file-abc',
filename: 'image.png',
filepath: '/uploads/image.png',
type: 'image/png',
bytes: 1024,
object: 'file',
user: 'user-1',
embedded: false,
usage: 0,
text: 'large ocr blob that should be stripped',
_id: 'mongo-id-1',
};
beforeEach(() => {
TestClient.options.req = { body: { files: [{ file_id: 'file-abc' }] } };
TestClient.options.attachments = [attachment];
});
test('populates userMessage.files before saveMessageToDatabase is called', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockImplementation((msg) => {
return Promise.resolve({ message: msg });
});
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave).toBeDefined();
expect(userSave[0].files).toBeDefined();
expect(userSave[0].files).toHaveLength(1);
expect(userSave[0].files[0].file_id).toBe('file-abc');
});
test('strips text and _id from files before saving', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files[0].text).toBeUndefined();
expect(userSave[0].files[0]._id).toBeUndefined();
expect(userSave[0].files[0].filename).toBe('image.png');
});
test('deletes image_urls from userMessage when files are present', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
TestClient.options.attachments = [
{ ...attachment, image_urls: ['data:image/png;base64,...'] },
];
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].image_urls).toBeUndefined();
});
test('does not set files when no attachments match request file IDs', async () => {
TestClient.options.req = { body: { files: [{ file_id: 'file-nomatch' }] } };
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toBeUndefined();
});
test('skips file population when attachments is not an array (Promise case)', async () => {
TestClient.options.attachments = Promise.resolve([attachment]);
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toBeUndefined();
});
test('skips file population when skipSaveUserMessage is true', async () => {
TestClient.skipSaveUserMessage = true;
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg?.isCreatedByUser,
);
expect(userSave).toBeUndefined();
});
test('ignores file_id: undefined entries in req.body.files (no set poisoning)', async () => {
TestClient.options.req = {
body: { files: [{ file_id: undefined }, { file_id: 'file-abc' }] },
};
TestClient.options.attachments = [
{ ...attachment, file_id: undefined },
{ ...attachment, file_id: 'file-abc' },
];
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toHaveLength(1);
expect(userSave[0].files[0].file_id).toBe('file-abc');
});
});
});

View file

@ -44,7 +44,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.51",
"@librechat/agents": "^3.1.52",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View file

@ -35,7 +35,6 @@ const graphPropsToClean = [
'tools',
'signal',
'config',
'agentContexts',
'messages',
'contentData',
'stepKeyIds',
@ -277,7 +276,16 @@ function disposeClient(client) {
if (client.run) {
if (client.run.Graph) {
client.run.Graph.resetValues();
if (typeof client.run.Graph.clearHeavyState === 'function') {
client.run.Graph.clearHeavyState();
} else {
client.run.Graph.resetValues();
}
if (client.run.Graph.agentContexts) {
client.run.Graph.agentContexts.clear();
client.run.Graph.agentContexts = null;
}
graphPropsToClean.forEach((prop) => {
if (client.run.Graph[prop] !== undefined) {

View file

@ -18,7 +18,7 @@ const {
findUser,
} = require('~/models');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOpenIdConfig } = require('~/strategies');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const registrationController = async (req, res) => {
try {
@ -87,7 +87,7 @@ const refreshController = async (req, res) => {
const claims = tokenset.claims();
const { user, error, migration } = await findOpenIDUser({
findUser,
email: claims.email,
email: getOpenIdEmail(claims),
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'refreshController',

View file

@ -1,5 +1,5 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn() },
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: jest.fn(),
@ -11,7 +11,8 @@ jest.mock('~/server/services/AuthService', () => ({
setAuthTokens: jest.fn(),
registerUser: jest.fn(),
}));
jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn() }));
jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() }));
jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() }));
jest.mock('~/models', () => ({
deleteAllUserSessions: jest.fn(),
getUserById: jest.fn(),
@ -24,9 +25,13 @@ jest.mock('@librechat/api', () => ({
findOpenIDUser: jest.fn(),
}));
const { isEnabled } = require('@librechat/api');
const openIdClient = require('openid-client');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const { graphTokenController, refreshController } = require('./AuthController');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { graphTokenController } = require('./AuthController');
const { setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const { updateUser } = require('~/models');
describe('graphTokenController', () => {
let req, res;
@ -142,3 +147,156 @@ describe('graphTokenController', () => {
});
});
});
describe('refreshController OpenID path', () => {
const mockTokenset = {
claims: jest.fn(),
access_token: 'new-access',
id_token: 'new-id',
refresh_token: 'new-refresh',
};
const baseClaims = {
sub: 'oidc-sub-123',
oid: 'oid-456',
email: 'user@example.com',
exp: 9999999999,
};
let req, res;
beforeEach(() => {
jest.clearAllMocks();
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue({ some: 'config' });
openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset);
mockTokenset.claims.mockReturnValue(baseClaims);
getOpenIdEmail.mockReturnValue(baseClaims.email);
setOpenIDAuthTokens.mockReturnValue('new-app-token');
updateUser.mockResolvedValue({});
req = {
headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' },
session: {},
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
redirect: jest.fn(),
};
});
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
const user = {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
};
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
await refreshController(req, res);
expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: baseClaims.email }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => {
const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' };
mockTokenset.claims.mockReturnValue(claimsWithUpn);
getOpenIdEmail.mockReturnValue('user@corp.example.com');
const user = {
_id: 'user-db-id',
email: 'user@corp.example.com',
openidId: baseClaims.sub,
};
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
await refreshController(req, res);
expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'user@corp.example.com' }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should fall back to claims.email when configured claim is absent from token claims', async () => {
getOpenIdEmail.mockReturnValue(baseClaims.email);
const user = {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
};
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
await refreshController(req, res);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: baseClaims.email }),
);
});
it('should update openidId when migration is triggered on refresh', async () => {
const user = { _id: 'user-db-id', email: baseClaims.email, openidId: null };
findOpenIDUser.mockResolvedValue({ user, error: null, migration: true });
await refreshController(req, res);
expect(updateUser).toHaveBeenCalledWith(
'user-db-id',
expect.objectContaining({ provider: 'openid', openidId: baseClaims.sub }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should return 401 and redirect to /login when findOpenIDUser returns no user', async () => {
findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
await refreshController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.redirect).toHaveBeenCalledWith('/login');
});
it('should return 401 and redirect when findOpenIDUser returns an error', async () => {
findOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false });
await refreshController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.redirect).toHaveBeenCalledWith('/login');
});
it('should skip OpenID path when token_provider is not openid', async () => {
req.headers.cookie = 'token_provider=local; refreshToken=some-token';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('should skip OpenID path when OPENID_REUSE_TOKENS is disabled', async () => {
isEnabled.mockReturnValue(false);
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('should return 200 with token not provided when refresh token is absent', async () => {
req.headers.cookie = 'token_provider=openid';
req.session = {};
await refreshController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith('Refresh token not provided');
});
});

View file

@ -891,9 +891,10 @@ class AgentClient extends BaseClient {
config.signal = null;
};
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
await runAgents(initialMessages);
/** @deprecated Agent Chain */
if (config.configurable.hide_sequential_outputs) {
if (hideSequentialOutputs) {
this.contentParts = this.contentParts.filter((part, index) => {
// Include parts that are either:
// 1. At or after the finalContentStart index

View file

@ -3,9 +3,9 @@ const { Constants, ViolationTypes } = require('librechat-data-provider');
const {
sendEvent,
getViolationInfo,
buildMessageFiles,
GenerationJobManager,
decrementPendingRequest,
sanitizeFileForTransmit,
sanitizeMessageForTransmit,
checkAndIncrementPendingRequest,
} = require('@librechat/api');
@ -252,13 +252,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
if (req.body.files && client.options?.attachments) {
userMessage.files = [];
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
for (const attachment of client.options.attachments) {
if (messageFiles.has(attachment.file_id)) {
userMessage.files.push(sanitizeFileForTransmit(attachment));
}
if (req.body.files && Array.isArray(client.options.attachments)) {
const files = buildMessageFiles(req.body.files, client.options.attachments);
if (files.length > 0) {
userMessage.files = files;
}
delete userMessage.image_urls;
}
@ -639,14 +636,10 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
// 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 (const attachment of client.options.attachments) {
if (messageFiles.has(attachment.file_id)) {
userMessage.files.push(sanitizeFileForTransmit(attachment));
}
if (req.body.files && Array.isArray(client.options.attachments)) {
const files = buildMessageFiles(req.body.files, client.options.attachments);
if (files.length > 0) {
userMessage.files = files;
}
delete userMessage.image_urls;
}

View file

@ -13,11 +13,12 @@ const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
ErrorController,
memoryDiagnostics,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
GenerationJobManager,
createStreamServices,
initializeFileStorage,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
@ -201,6 +202,11 @@ const startServer = async () => {
const streamServices = createStreamServices();
GenerationJobManager.configure(streamServices);
GenerationJobManager.initialize();
const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect'));
if (inspectFlags || isEnabled(process.env.MEM_DIAG)) {
memoryDiagnostics.start();
}
});
};

View file

@ -16,6 +16,7 @@ const {
removeNullishValues,
isAssistantsEndpoint,
getEndpointFileConfig,
documentParserMimeTypes,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
@ -559,19 +560,12 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
const fileConfig = mergeFileConfig(appConfig.fileConfig);
const documentParserMimeTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
const shouldUseConfiguredOCR =
appConfig?.ocr != null &&
fileConfig.checkType(file.mimetype, fileConfig.ocr?.supportedMimeTypes || []);
const shouldUseDocumentParser =
!shouldUseConfiguredOCR && documentParserMimeTypes.includes(file.mimetype);
!shouldUseConfiguredOCR && documentParserMimeTypes.some((regex) => regex.test(file.mimetype));
const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser;

View file

@ -83,6 +83,10 @@ const PDF_MIME = 'application/pdf';
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const XLS_MIME = 'application/vnd.ms-excel';
const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const ODP_MIME = 'application/vnd.oasis.opendocument.presentation';
const ODG_MIME = 'application/vnd.oasis.opendocument.graphics';
const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({
user: { id: 'user-123' },
@ -138,6 +142,9 @@ describe('processAgentFileUpload', () => {
['DOCX', DOCX_MIME],
['XLSX', XLSX_MIME],
['XLS', XLS_MIME],
['ODS', ODS_MIME],
['Excel variant (msexcel)', 'application/msexcel'],
['Excel variant (x-msexcel)', 'application/x-msexcel'],
])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => {
mergeFileConfig.mockReturnValue(makeFileConfig());
const req = makeReq({ mimetype: mime, ocrConfig: null });
@ -229,6 +236,23 @@ describe('processAgentFileUpload', () => {
expect(getStrategyFunctions).not.toHaveBeenCalled();
});
test.each([
['ODT', ODT_MIME],
['ODP', ODP_MIME],
['ODG', ODG_MIME],
])('routes %s through configured OCR when OCR supports the type', async (_, mime) => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [mime] }));
const req = makeReq({
mimetype: mime,
ocrConfig: { strategy: FileSources.mistral_ocr },
});
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
});
test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => {
getStrategyFunctions.mockReturnValue({
handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')),

View file

@ -1,4 +1,4 @@
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
const facebookLogin = require('./facebookStrategy');
const discordLogin = require('./discordStrategy');
@ -20,6 +20,7 @@ module.exports = {
facebookLogin,
setupOpenId,
getOpenIdConfig,
getOpenIdEmail,
ldapLogin,
setupSaml,
openIdJwtLogin,

View file

@ -5,6 +5,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
const { SystemRoles } = require('librechat-data-provider');
const { isEnabled, findOpenIDUser, math } = require('@librechat/api');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { getOpenIdEmail } = require('./openidStrategy');
const { updateUser, findUser } = require('~/models');
/**
@ -53,7 +54,7 @@ const openIdJwtLogin = (openIdConfig) => {
const { user, error, migration } = await findOpenIDUser({
findUser,
email: payload?.email,
email: payload ? getOpenIdEmail(payload) : undefined,
openidId: payload?.sub,
idOnTheSource: payload?.oid,
strategyName: 'openIdJwtLogin',

View file

@ -29,10 +29,21 @@ jest.mock('~/models', () => ({
findUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }),
);
const { findOpenIDUser } = require('@librechat/api');
const { updateUser } = require('~/models');
const openIdJwtLogin = require('./openIdJwtStrategy');
const { findUser, updateUser } = require('~/models');
// Helper: build a mock openIdConfig
const mockOpenIdConfig = {
@ -181,3 +192,156 @@ describe('openIdJwtStrategy token source handling', () => {
expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token);
});
});
describe('openIdJwtStrategy OPENID_EMAIL_CLAIM', () => {
const payload = {
sub: 'oidc-123',
email: 'test@example.com',
preferred_username: 'testuser',
upn: 'test@corp.example.com',
exp: 9999999999,
};
beforeEach(() => {
jest.clearAllMocks();
delete process.env.OPENID_EMAIL_CLAIM;
// Use real findOpenIDUser so it delegates to the findUser mock
const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser;
findOpenIDUser.mockImplementation(realFindOpenIDUser);
findUser.mockResolvedValue(null);
updateUser.mockResolvedValue({});
openIdJwtLogin(mockOpenIdConfig);
});
afterEach(() => {
delete process.env.OPENID_EMAIL_CLAIM;
});
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
const existingUser = {
_id: 'user-id-1',
provider: 'openid',
openidId: payload.sub,
email: payload.email,
role: SystemRoles.USER,
};
findUser.mockImplementation(async (query) => {
if (query.$or && query.$or.some((c) => c.openidId === payload.sub)) {
return existingUser;
}
return null;
});
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith(
expect.objectContaining({
$or: expect.arrayContaining([{ openidId: payload.sub }]),
}),
);
});
it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => {
process.env.OPENID_EMAIL_CLAIM = 'upn';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledTimes(2);
expect(findUser.mock.calls[0][0]).toMatchObject({
$or: expect.arrayContaining([{ openidId: payload.sub }]),
});
expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' });
expect(user).toBe(false);
});
it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => {
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
expect(user).toBe(false);
});
it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => {
process.env.OPENID_EMAIL_CLAIM = ' upn ';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' });
});
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = '';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
});
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = ' ';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
});
it('should resolve undefined email when payload is null', async () => {
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, null);
expect(user).toBe(false);
});
it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => {
const payloadNoEmail = {
sub: 'oidc-new-sub',
preferred_username: 'legacy@corp.com',
upn: 'legacy@corp.com',
exp: 9999999999,
};
const legacyUser = {
_id: 'legacy-db-id',
email: 'legacy@corp.com',
openidId: null,
role: SystemRoles.USER,
};
findUser.mockImplementation(async (query) => {
if (query.$or) {
return null;
}
if (query.email === 'legacy@corp.com') {
return legacyUser;
}
return null;
});
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payloadNoEmail);
expect(findUser).toHaveBeenCalledTimes(2);
expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' });
expect(user).toBeTruthy();
expect(updateUser).toHaveBeenCalledWith(
'legacy-db-id',
expect.objectContaining({ provider: 'openid', openidId: payloadNoEmail.sub }),
);
});
});

View file

@ -267,6 +267,34 @@ function getFullName(userinfo) {
return userinfo.username || userinfo.email;
}
/**
* Resolves the user identifier from OpenID claims.
* Configurable via OPENID_EMAIL_CLAIM; defaults to: email -> preferred_username -> upn.
*
* @param {Object} userinfo - The user information object from OpenID Connect
* @returns {string|undefined} The resolved identifier string
*/
function getOpenIdEmail(userinfo) {
const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim();
if (claimKey) {
const value = userinfo[claimKey];
if (typeof value === 'string' && value) {
return value;
}
if (value !== undefined && value !== null) {
logger.warn(
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`,
);
} else {
logger.warn(
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`,
);
}
}
const fallback = userinfo.email || userinfo.preferred_username || userinfo.upn;
return typeof fallback === 'string' ? fallback : undefined;
}
/**
* Converts an input into a string suitable for a username.
* If the input is a string, it will be returned as is.
@ -379,11 +407,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
}
const appConfig = await getAppConfig();
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
const email = getOpenIdEmail(userinfo);
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
`[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
);
throw new Error('Email domain not allowed');
}
@ -728,4 +755,5 @@ function getOpenIdConfig() {
module.exports = {
setupOpenId,
getOpenIdConfig,
getOpenIdEmail,
};

View file

@ -1,6 +1,6 @@
const undici = require('undici');
const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode');
const undici = require('undici');
const { ErrorTypes } = require('librechat-data-provider');
const { findUser, createUser, updateUser } = require('~/models');
const { setupOpenId } = require('./openidStrategy');
@ -152,6 +152,7 @@ describe('setupOpenId', () => {
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.OPENID_EMAIL_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
@ -1402,4 +1403,82 @@ describe('setupOpenId', () => {
expect(user).toBe(false);
});
});
describe('OPENID_EMAIL_CLAIM', () => {
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
});
it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => {
process.env.OPENID_EMAIL_CLAIM = 'upn';
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('user@corp.example.com');
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'user@corp.example.com' }),
expect.anything(),
true,
true,
);
});
it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => {
const userinfo = { ...tokenset.claims() };
delete userinfo.email;
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('testusername');
});
it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => {
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
delete userinfo.email;
delete userinfo.preferred_username;
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('user@corp.example.com');
});
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = '';
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
});
it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => {
process.env.OPENID_EMAIL_CLAIM = ' upn ';
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('user@corp.example.com');
});
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = ' ';
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
});
it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'),
);
});
});
});

View file

@ -1,15 +1,19 @@
import { useEffect, useState } from 'react';
import { ErrorTypes, registerPage } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import { useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common';
import { getLoginError, persistRedirectToSession } from '~/utils';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialButton from '~/components/Auth/SocialButton';
import { useAuthContext } from '~/hooks/AuthContext';
import { getLoginError } from '~/utils';
import { useLocalize } from '~/hooks';
import LoginForm from './LoginForm';
interface LoginLocationState {
redirect_to?: string;
}
function Login() {
const localize = useLocalize();
const { showToast } = useToastContext();
@ -17,13 +21,22 @@ function Login() {
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
const [searchParams, setSearchParams] = useSearchParams();
// Determine if auto-redirect should be disabled based on the URL parameter
const location = useLocation();
const disableAutoRedirect = searchParams.get('redirect') === 'false';
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
useEffect(() => {
const redirectTo = searchParams.get('redirect_to');
if (redirectTo) {
persistRedirectToSession(redirectTo);
} else {
const state = location.state as LoginLocationState | null;
if (state?.redirect_to) {
persistRedirectToSession(state.redirect_to);
}
}
const oauthError = searchParams?.get('error');
if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) {
showToast({
@ -34,9 +47,8 @@ function Login() {
newParams.delete('error');
setSearchParams(newParams, { replace: true });
}
}, [searchParams, setSearchParams, showToast, localize]);
}, [searchParams, setSearchParams, showToast, localize, location.state]);
// Once the disable flag is detected, update local state and remove the parameter from the URL.
useEffect(() => {
if (disableAutoRedirect) {
setIsAutoRedirectDisabled(true);
@ -46,7 +58,6 @@ function Login() {
}
}, [disableAutoRedirect, searchParams, setSearchParams]);
// Determine whether we should auto-redirect to OpenID.
const shouldAutoRedirect =
startupConfig?.openidLoginEnabled &&
startupConfig?.openidAutoRedirect &&
@ -60,7 +71,6 @@ function Login() {
}
}, [shouldAutoRedirect, startupConfig]);
// Render fallback UI if auto-redirect is active.
if (shouldAutoRedirect) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">

View file

@ -3,7 +3,6 @@ import {
useMemo,
useState,
useEffect,
ReactNode,
useContext,
useCallback,
createContext,
@ -13,6 +12,7 @@ import { useRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { setTokenHeader, SystemRoles } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { ReactNode } from 'react';
import {
useGetRole,
useGetUserQuery,
@ -20,6 +20,7 @@ import {
useLogoutUserMutation,
useRefreshTokenMutation,
} from '~/data-provider';
import { isSafeRedirect, buildLoginRedirectUrl, getPostLoginRedirect } from '~/utils';
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
import useTimeout from './useTimeout';
import store from '~/store';
@ -58,20 +59,22 @@ const AuthContextProvider = ({
setTokenHeader(token);
setIsAuthenticated(isAuthenticated);
// Use a custom redirect if set
const finalRedirect = logoutRedirectRef.current || redirect;
// Clear the stored redirect
const searchParams = new URLSearchParams(window.location.search);
const postLoginRedirect = getPostLoginRedirect(searchParams);
const logoutRedirect = logoutRedirectRef.current;
logoutRedirectRef.current = undefined;
const finalRedirect =
logoutRedirect ??
postLoginRedirect ??
(redirect && isSafeRedirect(redirect) ? redirect : null);
if (finalRedirect == null) {
return;
}
if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) {
window.location.href = finalRedirect;
} else {
navigate(finalRedirect, { replace: true });
}
navigate(finalRedirect, { replace: true });
}, 50),
[navigate, setUser],
);
@ -81,7 +84,6 @@ const AuthContextProvider = ({
onSuccess: (data: t.TLoginResponse) => {
const { user, token, twoFAPending, tempToken } = data;
if (twoFAPending) {
// Redirect to the two-factor authentication route.
navigate(`/login/2fa?tempToken=${tempToken}`, { replace: true });
return;
}
@ -91,7 +93,15 @@ const AuthContextProvider = ({
onError: (error: TResError | unknown) => {
const resError = error as TResError;
doSetError(resError.message);
navigate('/login', { replace: true });
// Preserve a valid redirect_to across login failures so the deep link survives retries.
// Cannot use buildLoginRedirectUrl() here — it reads the current pathname (already /login)
// and would return plain /login, dropping the redirect_to destination.
const redirectTo = new URLSearchParams(window.location.search).get('redirect_to');
const loginPath =
redirectTo && isSafeRedirect(redirectTo)
? `/login?redirect_to=${encodeURIComponent(redirectTo)}`
: '/login';
navigate(loginPath, { replace: true });
},
});
const logoutUser = useLogoutUserMutation({
@ -141,22 +151,23 @@ const AuthContextProvider = ({
const { user, token = '' } = data ?? {};
if (token) {
setUserContext({ token, isAuthenticated: true, user });
} else {
console.log('Token is not present. User is not authenticated.');
if (authConfig?.test === true) {
return;
}
navigate('/login');
return;
}
console.log('Token is not present. User is not authenticated.');
if (authConfig?.test === true) {
return;
}
navigate(buildLoginRedirectUrl());
},
onError: (error) => {
console.log('refreshToken mutation error:', error);
if (authConfig?.test === true) {
return;
}
navigate('/login');
navigate(buildLoginRedirectUrl());
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- deps are stable at mount; adding refreshToken causes infinite re-fire
}, []);
useEffect(() => {
@ -164,7 +175,7 @@ const AuthContextProvider = ({
setUser(userQuery.data);
} else if (userQuery.isError) {
doSetError((userQuery.error as Error).message);
navigate('/login', { replace: true });
navigate(buildLoginRedirectUrl(), { replace: true });
}
if (error != null && error && isAuthenticated) {
doSetError(undefined);
@ -186,24 +197,22 @@ const AuthContextProvider = ({
]);
useEffect(() => {
const handleTokenUpdate = (event) => {
const handleTokenUpdate = (event: CustomEvent<string>) => {
console.log('tokenUpdated event received event');
const newToken = event.detail;
setUserContext({
token: newToken,
token: event.detail,
isAuthenticated: true,
user: user,
});
};
window.addEventListener('tokenUpdated', handleTokenUpdate);
window.addEventListener('tokenUpdated', handleTokenUpdate as EventListener);
return () => {
window.removeEventListener('tokenUpdated', handleTokenUpdate);
window.removeEventListener('tokenUpdated', handleTokenUpdate as EventListener);
};
}, [setUserContext, user]);
// Make the provider update only when it should
const memoedValue = useMemo(
() => ({
user,

View file

@ -0,0 +1,174 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { TAuthConfig } from '~/common';
import { AuthContextProvider, useAuthContext } from '../AuthContext';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
setTokenHeader: jest.fn(),
}));
let mockCapturedLoginOptions: {
onSuccess: (...args: unknown[]) => void;
onError: (...args: unknown[]) => void;
};
jest.mock('~/data-provider', () => ({
useLoginUserMutation: jest.fn(
(options: {
onSuccess: (...args: unknown[]) => void;
onError: (...args: unknown[]) => void;
}) => {
mockCapturedLoginOptions = options;
return { mutate: jest.fn() };
},
),
useLogoutUserMutation: jest.fn(() => ({ mutate: jest.fn() })),
useRefreshTokenMutation: jest.fn(() => ({ mutate: jest.fn() })),
useGetUserQuery: jest.fn(() => ({
data: undefined,
isError: false,
error: null,
})),
useGetRole: jest.fn(() => ({ data: null })),
}));
const authConfig: TAuthConfig = { loginRedirect: '/login', test: true };
function TestConsumer() {
const ctx = useAuthContext();
return <div data-testid="consumer" data-authenticated={ctx.isAuthenticated} />;
}
function renderProvider() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter>
<AuthContextProvider authConfig={authConfig}>
<TestConsumer />
</AuthContextProvider>
</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>,
);
}
describe('AuthContextProvider — login onError redirect handling', () => {
const originalLocation = window.location;
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(window, 'location', {
value: { ...originalLocation, pathname: '/login', search: '', hash: '' },
writable: true,
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
});
it('preserves a valid redirect_to param across login failure', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc123', hash: '' },
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login?redirect_to=%2Fc%2Fabc123', {
replace: true,
});
});
it('drops redirect_to when it contains an absolute URL (open-redirect prevention)', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/login', search: '?redirect_to=https%3A%2F%2Fevil.com', hash: '' },
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
});
it('drops redirect_to when it points to /login (recursive redirect prevention)', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/login', search: '?redirect_to=%2Flogin', hash: '' },
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
});
it('navigates to plain /login when no redirect_to param exists', () => {
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Server error' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
});
it('preserves redirect_to with query params and hash', () => {
const target = '/c/abc123?model=gpt-4#section';
Object.defineProperty(window, 'location', {
value: {
pathname: '/login',
search: `?redirect_to=${encodeURIComponent(target)}`,
hash: '',
},
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
const navigatedUrl = mockNavigate.mock.calls[0][0] as string;
const params = new URLSearchParams(navigatedUrl.split('?')[1]);
expect(decodeURIComponent(params.get('redirect_to')!)).toBe(target);
});
});

View file

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import type { TStartupConfig } from 'librechat-data-provider';
import { TranslationKeys, useLocalize } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import AuthLayout from '~/components/Auth/AuthLayout';
import { TranslationKeys, useLocalize } from '~/hooks';
import { REDIRECT_PARAM, SESSION_KEY } from '~/utils';
const headerMap: Record<string, TranslationKeys> = {
'/login': 'com_auth_welcome_back',
@ -30,7 +31,12 @@ export default function StartupLayout({ isAuthenticated }: { isAuthenticated?: b
useEffect(() => {
if (isAuthenticated) {
navigate('/c/new', { replace: true });
const hasPendingRedirect =
new URLSearchParams(window.location.search).has(REDIRECT_PARAM) ||
sessionStorage.getItem(SESSION_KEY) != null;
if (!hasPendingRedirect) {
navigate('/c/new', { replace: true });
}
}
if (data) {
setStartupConfig(data);

View file

@ -0,0 +1,128 @@
/* eslint-disable i18next/no-literal-string */
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { SESSION_KEY } from '~/utils';
import StartupLayout from '../Layouts/Startup';
if (typeof Request === 'undefined') {
global.Request = class Request {
constructor(
public url: string,
public init?: RequestInit,
) {}
} as any;
}
jest.mock('~/data-provider', () => ({
useGetStartupConfig: jest.fn(() => ({
data: null,
isFetching: false,
error: null,
})),
}));
jest.mock('~/hooks', () => ({
useLocalize: jest.fn(() => (key: string) => key),
TranslationKeys: {},
}));
jest.mock('~/components/Auth/AuthLayout', () => {
return function MockAuthLayout({ children }: { children: React.ReactNode }) {
return <div data-testid="auth-layout">{children}</div>;
};
});
function ChildRoute() {
return <div data-testid="child-route">Child</div>;
}
function NewConversation() {
return <div data-testid="new-conversation">New Conversation</div>;
}
const createTestRouter = (initialEntry: string, isAuthenticated: boolean) =>
createMemoryRouter(
[
{
path: '/login',
element: <StartupLayout isAuthenticated={isAuthenticated} />,
children: [{ index: true, element: <ChildRoute /> }],
},
{
path: '/c/new',
element: <NewConversation />,
},
],
{ initialEntries: [initialEntry] },
);
describe('StartupLayout — redirect race condition', () => {
const originalLocation = window.location;
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
jest.restoreAllMocks();
});
it('navigates to /c/new when authenticated with no pending redirect', async () => {
Object.defineProperty(window, 'location', {
value: { ...originalLocation, search: '' },
writable: true,
});
const router = createTestRouter('/login', true);
render(<RouterProvider router={router} />);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/c/new');
});
});
it('does NOT navigate to /c/new when redirect_to URL param is present', async () => {
Object.defineProperty(window, 'location', {
value: { ...originalLocation, search: '?redirect_to=%2Fc%2Fabc123' },
writable: true,
});
const router = createTestRouter('/login?redirect_to=%2Fc%2Fabc123', true);
render(<RouterProvider router={router} />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(router.state.location.pathname).toBe('/login');
});
it('does NOT navigate to /c/new when sessionStorage redirect is present', async () => {
Object.defineProperty(window, 'location', {
value: { ...originalLocation, search: '' },
writable: true,
});
sessionStorage.setItem(SESSION_KEY, '/c/abc123');
const router = createTestRouter('/login', true);
render(<RouterProvider router={router} />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(router.state.location.pathname).toBe('/login');
});
it('does NOT navigate when not authenticated', async () => {
Object.defineProperty(window, 'location', {
value: { ...originalLocation, search: '' },
writable: true,
});
const router = createTestRouter('/login', false);
render(<RouterProvider router={router} />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(router.state.location.pathname).toBe('/login');
});
});

View file

@ -33,9 +33,8 @@ function TestComponent() {
* Creates a test router with optional basename to verify navigation works correctly
* with subdirectory deployments (e.g., /librechat)
*/
const createTestRouter = (basename = '/') => {
// When using basename, initialEntries must include the basename
const initialEntry = basename === '/' ? '/' : `${basename}/`;
const createTestRouter = (basename = '/', initialEntry?: string) => {
const defaultEntry = basename === '/' ? '/' : `${basename}/`;
return createMemoryRouter(
[
@ -47,10 +46,14 @@ const createTestRouter = (basename = '/') => {
path: '/login',
element: <div data-testid="login-page">Login Page</div>,
},
{
path: '/c/:id',
element: <TestComponent />,
},
],
{
basename,
initialEntries: [initialEntry],
initialEntries: [initialEntry ?? defaultEntry],
},
);
};
@ -199,4 +202,73 @@ describe('useAuthRedirect', () => {
expect(testResult.isAuthenticated).toBe(true);
});
});
it('should include redirect_to param with encoded current path when redirecting', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter('/', '/c/abc123');
render(<RouterProvider router={router} />);
await waitFor(
() => {
expect(router.state.location.pathname).toBe('/login');
const search = router.state.location.search;
const params = new URLSearchParams(search);
const redirectTo = params.get('redirect_to');
expect(redirectTo).not.toBeNull();
expect(decodeURIComponent(redirectTo!)).toBe('/c/abc123');
},
{ timeout: 1000 },
);
});
it('should encode query params and hash from the source URL', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter('/', '/c/abc123?q=hello&submit=true#section');
render(<RouterProvider router={router} />);
await waitFor(
() => {
expect(router.state.location.pathname).toBe('/login');
const params = new URLSearchParams(router.state.location.search);
const decoded = decodeURIComponent(params.get('redirect_to')!);
expect(decoded).toBe('/c/abc123?q=hello&submit=true#section');
},
{ timeout: 1000 },
);
});
it('should not append redirect_to when already on /login', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createMemoryRouter(
[
{
path: '/login',
element: <TestComponent />,
},
],
{ initialEntries: ['/login'] },
);
render(<RouterProvider router={router} />);
await waitFor(
() => {
expect(router.state.location.pathname).toBe('/login');
},
{ timeout: 1000 },
);
expect(router.state.location.search).toBe('');
});
});

View file

@ -1,22 +1,28 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { buildLoginRedirectUrl } from '~/utils';
import { useAuthContext } from '~/hooks';
export default function useAuthRedirect() {
const { user, roles, isAuthenticated } = useAuthContext();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const timeout = setTimeout(() => {
if (!isAuthenticated) {
navigate('/login', { replace: true });
if (isAuthenticated) {
return;
}
navigate(buildLoginRedirectUrl(location.pathname, location.search, location.hash), {
replace: true,
});
}, 300);
return () => {
clearTimeout(timeout);
};
}, [isAuthenticated, navigate]);
}, [isAuthenticated, navigate, location]);
return {
user,

View file

@ -0,0 +1,266 @@
import {
isSafeRedirect,
buildLoginRedirectUrl,
getPostLoginRedirect,
persistRedirectToSession,
SESSION_KEY,
} from '../redirect';
describe('isSafeRedirect', () => {
it('accepts a simple relative path', () => {
expect(isSafeRedirect('/c/new')).toBe(true);
});
it('accepts a path with query params and hash', () => {
expect(isSafeRedirect('/c/new?q=hello&submit=true#section')).toBe(true);
});
it('accepts a nested path', () => {
expect(isSafeRedirect('/dashboard/settings/profile')).toBe(true);
});
it('rejects an absolute http URL', () => {
expect(isSafeRedirect('https://evil.com')).toBe(false);
});
it('rejects an absolute http URL with path', () => {
expect(isSafeRedirect('https://evil.com/phishing')).toBe(false);
});
it('rejects a protocol-relative URL', () => {
expect(isSafeRedirect('//evil.com')).toBe(false);
});
it('rejects a bare domain', () => {
expect(isSafeRedirect('evil.com')).toBe(false);
});
it('rejects an empty string', () => {
expect(isSafeRedirect('')).toBe(false);
});
it('rejects /login to prevent redirect loops', () => {
expect(isSafeRedirect('/login')).toBe(false);
});
it('rejects /login with query params', () => {
expect(isSafeRedirect('/login?redirect_to=/c/new')).toBe(false);
});
it('rejects /login sub-paths', () => {
expect(isSafeRedirect('/login/2fa')).toBe(false);
});
it('rejects /login with hash', () => {
expect(isSafeRedirect('/login#foo')).toBe(false);
});
it('accepts the root path', () => {
expect(isSafeRedirect('/')).toBe(true);
});
});
describe('buildLoginRedirectUrl', () => {
const originalLocation = window.location;
beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' },
writable: true,
});
});
afterEach(() => {
Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
});
it('builds a login URL from explicit args', () => {
const result = buildLoginRedirectUrl('/c/new', '?q=hello', '');
expect(result).toBe('/login?redirect_to=%2Fc%2Fnew%3Fq%3Dhello');
});
it('encodes complex paths with query and hash', () => {
const result = buildLoginRedirectUrl('/c/new', '?q=hello&submit=true', '#section');
expect(result).toContain('redirect_to=');
const encoded = result.split('redirect_to=')[1];
expect(decodeURIComponent(encoded)).toBe('/c/new?q=hello&submit=true#section');
});
it('falls back to window.location when no args provided', () => {
const result = buildLoginRedirectUrl();
const encoded = result.split('redirect_to=')[1];
expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5');
});
it('falls back to "/" when all location parts are empty', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '', search: '', hash: '' },
writable: true,
});
const result = buildLoginRedirectUrl();
expect(result).toBe('/login?redirect_to=%2F');
});
it('returns plain /login when pathname is /login (prevents recursive redirect)', () => {
const result = buildLoginRedirectUrl('/login', '?redirect_to=%2Fc%2Fnew', '');
expect(result).toBe('/login');
});
it('returns plain /login when window.location is already /login', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
writable: true,
});
const result = buildLoginRedirectUrl();
expect(result).toBe('/login');
});
it('returns plain /login for /login sub-paths', () => {
const result = buildLoginRedirectUrl('/login/2fa', '', '');
expect(result).toBe('/login');
});
it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
writable: true,
});
const result = buildLoginRedirectUrl();
expect(result).toBe('/login');
});
it('returns plain /login for basename-prefixed /login sub-paths', () => {
const result = buildLoginRedirectUrl('/librechat/login/2fa', '', '');
expect(result).toBe('/login');
});
it('does NOT match paths where "login" is a substring of a segment', () => {
const result = buildLoginRedirectUrl('/c/loginhistory', '', '');
expect(result).toContain('redirect_to=');
expect(decodeURIComponent(result.split('redirect_to=')[1])).toBe('/c/loginhistory');
});
});
describe('getPostLoginRedirect', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('returns the redirect_to param when valid', () => {
const params = new URLSearchParams('redirect_to=%2Fc%2Fnew');
expect(getPostLoginRedirect(params)).toBe('/c/new');
});
it('falls back to sessionStorage when no URL param', () => {
sessionStorage.setItem(SESSION_KEY, '/c/abc123');
const params = new URLSearchParams();
expect(getPostLoginRedirect(params)).toBe('/c/abc123');
});
it('prefers URL param over sessionStorage', () => {
sessionStorage.setItem(SESSION_KEY, '/c/old');
const params = new URLSearchParams('redirect_to=%2Fc%2Fnew');
expect(getPostLoginRedirect(params)).toBe('/c/new');
});
it('clears sessionStorage after reading', () => {
sessionStorage.setItem(SESSION_KEY, '/c/abc123');
const params = new URLSearchParams();
getPostLoginRedirect(params);
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
});
it('returns null when no redirect source exists', () => {
const params = new URLSearchParams();
expect(getPostLoginRedirect(params)).toBeNull();
});
it('rejects an absolute URL from params', () => {
const params = new URLSearchParams('redirect_to=https%3A%2F%2Fevil.com');
expect(getPostLoginRedirect(params)).toBeNull();
});
it('rejects a protocol-relative URL from params', () => {
const params = new URLSearchParams('redirect_to=%2F%2Fevil.com');
expect(getPostLoginRedirect(params)).toBeNull();
});
it('rejects an absolute URL from sessionStorage', () => {
sessionStorage.setItem(SESSION_KEY, 'https://evil.com');
const params = new URLSearchParams();
expect(getPostLoginRedirect(params)).toBeNull();
});
it('rejects /login redirect to prevent loops', () => {
const params = new URLSearchParams('redirect_to=%2Flogin');
expect(getPostLoginRedirect(params)).toBeNull();
});
it('rejects /login sub-path redirect', () => {
const params = new URLSearchParams('redirect_to=%2Flogin%2F2fa');
expect(getPostLoginRedirect(params)).toBeNull();
});
it('still clears sessionStorage even when target is unsafe', () => {
sessionStorage.setItem(SESSION_KEY, 'https://evil.com');
const params = new URLSearchParams();
getPostLoginRedirect(params);
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
});
});
describe('login error redirect_to preservation (AuthContext onError pattern)', () => {
/** Mirrors the logic in AuthContext.tsx loginUser.onError */
function buildLoginErrorPath(search: string): string {
const redirectTo = new URLSearchParams(search).get('redirect_to');
return redirectTo && isSafeRedirect(redirectTo)
? `/login?redirect_to=${encodeURIComponent(redirectTo)}`
: '/login';
}
it('preserves a valid redirect_to across login failure', () => {
const result = buildLoginErrorPath('?redirect_to=%2Fc%2Fnew');
expect(result).toBe('/login?redirect_to=%2Fc%2Fnew');
});
it('drops an open-redirect attempt (absolute URL)', () => {
const result = buildLoginErrorPath('?redirect_to=https%3A%2F%2Fevil.com');
expect(result).toBe('/login');
});
it('drops a /login redirect_to to prevent loops', () => {
const result = buildLoginErrorPath('?redirect_to=%2Flogin');
expect(result).toBe('/login');
});
it('returns plain /login when no redirect_to param exists', () => {
const result = buildLoginErrorPath('');
expect(result).toBe('/login');
});
});
describe('persistRedirectToSession', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('stores a valid relative path', () => {
persistRedirectToSession('/c/new?q=hello');
expect(sessionStorage.getItem(SESSION_KEY)).toBe('/c/new?q=hello');
});
it('rejects an absolute URL', () => {
persistRedirectToSession('https://evil.com');
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
});
it('rejects a protocol-relative URL', () => {
persistRedirectToSession('//evil.com');
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
});
it('rejects /login paths', () => {
persistRedirectToSession('/login?redirect_to=/c/new');
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
});
});

View file

@ -13,6 +13,7 @@ export * from './agents';
export * from './drafts';
export * from './convos';
export * from './routes';
export * from './redirect';
export * from './presets';
export * from './prompts';
export * from './textarea';

View file

@ -0,0 +1,66 @@
const REDIRECT_PARAM = 'redirect_to';
const SESSION_KEY = 'post_login_redirect_to';
/** Matches `/login` as a full path segment, with optional basename prefix (e.g. `/librechat/login/2fa`) */
const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/;
/** Validates that a redirect target is a safe relative path (not an absolute or protocol-relative URL) */
function isSafeRedirect(url: string): boolean {
if (!url.startsWith('/') || url.startsWith('//')) {
return false;
}
const path = url.split('?')[0].split('#')[0];
return !LOGIN_PATH_RE.test(path);
}
/**
* Builds a `/login?redirect_to=...` URL from the given or current location.
* Returns plain `/login` (no param) when already on a login route to prevent recursive nesting.
*/
function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string {
const p = pathname ?? window.location.pathname;
if (LOGIN_PATH_RE.test(p)) {
return '/login';
}
const s = search ?? window.location.search;
const h = hash ?? window.location.hash;
const currentPath = `${p}${s}${h}`;
const encoded = encodeURIComponent(currentPath || '/');
return `/login?${REDIRECT_PARAM}=${encoded}`;
}
/**
* Resolves the post-login redirect from URL params and sessionStorage,
* cleans up both sources, and returns the validated target (or null).
*/
function getPostLoginRedirect(searchParams: URLSearchParams): string | null {
const urlRedirect = searchParams.get(REDIRECT_PARAM);
const storedRedirect = sessionStorage.getItem(SESSION_KEY);
const target = urlRedirect ?? storedRedirect;
if (storedRedirect) {
sessionStorage.removeItem(SESSION_KEY);
}
if (target == null || !isSafeRedirect(target)) {
return null;
}
return target;
}
function persistRedirectToSession(value: string): void {
if (isSafeRedirect(value)) {
sessionStorage.setItem(SESSION_KEY, value);
}
}
export {
SESSION_KEY,
REDIRECT_PARAM,
isSafeRedirect,
persistRedirectToSession,
buildLoginRedirectUrl,
getPostLoginRedirect,
};

View file

@ -8,15 +8,18 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { VitePWA } from 'vite-plugin-pwa';
// https://vitejs.dev/config/
const backendPort = process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT) || 3080;
const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`;
const backendPort = (process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT)) || 3080;
const backendURL = process.env.HOST
? `http://${process.env.HOST}:${backendPort}`
: `http://localhost:${backendPort}`;
export default defineConfig(({ command }) => ({
base: '',
server: {
allowedHosts: process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',') || [],
allowedHosts:
(process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',')) || [],
host: process.env.HOST || 'localhost',
port: process.env.PORT && Number(process.env.PORT) || 3090,
port: (process.env.PORT && Number(process.env.PORT)) || 3090,
strictPort: false,
proxy: {
'/api': {
@ -143,7 +146,12 @@ export default defineConfig(({ command }) => ({
if (normalizedId.includes('@dicebear')) {
return 'avatars';
}
if (normalizedId.includes('react-dnd') || normalizedId.includes('react-flip-toolkit')) {
if (
normalizedId.includes('react-dnd') ||
normalizedId.includes('dnd-core') ||
normalizedId.includes('react-flip-toolkit') ||
normalizedId.includes('flip-toolkit')
) {
return 'react-interactions';
}
if (normalizedId.includes('react-hook-form')) {
@ -219,7 +227,10 @@ export default defineConfig(({ command }) => ({
if (normalizedId.includes('framer-motion')) {
return 'framer-motion';
}
if (normalizedId.includes('node_modules/highlight.js')) {
if (
normalizedId.includes('node_modules/highlight.js') ||
normalizedId.includes('node_modules/lowlight')
) {
return 'markdown_highlight';
}
if (normalizedId.includes('katex') || normalizedId.includes('node_modules/katex')) {

512
package-lock.json generated
View file

@ -59,7 +59,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.51",
"@librechat/agents": "^3.1.52",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -10439,29 +10439,6 @@
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -11859,9 +11836,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.1.51",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.51.tgz",
"integrity": "sha512-inEcLCuD7YF0yCBrnxCgemg2oyRWJtCq49tLtokrD+WyWT97benSB+UyopjWh5woOsxSws3oc60d5mxRtifoLg==",
"version": "3.1.52",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.52.tgz",
"integrity": "sha512-Bg35zp+vEDZ0AEJQPZ+ukWb/UqBrsLcr3YQWRQpuvpftEgfQz0fHM5Wrxn6l5P7PvaD1ViolxoG44nggjCt7Hw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.73.0",
@ -18697,9 +18674,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz",
"integrity": "sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@ -18711,9 +18688,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.37.0.tgz",
"integrity": "sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@ -18725,9 +18702,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.37.0.tgz",
"integrity": "sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@ -18739,9 +18716,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.37.0.tgz",
"integrity": "sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@ -18753,9 +18730,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.37.0.tgz",
"integrity": "sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@ -18767,9 +18744,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.37.0.tgz",
"integrity": "sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@ -18781,9 +18758,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.37.0.tgz",
"integrity": "sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@ -18795,9 +18772,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.37.0.tgz",
"integrity": "sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@ -18809,9 +18786,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.37.0.tgz",
"integrity": "sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@ -18823,9 +18800,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.37.0.tgz",
"integrity": "sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@ -18836,10 +18813,10 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.37.0.tgz",
"integrity": "sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==",
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@ -18850,10 +18827,38 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.37.0.tgz",
"integrity": "sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==",
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@ -18865,9 +18870,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.37.0.tgz",
"integrity": "sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@ -18879,9 +18884,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.37.0.tgz",
"integrity": "sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@ -18893,9 +18898,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.37.0.tgz",
"integrity": "sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@ -18907,9 +18912,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.37.0.tgz",
"integrity": "sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@ -18921,9 +18926,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.37.0.tgz",
"integrity": "sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@ -18934,10 +18939,38 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.37.0.tgz",
"integrity": "sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@ -18949,9 +18982,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.37.0.tgz",
"integrity": "sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@ -18962,10 +18995,24 @@
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.37.0.tgz",
"integrity": "sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@ -20679,9 +20726,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/estree-jsx": {
@ -21233,13 +21280,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -21749,9 +21796,9 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -22074,9 +22121,9 @@
}
},
"node_modules/asn1.js/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"dev": true,
"license": "MIT"
},
@ -22589,9 +22636,9 @@
"license": "MIT"
},
"node_modules/bn.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz",
"integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==",
"dev": true,
"license": "MIT"
},
@ -23876,9 +23923,9 @@
}
},
"node_modules/create-ecdh/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"dev": true,
"license": "MIT"
},
@ -25292,9 +25339,9 @@
}
},
"node_modules/diffie-hellman/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"dev": true,
"license": "MIT"
},
@ -25519,9 +25566,9 @@
}
},
"node_modules/elliptic/node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"dev": true,
"license": "MIT"
},
@ -27150,10 +27197,11 @@
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@ -27775,18 +27823,18 @@
}
},
"node_modules/glob": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@ -27803,36 +27851,59 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/lru-cache": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "ISC",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@ -27840,7 +27911,7 @@
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@ -28351,9 +28422,9 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/hono": {
"version": "4.11.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz",
"integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@ -30742,13 +30813,13 @@
}
},
"node_modules/jest/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -33711,9 +33782,9 @@
}
},
"node_modules/miller-rabin/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"dev": true,
"license": "MIT"
},
@ -33814,9 +33885,10 @@
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
@ -37336,9 +37408,9 @@
}
},
"node_modules/public-encrypt/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"dev": true,
"license": "MIT"
},
@ -37359,9 +37431,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@ -39087,12 +39159,12 @@
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -39168,13 +39240,13 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.37.0.tgz",
"integrity": "sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.6"
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
@ -39184,26 +39256,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.37.0",
"@rollup/rollup-android-arm64": "4.37.0",
"@rollup/rollup-darwin-arm64": "4.37.0",
"@rollup/rollup-darwin-x64": "4.37.0",
"@rollup/rollup-freebsd-arm64": "4.37.0",
"@rollup/rollup-freebsd-x64": "4.37.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.37.0",
"@rollup/rollup-linux-arm-musleabihf": "4.37.0",
"@rollup/rollup-linux-arm64-gnu": "4.37.0",
"@rollup/rollup-linux-arm64-musl": "4.37.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.37.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.37.0",
"@rollup/rollup-linux-riscv64-gnu": "4.37.0",
"@rollup/rollup-linux-riscv64-musl": "4.37.0",
"@rollup/rollup-linux-s390x-gnu": "4.37.0",
"@rollup/rollup-linux-x64-gnu": "4.37.0",
"@rollup/rollup-linux-x64-musl": "4.37.0",
"@rollup/rollup-win32-arm64-msvc": "4.37.0",
"@rollup/rollup-win32-ia32-msvc": "4.37.0",
"@rollup/rollup-win32-x64-msvc": "4.37.0",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@ -40557,12 +40634,12 @@
}
},
"node_modules/sucrase/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -43097,10 +43174,11 @@
}
},
"node_modules/workbox-build/node_modules/rollup": {
"version": "2.79.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
@ -43702,8 +43780,8 @@
"mammoth": "^1.11.0",
"mongodb": "^6.14.2",
"pdfjs-dist": "^5.4.624",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"ts-node": "^10.9.2",
"typescript": "^5.0.4",
@ -43719,7 +43797,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.51",
"@librechat/agents": "^3.1.52",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.27.1",
"@smithy/node-http-handler": "^4.4.5",
@ -43769,13 +43847,13 @@
}
},
"packages/api/node_modules/rimraf": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
"integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
"integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"glob": "^13.0.0",
"glob": "^13.0.3",
"package-json-from-dist": "^1.0.1"
},
"bin": {
@ -43820,8 +43898,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.0",
"rimraf": "^6.1.2",
"rollup": "^4.0.0",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-typescript2": "^0.35.0",
@ -45943,13 +46021,13 @@
}
},
"packages/client/node_modules/rimraf": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
"integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
"integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"glob": "^13.0.0",
"glob": "^13.0.3",
"package-json-from-dist": "^1.0.1"
},
"bin": {
@ -46106,8 +46184,8 @@
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"openapi-types": "^12.1.3",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",
"typescript": "^5.0.4"
@ -46117,13 +46195,13 @@
}
},
"packages/data-provider/node_modules/rimraf": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
"integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
"integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"glob": "^13.0.0",
"glob": "^13.0.3",
"package-json-from-dist": "^1.0.1"
},
"bin": {
@ -46154,8 +46232,8 @@
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"mongodb-memory-server": "^10.1.4",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",
"ts-node": "^10.9.2",
@ -46200,13 +46278,13 @@
}
},
"packages/data-schemas/node_modules/rimraf": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
"integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
"integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"glob": "^13.0.0",
"glob": "^13.0.3",
"package-json-from-dist": "^1.0.1"
},
"bin": {

View file

@ -38,7 +38,7 @@
"update-banner": "node config/update-banner.js",
"delete-banner": "node config/delete-banner.js",
"backend": "cross-env NODE_ENV=production node api/server/index.js",
"backend:inspect": "cross-env NODE_ENV=production node --inspect api/server/index.js",
"backend:inspect": "cross-env NODE_ENV=production node --inspect --expose-gc api/server/index.js",
"backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js",
"backend:experimental": "cross-env NODE_ENV=production node api/server/experimental.js",
"backend:stop": "node config/stop-backend.js",

View file

@ -70,8 +70,8 @@
"mammoth": "^1.11.0",
"mongodb": "^6.14.2",
"pdfjs-dist": "^5.4.624",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"ts-node": "^10.9.2",
"typescript": "^5.0.4",
@ -90,7 +90,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.51",
"@librechat/agents": "^3.1.52",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.27.1",
"@smithy/node-http-handler": "^4.4.5",

View file

@ -9,6 +9,7 @@ import type {
ToolExecuteBatchRequest,
} from '@librechat/agents';
import type { StructuredToolInterface } from '@langchain/core/tools';
import { runOutsideTracing } from '~/utils';
export interface ToolEndCallbackData {
output: {
@ -57,110 +58,122 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
const { toolCalls, agentId, configurable, metadata, resolve, reject } = data;
try {
const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))];
const { loadedTools, configurable: toolConfigurable } = await loadTools(toolNames, agentId);
const toolMap = new Map(loadedTools.map((t) => [t.name, t]));
const mergedConfigurable = { ...configurable, ...toolConfigurable };
await runOutsideTracing(async () => {
try {
const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))];
const { loadedTools, configurable: toolConfigurable } = await loadTools(
toolNames,
agentId,
);
const toolMap = new Map(loadedTools.map((t) => [t.name, t]));
const mergedConfigurable = { ...configurable, ...toolConfigurable };
const results: ToolExecuteResult[] = await Promise.all(
toolCalls.map(async (tc: ToolCallRequest) => {
const tool = toolMap.get(tc.name);
const results: ToolExecuteResult[] = await Promise.all(
toolCalls.map(async (tc: ToolCallRequest) => {
const tool = toolMap.get(tc.name);
if (!tool) {
logger.warn(
`[ON_TOOL_EXECUTE] Tool "${tc.name}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
return {
toolCallId: tc.id,
status: 'error' as const,
content: '',
errorMessage: `Tool ${tc.name} not found`,
};
}
try {
const toolCallConfig: Record<string, unknown> = {
id: tc.id,
stepId: tc.stepId,
turn: tc.turn,
};
if (
tc.codeSessionContext &&
(tc.name === Constants.EXECUTE_CODE ||
tc.name === Constants.PROGRAMMATIC_TOOL_CALLING)
) {
toolCallConfig.session_id = tc.codeSessionContext.session_id;
if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) {
toolCallConfig._injected_files = tc.codeSessionContext.files;
}
}
if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
const toolRegistry = mergedConfigurable?.toolRegistry as LCToolRegistry | undefined;
const ptcToolMap = mergedConfigurable?.ptcToolMap as
| Map<string, StructuredToolInterface>
| undefined;
if (toolRegistry) {
const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter(
(t) =>
t.name !== Constants.PROGRAMMATIC_TOOL_CALLING &&
t.name !== Constants.TOOL_SEARCH,
if (!tool) {
logger.warn(
`[ON_TOOL_EXECUTE] Tool "${tc.name}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
toolCallConfig.toolDefs = toolDefs;
toolCallConfig.toolMap = ptcToolMap ?? toolMap;
return {
toolCallId: tc.id,
status: 'error' as const,
content: '',
errorMessage: `Tool ${tc.name} not found`,
};
}
}
const result = await tool.invoke(tc.args, {
toolCall: toolCallConfig,
configurable: mergedConfigurable,
metadata,
} as Record<string, unknown>);
try {
const toolCallConfig: Record<string, unknown> = {
id: tc.id,
stepId: tc.stepId,
turn: tc.turn,
};
if (toolEndCallback) {
await toolEndCallback(
{
output: {
name: tc.name,
tool_call_id: tc.id,
content: result.content,
artifact: result.artifact,
},
},
{
run_id: (metadata as Record<string, unknown>)?.run_id as string | undefined,
thread_id: (metadata as Record<string, unknown>)?.thread_id as
| string
| undefined,
...metadata,
},
);
}
if (
tc.codeSessionContext &&
(tc.name === Constants.EXECUTE_CODE ||
tc.name === Constants.PROGRAMMATIC_TOOL_CALLING)
) {
toolCallConfig.session_id = tc.codeSessionContext.session_id;
if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) {
toolCallConfig._injected_files = tc.codeSessionContext.files;
}
}
return {
toolCallId: tc.id,
content: result.content,
artifact: result.artifact,
status: 'success' as const,
};
} catch (toolError) {
const error = toolError as Error;
logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error);
return {
toolCallId: tc.id,
status: 'error' as const,
content: '',
errorMessage: error.message,
};
}
}),
);
if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
const toolRegistry = mergedConfigurable?.toolRegistry as
| LCToolRegistry
| undefined;
const ptcToolMap = mergedConfigurable?.ptcToolMap as
| Map<string, StructuredToolInterface>
| undefined;
if (toolRegistry) {
const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter(
(t) =>
t.name !== Constants.PROGRAMMATIC_TOOL_CALLING &&
t.name !== Constants.TOOL_SEARCH,
);
toolCallConfig.toolDefs = toolDefs;
toolCallConfig.toolMap = ptcToolMap ?? toolMap;
}
}
resolve(results);
} catch (error) {
logger.error('[ON_TOOL_EXECUTE] Fatal error:', error);
reject(error as Error);
const result = await tool.invoke(tc.args, {
toolCall: toolCallConfig,
configurable: mergedConfigurable,
metadata,
} as Record<string, unknown>);
if (toolEndCallback) {
await toolEndCallback(
{
output: {
name: tc.name,
tool_call_id: tc.id,
content: result.content,
artifact: result.artifact,
},
},
{
run_id: (metadata as Record<string, unknown>)?.run_id as string | undefined,
thread_id: (metadata as Record<string, unknown>)?.thread_id as
| string
| undefined,
...metadata,
},
);
}
return {
toolCallId: tc.id,
content: result.content,
artifact: result.artifact,
status: 'success' as const,
};
} catch (toolError) {
const error = toolError as Error;
logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error);
return {
toolCallId: tc.id,
status: 'error' as const,
content: '',
errorMessage: error.message,
};
}
}),
);
resolve(results);
} catch (error) {
logger.error('[ON_TOOL_EXECUTE] Fatal error:', error);
reject(error as Error);
}
});
} catch (outerError) {
logger.error('[ON_TOOL_EXECUTE] Unexpected error:', outerError);
reject(outerError as Error);
}
},
};

View file

@ -56,6 +56,50 @@ describe('Document Parser', () => {
});
});
test('parseDocument() parses text from ods', async () => {
const file = {
originalname: 'sample.ods',
path: path.join(__dirname, 'sample.ods'),
mimetype: 'application/vnd.oasis.opendocument.spreadsheet',
} as Express.Multer.File;
const document = await parseDocument({ file });
expect(document).toEqual({
bytes: 66,
filename: 'sample.ods',
filepath: 'document_parser',
images: [],
text: 'Sheet One:\nData,on,first,sheet\nSecond Sheet:\nData,On\nSecond,Sheet\n',
});
});
test.each([
'application/msexcel',
'application/x-msexcel',
'application/x-ms-excel',
'application/x-excel',
'application/x-dos_ms_excel',
'application/xls',
'application/x-xls',
])('parseDocument() parses xls with variant MIME type: %s', async (mimetype) => {
const file = {
originalname: 'sample.xls',
path: path.join(__dirname, 'sample.xls'),
mimetype,
} as Express.Multer.File;
const document = await parseDocument({ file });
expect(document).toEqual({
bytes: 31,
filename: 'sample.xls',
filepath: 'document_parser',
images: [],
text: 'Sheet One:\nData,on,first,sheet\n',
});
});
test('parseDocument() throws error for unhandled document type', async () => {
const file = {
originalname: 'nonexistent.file',

View file

@ -1,12 +1,13 @@
import * as fs from 'fs';
import { FileSources } from 'librechat-data-provider';
import { excelMimeTypes, FileSources } from 'librechat-data-provider';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { MistralOCRUploadResult } from '~/types';
/**
* Parses an uploaded document and extracts its text content and metadata.
* Handled types must stay in sync with `documentParserMimeTypes` from data-provider.
*
* Throws an Error if it fails to parse or no text is found.
* @throws {Error} if `file.mimetype` is not handled or no text is found.
*/
export async function parseDocument({
file,
@ -14,19 +15,19 @@ export async function parseDocument({
file: Express.Multer.File;
}): Promise<MistralOCRUploadResult> {
let text: string;
switch (file.mimetype) {
case 'application/pdf':
text = await pdfToText(file);
break;
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
text = await wordDocToText(file);
break;
case 'application/vnd.ms-excel':
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
text = await excelSheetToText(file);
break;
default:
throw new Error(`Unsupported file type in document parser: ${file.mimetype}`);
if (file.mimetype === 'application/pdf') {
text = await pdfToText(file);
} else if (
file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
) {
text = await wordDocToText(file);
} else if (
excelMimeTypes.test(file.mimetype) ||
file.mimetype === 'application/vnd.oasis.opendocument.spreadsheet'
) {
text = await excelSheetToText(file);
} else {
throw new Error(`Unsupported file type in document parser: ${file.mimetype}`);
}
if (!text?.trim()) {

Binary file not shown.

View file

@ -43,6 +43,8 @@ export * from './web';
export * from './cache';
/* Stream */
export * from './stream';
/* Diagnostics */
export { memoryDiagnostics } from './utils/memory';
/* types */
export type * from './mcp/types';
export type * from './flow/types';

View file

@ -25,6 +25,11 @@ export class ConnectionsRepository {
this.oauthOpts = oauthOpts;
}
/** Returns the number of active connections in this repository */
public getConnectionCount(): number {
return this.connections.size;
}
/** Checks whether this repository can connect to a specific server */
async has(serverName: string): Promise<boolean> {
const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, this.ownerId);

View file

@ -237,4 +237,23 @@ export abstract class UserConnectionManager {
}
}
}
/** Returns counts of tracked users and connections for diagnostics */
public getConnectionStats(): {
trackedUsers: number;
totalConnections: number;
activityEntries: number;
appConnectionCount: number;
} {
let totalConnections = 0;
for (const serverMap of this.userConnections.values()) {
totalConnections += serverMap.size;
}
return {
trackedUsers: this.userConnections.size,
totalConnections,
activityEntries: this.userLastActivity.size,
appConnectionCount: this.appConnections?.getConnectionCount() ?? 0,
};
}
}

View file

@ -18,10 +18,11 @@ import type {
Response as UndiciResponse,
} from 'undici';
import type { MCPOAuthTokens } from './oauth/types';
import { withTimeout } from '~/utils/promise';
import type * as t from './types';
import { createSSRFSafeUndiciConnect, resolveHostnameSSRF } from '~/auth';
import { runOutsideTracing } from '~/utils/tracing';
import { sanitizeUrlForLogging } from './utils';
import { withTimeout } from '~/utils/promise';
import { mcpConfig } from './mcpConfig';
type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
@ -698,14 +699,16 @@ export class MCPConnection extends EventEmitter {
await this.closeAgents();
}
this.transport = await this.constructTransport(this.options);
this.transport = await runOutsideTracing(() => this.constructTransport(this.options));
this.setupTransportDebugHandlers();
const connectTimeout = this.options.initTimeout ?? 120000;
await withTimeout(
this.client.connect(this.transport),
connectTimeout,
`Connection timeout after ${connectTimeout}ms`,
await runOutsideTracing(() =>
withTimeout(
this.client.connect(this.transport!),
connectTimeout,
`Connection timeout after ${connectTimeout}ms`,
),
);
this.connectionState = 'connected';

View file

@ -147,6 +147,10 @@ export class OAuthReconnectionManager {
}
}
public getTrackerStats() {
return this.reconnectionsTracker.getStats();
}
private async canReconnect(userId: string, serverName: string) {
if (this.mcpManager == null) {
return false;

View file

@ -86,4 +86,17 @@ export class OAuthReconnectionTracker {
const key = `${userId}:${serverName}`;
this.activeTimestamps.delete(key);
}
/** Returns map sizes for diagnostics */
public getStats(): {
usersWithFailedServers: number;
usersWithActiveReconnections: number;
activeTimestamps: number;
} {
return {
usersWithFailedServers: this.failed.size,
usersWithActiveReconnections: this.active.size,
activeTimestamps: this.activeTimestamps.size,
};
}
}

View file

@ -1142,6 +1142,19 @@ class GenerationJobManagerClass {
return this.jobStore.getJobCount();
}
/** Returns sizes of internal runtime maps for diagnostics */
getRuntimeStats(): {
runtimeStateSize: number;
runStepBufferSize: number;
eventTransportStreams: number;
} {
return {
runtimeStateSize: this.runtimeState.size,
runStepBufferSize: this.runStepBuffers?.size ?? 0,
eventTransportStreams: this.eventTransport.getTrackedStreamIds().length,
};
}
/**
* Get job count by status.
*/

View file

@ -0,0 +1,173 @@
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('~/stream', () => ({
GenerationJobManager: {
getRuntimeStats: jest.fn(() => null),
},
}));
jest.mock('~/mcp/oauth/OAuthReconnectionManager', () => ({
OAuthReconnectionManager: {
getInstance: jest.fn(() => ({
getTrackerStats: jest.fn(() => null),
})),
},
}));
jest.mock('~/mcp/MCPManager', () => ({
MCPManager: {
getInstance: jest.fn(() => ({
getConnectionStats: jest.fn(() => null),
})),
},
}));
import { logger } from '@librechat/data-schemas';
import { memoryDiagnostics } from '../memory';
type MockFn = jest.Mock<void, unknown[]>;
const debugMock = logger.debug as unknown as MockFn;
const infoMock = logger.info as unknown as MockFn;
const warnMock = logger.warn as unknown as MockFn;
function callsContaining(mock: MockFn, substring: string): unknown[][] {
return mock.mock.calls.filter(
(args) => typeof args[0] === 'string' && (args[0] as string).includes(substring),
);
}
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
memoryDiagnostics.stop();
const snaps = memoryDiagnostics.getSnapshots() as unknown[];
snaps.length = 0;
});
afterEach(() => {
memoryDiagnostics.stop();
jest.useRealTimers();
});
describe('memoryDiagnostics', () => {
describe('collectSnapshot', () => {
it('pushes a snapshot with expected shape', () => {
memoryDiagnostics.collectSnapshot();
const snaps = memoryDiagnostics.getSnapshots();
expect(snaps).toHaveLength(1);
expect(snaps[0]).toEqual(
expect.objectContaining({
ts: expect.any(Number),
rss: expect.any(Number),
heapUsed: expect.any(Number),
heapTotal: expect.any(Number),
external: expect.any(Number),
arrayBuffers: expect.any(Number),
}),
);
});
it('caps history at 120 snapshots', () => {
for (let i = 0; i < 130; i++) {
memoryDiagnostics.collectSnapshot();
}
expect(memoryDiagnostics.getSnapshots()).toHaveLength(120);
});
it('does not log trend with fewer than 3 snapshots', () => {
memoryDiagnostics.collectSnapshot();
memoryDiagnostics.collectSnapshot();
expect(callsContaining(debugMock, 'Trend')).toHaveLength(0);
});
it('skips trend when elapsed time is under 0.1 minutes', () => {
memoryDiagnostics.collectSnapshot();
memoryDiagnostics.collectSnapshot();
memoryDiagnostics.collectSnapshot();
expect(callsContaining(debugMock, 'Trend')).toHaveLength(0);
});
it('logs trend data when enough time has elapsed', () => {
memoryDiagnostics.collectSnapshot();
jest.advanceTimersByTime(7_000);
memoryDiagnostics.collectSnapshot();
jest.advanceTimersByTime(7_000);
memoryDiagnostics.collectSnapshot();
const trendCalls = callsContaining(debugMock, 'Trend');
expect(trendCalls.length).toBeGreaterThanOrEqual(1);
const trendPayload = trendCalls[0][1] as Record<string, string>;
expect(trendPayload).toHaveProperty('rssRate');
expect(trendPayload).toHaveProperty('heapRate');
expect(trendPayload.rssRate).toMatch(/MB\/hr$/);
expect(trendPayload.heapRate).toMatch(/MB\/hr$/);
expect(trendPayload.rssRate).not.toBe('Infinity MB/hr');
expect(trendPayload.heapRate).not.toBe('Infinity MB/hr');
});
});
describe('start / stop', () => {
it('start is idempotent — calling twice does not create two intervals', () => {
memoryDiagnostics.start();
memoryDiagnostics.start();
expect(callsContaining(infoMock, 'Starting')).toHaveLength(1);
});
it('stop is idempotent — calling twice does not error', () => {
memoryDiagnostics.start();
memoryDiagnostics.stop();
memoryDiagnostics.stop();
expect(callsContaining(infoMock, 'Stopped')).toHaveLength(1);
});
it('collects an immediate snapshot on start', () => {
expect(memoryDiagnostics.getSnapshots()).toHaveLength(0);
memoryDiagnostics.start();
expect(memoryDiagnostics.getSnapshots().length).toBeGreaterThanOrEqual(1);
});
});
describe('forceGC', () => {
it('returns false and warns when gc is not exposed', () => {
const origGC = global.gc;
global.gc = undefined;
const result = memoryDiagnostics.forceGC();
expect(result).toBe(false);
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('GC not exposed'));
global.gc = origGC;
});
it('calls gc and returns true when gc is exposed', () => {
const mockGC = jest.fn();
global.gc = mockGC;
const result = memoryDiagnostics.forceGC();
expect(result).toBe(true);
expect(mockGC).toHaveBeenCalledTimes(1);
expect(infoMock).toHaveBeenCalledWith(expect.stringContaining('Forced garbage collection'));
global.gc = undefined;
});
});
});

View file

@ -0,0 +1,137 @@
import { AsyncLocalStorage } from 'node:async_hooks';
const TRACING_ALS_KEY = Symbol.for('ls:tracing_async_local_storage');
const typedGlobal = globalThis as typeof globalThis & Record<symbol, AsyncLocalStorage<unknown>>;
let originalStorage: AsyncLocalStorage<unknown> | undefined;
beforeEach(() => {
originalStorage = typedGlobal[TRACING_ALS_KEY];
jest.restoreAllMocks();
});
afterEach(() => {
if (originalStorage) {
typedGlobal[TRACING_ALS_KEY] = originalStorage;
} else {
delete typedGlobal[TRACING_ALS_KEY];
}
delete process.env.LANGCHAIN_TRACING_V2;
});
async function freshImport(): Promise<typeof import('../tracing')> {
jest.resetModules();
return import('../tracing');
}
describe('runOutsideTracing', () => {
it('clears the ALS context to undefined inside fn', async () => {
const als = new AsyncLocalStorage<string>();
typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage<unknown>;
const { runOutsideTracing } = await freshImport();
let captured: string | undefined = 'NOT_CLEARED';
als.run('should-not-propagate', () => {
runOutsideTracing(() => {
captured = als.getStore();
});
});
expect(captured).toBeUndefined();
});
it('returns the value produced by fn (sync)', async () => {
const als = new AsyncLocalStorage<string>();
typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage<unknown>;
const { runOutsideTracing } = await freshImport();
const result = als.run('ctx', () => runOutsideTracing(() => 42));
expect(result).toBe(42);
});
it('returns the promise produced by fn (async)', async () => {
const als = new AsyncLocalStorage<string>();
typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage<unknown>;
const { runOutsideTracing } = await freshImport();
const result = await als.run('ctx', () =>
runOutsideTracing(async () => {
await Promise.resolve();
return 'async-value';
}),
);
expect(result).toBe('async-value');
});
it('propagates sync errors thrown inside fn', async () => {
const als = new AsyncLocalStorage<string>();
typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage<unknown>;
const { runOutsideTracing } = await freshImport();
expect(() =>
runOutsideTracing(() => {
throw new Error('boom');
}),
).toThrow('boom');
});
it('propagates async rejections from fn', async () => {
const als = new AsyncLocalStorage<string>();
typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage<unknown>;
const { runOutsideTracing } = await freshImport();
await expect(
runOutsideTracing(async () => {
throw new Error('async-boom');
}),
).rejects.toThrow('async-boom');
});
it('falls back to fn() when ALS is not on globalThis', async () => {
delete typedGlobal[TRACING_ALS_KEY];
const { runOutsideTracing } = await freshImport();
const result = runOutsideTracing(() => 'fallback');
expect(result).toBe('fallback');
});
it('does not warn when LANGCHAIN_TRACING_V2 is not set', async () => {
delete typedGlobal[TRACING_ALS_KEY];
delete process.env.LANGCHAIN_TRACING_V2;
const warnSpy = jest.fn();
jest.resetModules();
jest.doMock('@librechat/data-schemas', () => ({
logger: { warn: warnSpy },
}));
const { runOutsideTracing } = await import('../tracing');
runOutsideTracing(() => 'ok');
expect(warnSpy).not.toHaveBeenCalled();
});
it('warns once when LANGCHAIN_TRACING_V2 is set but ALS is missing', async () => {
delete typedGlobal[TRACING_ALS_KEY];
process.env.LANGCHAIN_TRACING_V2 = 'true';
const warnSpy = jest.fn();
jest.resetModules();
jest.doMock('@librechat/data-schemas', () => ({
logger: { warn: warnSpy },
}));
const { runOutsideTracing } = await import('../tracing');
runOutsideTracing(() => 'first');
runOutsideTracing(() => 'second');
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('LANGCHAIN_TRACING_V2 is set but ALS not found'),
);
});
});

View file

@ -25,3 +25,4 @@ export * from './http';
export * from './tokens';
export * from './url';
export * from './message';
export * from './tracing';

View file

@ -0,0 +1,150 @@
import { logger } from '@librechat/data-schemas';
import { GenerationJobManager } from '~/stream';
import { OAuthReconnectionManager } from '~/mcp/oauth/OAuthReconnectionManager';
import { MCPManager } from '~/mcp/MCPManager';
type ConnectionStats = ReturnType<InstanceType<typeof MCPManager>['getConnectionStats']>;
type TrackerStats = ReturnType<InstanceType<typeof OAuthReconnectionManager>['getTrackerStats']>;
type RuntimeStats = ReturnType<(typeof GenerationJobManager)['getRuntimeStats']>;
const INTERVAL_MS = 60_000;
const SNAPSHOT_HISTORY_LIMIT = 120;
interface MemorySnapshot {
ts: number;
rss: number;
heapUsed: number;
heapTotal: number;
external: number;
arrayBuffers: number;
mcpConnections: ConnectionStats | null;
oauthTracker: TrackerStats | null;
generationJobs: RuntimeStats | null;
}
const snapshots: MemorySnapshot[] = [];
let interval: NodeJS.Timeout | null = null;
function toMB(bytes: number): string {
return (bytes / 1024 / 1024).toFixed(2);
}
function getMCPStats(): {
mcpConnections: ConnectionStats | null;
oauthTracker: TrackerStats | null;
} {
let mcpConnections: ConnectionStats | null = null;
let oauthTracker: TrackerStats | null = null;
try {
mcpConnections = MCPManager.getInstance().getConnectionStats();
} catch {
/* not initialized yet */
}
try {
oauthTracker = OAuthReconnectionManager.getInstance().getTrackerStats();
} catch {
/* not initialized yet */
}
return { mcpConnections, oauthTracker };
}
function getJobStats(): { generationJobs: RuntimeStats | null } {
try {
return { generationJobs: GenerationJobManager.getRuntimeStats() };
} catch {
return { generationJobs: null };
}
}
function collectSnapshot(): void {
const mem = process.memoryUsage();
const mcpStats = getMCPStats();
const jobStats = getJobStats();
const snapshot: MemorySnapshot = {
ts: Date.now(),
rss: mem.rss,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
external: mem.external,
arrayBuffers: mem.arrayBuffers ?? 0,
...mcpStats,
...jobStats,
};
snapshots.push(snapshot);
if (snapshots.length > SNAPSHOT_HISTORY_LIMIT) {
snapshots.shift();
}
logger.debug('[MemDiag] Snapshot', {
rss: `${toMB(mem.rss)} MB`,
heapUsed: `${toMB(mem.heapUsed)} MB`,
heapTotal: `${toMB(mem.heapTotal)} MB`,
external: `${toMB(mem.external)} MB`,
arrayBuffers: `${toMB(mem.arrayBuffers ?? 0)} MB`,
mcp: mcpStats,
jobs: jobStats,
snapshotCount: snapshots.length,
});
if (snapshots.length < 3) {
return;
}
const first = snapshots[0];
const last = snapshots[snapshots.length - 1];
const elapsedMin = (last.ts - first.ts) / 60_000;
if (elapsedMin < 0.1) {
return;
}
const rssDelta = last.rss - first.rss;
const heapDelta = last.heapUsed - first.heapUsed;
logger.debug('[MemDiag] Trend', {
overMinutes: elapsedMin.toFixed(1),
rssDelta: `${toMB(rssDelta)} MB`,
heapDelta: `${toMB(heapDelta)} MB`,
rssRate: `${toMB((rssDelta / elapsedMin) * 60)} MB/hr`,
heapRate: `${toMB((heapDelta / elapsedMin) * 60)} MB/hr`,
});
}
function forceGC(): boolean {
if (global.gc) {
global.gc();
logger.info('[MemDiag] Forced garbage collection');
return true;
}
logger.warn('[MemDiag] GC not exposed. Start with --expose-gc to enable.');
return false;
}
function getSnapshots(): readonly MemorySnapshot[] {
return snapshots;
}
function start(): void {
if (interval) {
return;
}
logger.info(`[MemDiag] Starting memory diagnostics (interval: ${INTERVAL_MS / 1000}s)`);
collectSnapshot();
interval = setInterval(collectSnapshot, INTERVAL_MS);
if (interval.unref) {
interval.unref();
}
}
function stop(): void {
if (!interval) {
return;
}
clearInterval(interval);
interval = null;
logger.info('[MemDiag] Stopped memory diagnostics');
}
export const memoryDiagnostics = { start, stop, forceGC, getSnapshots, collectSnapshot };

View file

@ -1,5 +1,10 @@
import { Constants } from 'librechat-data-provider';
import { sanitizeFileForTransmit, sanitizeMessageForTransmit, getThreadData } from './message';
import {
sanitizeMessageForTransmit,
sanitizeFileForTransmit,
buildMessageFiles,
getThreadData,
} from './message';
/** Cast to string for type compatibility with ThreadMessage */
const NO_PARENT = Constants.NO_PARENT as string;
@ -125,47 +130,107 @@ describe('sanitizeMessageForTransmit', () => {
});
});
describe('buildMessageFiles', () => {
const baseAttachment = {
file_id: 'file-1',
filename: 'test.png',
filepath: '/uploads/test.png',
type: 'image/png',
bytes: 512,
object: 'file' as const,
user: 'user-1',
embedded: false,
usage: 0,
text: 'big ocr text',
_id: 'mongo-id',
};
it('returns sanitized files matching request file IDs', () => {
const result = buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment]);
expect(result).toHaveLength(1);
expect(result?.[0].file_id).toBe('file-1');
expect(result?.[0]).not.toHaveProperty('text');
expect(result?.[0]).not.toHaveProperty('_id');
});
it('returns undefined when no attachments match request IDs', () => {
const result = buildMessageFiles([{ file_id: 'file-nomatch' }], [baseAttachment]);
expect(result).toEqual([]);
});
it('returns undefined for empty attachments array', () => {
const result = buildMessageFiles([{ file_id: 'file-1' }], []);
expect(result).toEqual([]);
});
it('returns undefined for empty request files array', () => {
const result = buildMessageFiles([], [baseAttachment]);
expect(result).toEqual([]);
});
it('filters out undefined file_id entries in request files (no set poisoning)', () => {
const undefinedAttachment = { ...baseAttachment, file_id: undefined as unknown as string };
const result = buildMessageFiles(
[{ file_id: undefined }, { file_id: 'file-1' }],
[undefinedAttachment, baseAttachment],
);
expect(result).toHaveLength(1);
expect(result?.[0].file_id).toBe('file-1');
});
it('returns only attachments whose file_id is in the request set', () => {
const attachment2 = { ...baseAttachment, file_id: 'file-2', filename: 'b.png' };
const result = buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment, attachment2]);
expect(result).toHaveLength(1);
expect(result?.[0].file_id).toBe('file-1');
});
it('does not mutate original attachment objects', () => {
buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment]);
expect(baseAttachment.text).toBe('big ocr text');
expect(baseAttachment._id).toBe('mongo-id');
});
});
describe('getThreadData', () => {
describe('edge cases - empty and null inputs', () => {
it('should return empty result for empty messages array', () => {
const result = getThreadData([], 'parent-123');
it('should return empty result for empty messages array', () => {
const result = getThreadData([], 'parent-123');
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
it('should return empty result for null parentMessageId', () => {
const messages = [
{ messageId: 'msg-1', parentMessageId: null },
{ messageId: 'msg-2', parentMessageId: 'msg-1' },
];
it('should return empty result for null parentMessageId', () => {
const messages = [
{ messageId: 'msg-1', parentMessageId: null },
{ messageId: 'msg-2', parentMessageId: 'msg-1' },
];
const result = getThreadData(messages, null);
const result = getThreadData(messages, null);
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
it('should return empty result for undefined parentMessageId', () => {
const messages = [{ messageId: 'msg-1', parentMessageId: null }];
it('should return empty result for undefined parentMessageId', () => {
const messages = [{ messageId: 'msg-1', parentMessageId: null }];
const result = getThreadData(messages, undefined);
const result = getThreadData(messages, undefined);
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
it('should return empty result when parentMessageId not found in messages', () => {
const messages = [
{ messageId: 'msg-1', parentMessageId: null },
{ messageId: 'msg-2', parentMessageId: 'msg-1' },
];
it('should return empty result when parentMessageId not found in messages', () => {
const messages = [
{ messageId: 'msg-1', parentMessageId: null },
{ messageId: 'msg-2', parentMessageId: 'msg-1' },
];
const result = getThreadData(messages, 'non-existent');
const result = getThreadData(messages, 'non-existent');
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
expect(result.messageIds).toEqual([]);
expect(result.fileIds).toEqual([]);
});
describe('thread traversal', () => {

View file

@ -1,6 +1,9 @@
import { Constants } from 'librechat-data-provider';
import type { TFile, TMessage } from 'librechat-data-provider';
/** Minimal shape for request file entries (from `req.body.files`) */
type RequestFile = { file_id?: string };
/** Fields to strip from files before client transmission */
const FILE_STRIP_FIELDS = ['text', '_id', '__v'] as const;
@ -32,6 +35,27 @@ export function sanitizeFileForTransmit<T extends Partial<TFile>>(
return sanitized;
}
/** Filters attachments to those whose `file_id` appears in `requestFiles`, then sanitizes each. */
export function buildMessageFiles<T extends Partial<TFile>>(
requestFiles: RequestFile[],
attachments: T[],
): Omit<T, (typeof FILE_STRIP_FIELDS)[number]>[] {
const requestFileIds = new Set<string>();
for (const f of requestFiles) {
if (f.file_id) {
requestFileIds.add(f.file_id);
}
}
const files: Omit<T, (typeof FILE_STRIP_FIELDS)[number]>[] = [];
for (const attachment of attachments) {
if (attachment.file_id != null && requestFileIds.has(attachment.file_id)) {
files.push(sanitizeFileForTransmit(attachment));
}
}
return files;
}
/**
* Sanitizes a message object before transmitting to client.
* Removes large fields like `fileContext` and strips `text` from embedded files.

View file

@ -0,0 +1,31 @@
import { logger } from '@librechat/data-schemas';
import { AsyncLocalStorage } from 'node:async_hooks';
import { isEnabled } from '~/utils/common';
/** @see https://github.com/langchain-ai/langchainjs — @langchain/core RunTree ALS */
const TRACING_ALS_KEY = Symbol.for('ls:tracing_async_local_storage');
let warnedMissing = false;
/**
* Runs `fn` outside the LangGraph/LangSmith tracing AsyncLocalStorage context
* so I/O handles (child processes, sockets, timers) created during `fn`
* do not permanently retain the RunTree graph config message data chain.
*
* Relies on the private symbol `ls:tracing_async_local_storage` from `@langchain/core`.
* If the symbol is absent, falls back to calling `fn()` directly.
*/
export function runOutsideTracing<T>(fn: () => T): T {
const storage = (globalThis as typeof globalThis & Record<symbol, AsyncLocalStorage<unknown>>)[
TRACING_ALS_KEY
];
if (!storage && !warnedMissing && isEnabled(process.env.LANGCHAIN_TRACING_V2)) {
warnedMissing = true;
logger.warn(
'[runOutsideTracing] LANGCHAIN_TRACING_V2 is set but ALS not found — ' +
'runOutsideTracing will be a no-op. ' +
'Verify @langchain/core version still uses Symbol.for("ls:tracing_async_local_storage").',
);
}
return storage ? storage.run(undefined as unknown, fn) : fn();
}

View file

@ -104,8 +104,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.0",
"rimraf": "^6.1.2",
"rollup": "^4.0.0",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-typescript2": "^0.35.0",

View file

@ -62,8 +62,8 @@
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"openapi-types": "^12.1.3",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",
"typescript": "^5.0.4"

View file

@ -3,9 +3,122 @@ import {
fileConfig as baseFileConfig,
getEndpointFileConfig,
mergeFileConfig,
applicationMimeTypes,
defaultOCRMimeTypes,
documentParserMimeTypes,
supportedMimeTypes,
} from './file-config';
import { EModelEndpoint } from './schemas';
describe('applicationMimeTypes', () => {
const odfTypes = [
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.graphics',
];
it.each(odfTypes)('matches ODF type: %s', (mimeType) => {
expect(applicationMimeTypes.test(mimeType)).toBe(true);
});
const existingTypes = [
'application/pdf',
'application/json',
'application/csv',
'application/msword',
'application/xml',
'application/zip',
'application/epub+zip',
'application/x-tar',
'application/x-sh',
'application/typescript',
'application/sql',
'application/yaml',
'application/x-parquet',
'application/vnd.apache.parquet',
'application/vnd.coffeescript',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
it.each(existingTypes)('matches existing type: %s', (mimeType) => {
expect(applicationMimeTypes.test(mimeType)).toBe(true);
});
const invalidTypes = [
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.texts',
'application/vnd.oasis.opendocument.chart',
'application/vnd.oasis.opendocument.formula',
'application/vnd.oasis.opendocument.image',
'application/vnd.oasis.opendocument.text-master',
'text/plain',
'image/png',
];
it.each(invalidTypes)('does not match invalid type: %s', (mimeType) => {
expect(applicationMimeTypes.test(mimeType)).toBe(false);
});
});
describe('defaultOCRMimeTypes', () => {
const checkOCRType = (mimeType: string): boolean =>
defaultOCRMimeTypes.some((regex) => regex.test(mimeType));
it.each([
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.graphics',
])('matches ODF type for OCR: %s', (mimeType) => {
expect(checkOCRType(mimeType)).toBe(true);
});
});
describe('supportedMimeTypes', () => {
const checkSupported = (mimeType: string): boolean =>
supportedMimeTypes.some((regex) => regex.test(mimeType));
it.each([
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.graphics',
])('ODF type flows through supportedMimeTypes: %s', (mimeType) => {
expect(checkSupported(mimeType)).toBe(true);
});
});
describe('documentParserMimeTypes', () => {
const check = (mimeType: string): boolean =>
documentParserMimeTypes.some((regex) => regex.test(mimeType));
it.each([
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/msexcel',
'application/x-msexcel',
'application/x-ms-excel',
'application/vnd.oasis.opendocument.spreadsheet',
])('matches natively parseable type: %s', (mimeType) => {
expect(check(mimeType)).toBe(true);
});
it.each([
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.graphics',
'text/plain',
'image/png',
])('does not match OCR-only or unsupported type: %s', (mimeType) => {
expect(check(mimeType)).toBe(false);
});
});
describe('getEndpointFileConfig', () => {
describe('custom endpoint lookup', () => {
it('should find custom endpoint by direct lookup', () => {

View file

@ -61,6 +61,10 @@ export const fullMimeTypesList = [
'application/xml',
'application/zip',
'application/x-parquet',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.graphics',
'image/svg',
'image/svg+xml',
// Video formats
@ -179,7 +183,7 @@ export const textMimeTypes =
/^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv|xml))$/;
export const applicationMimeTypes =
/^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/;
/^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)|xml|zip))$/;
export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/;
@ -190,10 +194,20 @@ export const videoMimeTypes = /^video\/(mp4|avi|mov|wmv|flv|webm|mkv|m4v|3gp|ogv
export const defaultOCRMimeTypes = [
imageMimeTypes,
excelMimeTypes,
/^application\/pdf$/,
/^application\/vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)$/,
/^application\/vnd\.ms-(word|powerpoint|excel)$/,
/^application\/vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)$/,
/^application\/vnd\.ms-(word|powerpoint)$/,
/^application\/epub\+zip$/,
/^application\/vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)$/,
];
/** MIME types handled by the built-in document parser (pdf, docx, excel variants, ods) */
export const documentParserMimeTypes = [
excelMimeTypes,
/^application\/pdf$/,
/^application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document$/,
/^application\/vnd\.oasis\.opendocument\.spreadsheet$/,
];
export const defaultTextMimeTypes = [/^[\w.-]+\/[\w.-]+$/];
@ -331,6 +345,10 @@ export const codeTypeMapping: { [key: string]: string } = {
tcl: 'text/plain', // .tcl - Tcl source
awk: 'text/plain', // .awk - AWK script
sed: 'text/plain', // .sed - Sed script
odt: 'application/vnd.oasis.opendocument.text', // .odt - OpenDocument Text
ods: 'application/vnd.oasis.opendocument.spreadsheet', // .ods - OpenDocument Spreadsheet
odp: 'application/vnd.oasis.opendocument.presentation', // .odp - OpenDocument Presentation
odg: 'application/vnd.oasis.opendocument.graphics', // .odg - OpenDocument Graphics
};
/** Maps image extensions to MIME types for formats browsers may not recognize */

View file

@ -50,8 +50,8 @@
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"mongodb-memory-server": "^10.1.4",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",
"ts-node": "^10.9.2",