mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge branch 'main' into feature/stored-prompt-id-responses-api
This commit is contained in:
commit
378242763b
56 changed files with 2726 additions and 473 deletions
|
|
@ -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=
|
||||
|
||||
|
|
|
|||
10
.github/workflows/backend-review.yml
vendored
10
.github/workflows/backend-review.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
174
client/src/hooks/__tests__/AuthContext.spec.tsx
Normal file
174
client/src/hooks/__tests__/AuthContext.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
128
client/src/routes/__tests__/StartupLayout.spec.tsx
Normal file
128
client/src/routes/__tests__/StartupLayout.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
266
client/src/utils/__tests__/redirect.test.ts
Normal file
266
client/src/utils/__tests__/redirect.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
66
client/src/utils/redirect.ts
Normal file
66
client/src/utils/redirect.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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
512
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
BIN
packages/api/src/files/documents/sample.ods
Normal file
BIN
packages/api/src/files/documents/sample.ods
Normal file
Binary file not shown.
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
173
packages/api/src/utils/__tests__/memory.test.ts
Normal file
173
packages/api/src/utils/__tests__/memory.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
137
packages/api/src/utils/__tests__/tracing.test.ts
Normal file
137
packages/api/src/utils/__tests__/tracing.test.ts
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -25,3 +25,4 @@ export * from './http';
|
|||
export * from './tokens';
|
||||
export * from './url';
|
||||
export * from './message';
|
||||
export * from './tracing';
|
||||
|
|
|
|||
150
packages/api/src/utils/memory.ts
Normal file
150
packages/api/src/utils/memory.ts
Normal 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 };
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
31
packages/api/src/utils/tracing.ts
Normal file
31
packages/api/src/utils/tracing.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue