chore: import upstream snapshot with attribution
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:12 +08:00
commit e115934061
3584 changed files with 848449 additions and 0 deletions
+352
View File
@@ -0,0 +1,352 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const {
math,
isEnabled,
findOpenIDUser,
getOpenIdIssuer,
buildOpenIDRefreshParams,
} = require('@librechat/api');
const {
requestPasswordReset,
setOpenIDAuthTokens,
setCloudFrontAuthCookies,
resetPassword,
setAuthTokens,
registerUser,
} = require('~/server/services/AuthService');
const {
deleteAllUserSessions,
getUserById,
findSession,
updateUser,
findUser,
} = require('~/models');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens';
const OPENID_REUSE_EXPIRY_BUFFER_SECONDS = 30;
/**
* Max age (ms) LibreChat reuses a cached OpenID session token before forcing an IdP refresh.
* Env-overridable (accepts an arithmetic expression, e.g. `60 * 60 * 24 * 1000`, like
* `SESSION_EXPIRY`): deployments whose IdP revokes the previous access token on refresh can
* widen this to the access-token lifetime so a still-valid token is not rotated/revoked out
* from under downstream consumers (e.g. MCP servers that introspect the bearer). Defaults to
* 15 minutes.
*/
const OPENID_REUSE_MAX_SESSION_AGE_MS = math(
process.env.OPENID_REUSE_MAX_SESSION_AGE_MS,
15 * 60 * 1000,
);
const registrationController = async (req, res) => {
try {
const response = await registerUser(req.body);
const { status, message } = response;
res.status(status).send({ message });
} catch (err) {
logger.error('[registrationController]', err);
return res.status(500).json({ message: err.message });
}
};
const sanitizeUserForAuthResponse = (user) => {
const source = (typeof user?.toObject === 'function' ? user.toObject() : user) || {};
const {
password: _pw,
__v: _v,
totpSecret: _ts,
backupCodes: _bc,
federatedTokens: _ft,
...safeUser
} = source;
return safeUser;
};
const getValidOpenIDReuseUserId = (parsedCookies) => {
const openidUserId = parsedCookies.openid_user_id;
if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
return null;
}
try {
const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
? payload.id
: null;
} catch {
return null;
}
};
const isRecentOpenIDSessionRefresh = (openidTokens) => {
const lastRefreshedAt = Number(openidTokens?.lastRefreshedAt);
const elapsed = Date.now() - lastRefreshedAt;
return (
Number.isFinite(lastRefreshedAt) && elapsed >= 0 && elapsed <= OPENID_REUSE_MAX_SESSION_AGE_MS
);
};
const getReusableOpenIDSessionToken = (openidTokens) => {
if (!isRecentOpenIDSessionRefresh(openidTokens)) {
return null;
}
const candidates = [
{ token: openidTokens?.idToken, type: 'id_token' },
{ token: openidTokens?.accessToken, type: 'access_token' },
];
const now = Math.floor(Date.now() / 1000);
for (const candidate of candidates) {
if (!candidate.token) {
continue;
}
/** Decode only: tokens are from the trusted server-side session; expiry gates reuse. */
const decoded = jwt.decode(candidate.token);
if (
decoded &&
typeof decoded === 'object' &&
decoded.exp > now + OPENID_REUSE_EXPIRY_BUFFER_SECONDS
) {
return candidate;
}
}
return null;
};
const resetPasswordRequestController = async (req, res) => {
try {
const resetService = await requestPasswordReset(req);
if (resetService instanceof Error) {
return res.status(400).json(resetService);
} else {
return res.status(200).json(resetService);
}
} catch (e) {
logger.error('[resetPasswordRequestController]', e);
return res.status(400).json({ message: e.message });
}
};
const resetPasswordController = async (req, res) => {
try {
const resetPasswordService = await resetPassword(
req.body.userId,
req.body.token,
req.body.password,
);
if (resetPasswordService instanceof Error) {
return res.status(400).json(resetPasswordService);
} else {
await deleteAllUserSessions({ userId: req.body.userId });
return res.status(200).json(resetPasswordService);
}
} catch (e) {
logger.error('[resetPasswordController]', e);
return res.status(400).json({ message: e.message });
}
};
const refreshController = async (req, res) => {
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const token_provider = parsedCookies.token_provider;
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
/** For OpenID users, read refresh token from session to avoid large cookie issues */
const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken;
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}
try {
/**
* Reuse skips an IdP refresh only for recently-refreshed server-side tokens.
* Stale, missing, or near-expiry tokens fall through to refreshTokenGrant so
* upstream revocations and cookie/session extension are checked regularly.
*/
const reusableSessionToken = getReusableOpenIDSessionToken(req.session?.openidTokens);
const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null;
if (reuseUserId) {
const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION);
if (user) {
const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user);
logger.debug('[refreshController] OpenID session token reused', {
token_type: reusableSessionToken.type,
has_id_token: Boolean(req.session?.openidTokens?.idToken),
has_access_token: Boolean(req.session?.openidTokens?.accessToken),
cloudfront_cookies_set: cloudFrontCookiesSet,
});
return res.status(200).send({
token: reusableSessionToken.token,
user: sanitizeUserForAuthResponse(user),
});
}
}
const openIdConfig = getOpenIdConfig();
const refreshParams = buildOpenIDRefreshParams();
logger.debug('[refreshController] OpenID refresh params', {
has_scope: Boolean(process.env.OPENID_SCOPE),
has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
});
const tokenset = await openIdClient.refreshTokenGrant(
openIdConfig,
refreshToken,
refreshParams,
);
logger.debug('[refreshController] OpenID refresh succeeded', {
has_access_token: Boolean(tokenset.access_token),
has_id_token: Boolean(tokenset.id_token),
has_refresh_token: Boolean(tokenset.refresh_token),
expires_in: tokenset.expires_in,
});
const claims = tokenset.claims();
const openidIssuer = getOpenIdIssuer(claims, openIdConfig);
const { user, error, migration } = await findOpenIDUser({
findUser,
email: getOpenIdEmail(claims),
openidId: claims.sub,
openidIssuer,
idOnTheSource: claims.oid,
strategyName: 'refreshController',
});
logger.debug(
`[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`,
);
if (error || !user) {
logger.warn(
`[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`,
);
return res.status(401).redirect('/login');
}
// Handle migration: update user with openidId if found by email without openidId
// Also handle case where user has mismatched openidId (e.g., after database switch)
if (migration || user.openidId !== claims.sub) {
const reason = migration ? 'migration' : 'openidId mismatch';
await updateUser(user._id.toString(), {
provider: 'openid',
openidId: claims.sub,
...(openidIssuer ? { openidIssuer } : {}),
});
logger.info(
`[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
);
}
const token = setOpenIDAuthTokens(tokenset, req, res, {
userId: user._id.toString(),
existingRefreshToken: refreshToken,
tenantId: user.tenantId,
});
return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);
return res.status(403).send('Invalid OpenID refresh token');
}
}
/** For non-OpenID users, read refresh token from cookies */
const refreshToken = parsedCookies.refreshToken;
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await getUserById(payload.id, AUTH_REFRESH_USER_PROJECTION);
if (!user) {
return res.status(401).redirect('/login');
}
const userId = payload.id;
if (process.env.NODE_ENV === 'CI') {
const token = await setAuthTokens(userId, res, null, req);
return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
}
/** Session with the hashed refresh token */
const session = await findSession(
{
userId: userId,
refreshToken: refreshToken,
},
{ lean: false },
);
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session, req);
res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
res.status(403).send('No session found');
} else if (payload.exp < Date.now() / 1000) {
res.status(403).redirect('/login');
} else {
res.status(401).send('Refresh token expired or not found for this user');
}
} catch (err) {
logger.error(`[refreshController] Invalid refresh token:`, err);
res.status(403).send('Invalid refresh token');
}
};
const graphTokenController = async (req, res) => {
try {
// Validate user is authenticated via Entra ID
if (!req.user.openidId || req.user.provider !== 'openid') {
return res.status(403).json({
message: 'Microsoft Graph access requires Entra ID authentication',
});
}
// Check if OpenID token reuse is active (required for on-behalf-of flow)
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return res.status(403).json({
message: 'SharePoint integration requires OpenID token reuse to be enabled',
});
}
const scopes = req.query.scopes;
if (!scopes) {
return res.status(400).json({
message: 'Graph API scopes are required as query parameter',
});
}
const accessToken = req.user.federatedTokens?.access_token;
if (!accessToken) {
return res.status(401).json({
message: 'No federated access token available for token exchange',
});
}
const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
res.json(tokenResponse);
} catch (error) {
logger.error('[graphTokenController] Failed to obtain Graph API token:', error);
res.status(500).json({
message: 'Failed to obtain Microsoft Graph token',
});
}
};
module.exports = {
refreshController,
registrationController,
resetPasswordController,
resetPasswordRequestController,
graphTokenController,
};
@@ -0,0 +1,842 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: jest.fn(),
}));
jest.mock('~/server/services/AuthService', () => ({
requestPasswordReset: jest.fn(),
setOpenIDAuthTokens: jest.fn(),
setCloudFrontAuthCookies: jest.fn(),
resetPassword: jest.fn(),
setAuthTokens: jest.fn(),
registerUser: 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(),
findSession: jest.fn(),
updateUser: jest.fn(),
findUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
math: jest.fn((value, fallback) => fallback),
isEnabled: jest.fn(),
findOpenIDUser: jest.fn(),
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
buildOpenIDRefreshParams: jest.fn(() => {
const params = {};
if (process.env.OPENID_SCOPE) {
params.scope = process.env.OPENID_SCOPE;
}
if (process.env.OPENID_REFRESH_AUDIENCE) {
params.audience = process.env.OPENID_REFRESH_AUDIENCE;
}
return params;
}),
}));
const openIdClient = require('openid-client');
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librechat/api');
const { graphTokenController, refreshController } = require('./AuthController');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const {
setOpenIDAuthTokens,
setCloudFrontAuthCookies,
setAuthTokens,
} = require('~/server/services/AuthService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const { getUserById, findSession, updateUser } = require('~/models');
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
const ORIGINAL_JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
describe('graphTokenController', () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
isEnabled.mockReturnValue(true);
req = {
user: {
openidId: 'oid-123',
provider: 'openid',
federatedTokens: {
access_token: 'federated-access-token',
id_token: 'federated-id-token',
},
},
headers: { authorization: 'Bearer app-jwt-which-is-id-token' },
query: { scopes: 'https://graph.microsoft.com/.default' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
getGraphApiToken.mockResolvedValue({
access_token: 'graph-access-token',
token_type: 'Bearer',
expires_in: 3600,
});
});
it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => {
await graphTokenController(req, res);
expect(getGraphApiToken).toHaveBeenCalledWith(
req.user,
'federated-access-token',
'https://graph.microsoft.com/.default',
);
expect(getGraphApiToken).not.toHaveBeenCalledWith(
expect.anything(),
'app-jwt-which-is-id-token',
expect.anything(),
);
});
it('should return the graph token response on success', async () => {
await graphTokenController(req, res);
expect(res.json).toHaveBeenCalledWith({
access_token: 'graph-access-token',
token_type: 'Bearer',
expires_in: 3600,
});
});
it('should return 403 when user is not authenticated via Entra ID', async () => {
req.user.provider = 'google';
req.user.openidId = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => {
isEnabled.mockReturnValue(false);
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 400 when scopes query param is missing', async () => {
req.query.scopes = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 401 when federatedTokens.access_token is missing', async () => {
req.user.federatedTokens = {};
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 401 when federatedTokens is absent entirely', async () => {
req.user.federatedTokens = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 500 when getGraphApiToken throws', async () => {
getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed'));
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: 'Failed to obtain Microsoft Graph token',
});
});
});
describe('refreshController OpenID path', () => {
const mockTokenset = {
claims: jest.fn(),
access_token: 'new-access',
id_token: 'new-id',
refresh_token: 'new-refresh',
expires_in: 3600,
};
const baseClaims = {
iss: 'https://issuer.example.com',
sub: 'oidc-sub-123',
oid: 'oid-456',
email: 'user@example.com',
exp: 9999999999,
};
const defaultUser = {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
password: '$2b$10$hashedpassword',
__v: 0,
totpSecret: 'encrypted-totp-secret',
backupCodes: ['hashed-code-1', 'hashed-code-2'],
};
let req, res;
const idpSigningSecret = 'idp-signing-secret';
const makeSessionToken = (claims = {}) =>
jwt.sign(
{
sub: baseClaims.sub,
exp: Math.floor(Date.now() / 1000) + 3600,
...claims,
},
idpSigningSecret,
);
const makeSignedUserId = (id = 'user-db-id', options = { expiresIn: '1h' }) =>
jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, options);
const setOpenIDReuseCookies = (signedUserId = makeSignedUserId()) => {
req.headers.cookie = [
'token_provider=openid',
'refreshToken=stored-refresh',
`openid_user_id=${signedUserId}`,
].join('; ');
};
beforeEach(() => {
jest.clearAllMocks();
delete process.env.OPENID_SCOPE;
delete process.env.OPENID_REFRESH_AUDIENCE;
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue({ some: 'config' });
openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset);
mockTokenset.claims.mockReturnValue(baseClaims);
getOpenIdEmail.mockReturnValue(baseClaims.email);
setOpenIDAuthTokens.mockReturnValue('new-app-token');
setCloudFrontAuthCookies.mockReturnValue(true);
findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false });
getUserById.mockResolvedValue({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
});
updateUser.mockResolvedValue({});
req = {
headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' },
session: {},
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
redirect: jest.fn(),
};
});
afterAll(() => {
if (ORIGINAL_OPENID_SCOPE === undefined) {
delete process.env.OPENID_SCOPE;
} else {
process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE;
}
if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) {
delete process.env.OPENID_REFRESH_AUDIENCE;
} else {
process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE;
}
if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
}
});
/** Asserts the full OpenID refresh grant was triggered using default mock state. */
const expectOpenIDRefreshGrant = () => {
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{},
);
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'stored-refresh',
tenantId: undefined,
});
};
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
await refreshController(req, res);
expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1);
expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({
email: baseClaims.email,
openidIssuer: baseClaims.iss,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('reuses valid OpenID session tokens and refreshes CloudFront cookies', async () => {
const reusableIdToken = makeSessionToken();
const signedUserId = makeSignedUserId();
setOpenIDReuseCookies(signedUserId);
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: reusableIdToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
const user = {
...defaultUser,
federatedTokens: { access_token: 'do-not-return' },
};
getUserById.mockResolvedValue(user);
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, user);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
token: reusableIdToken,
user: expect.objectContaining({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
}),
});
const sentPayload = res.send.mock.calls[0][0];
expect(sentPayload.user).not.toHaveProperty('password');
expect(sentPayload.user).not.toHaveProperty('totpSecret');
expect(sentPayload.user).not.toHaveProperty('backupCodes');
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
expect(logger.debug).toHaveBeenCalledWith(
'[refreshController] OpenID session token reused',
expect.objectContaining({
token_type: 'id_token',
cloudfront_cookies_set: true,
}),
);
const debugOutput = JSON.stringify(logger.debug.mock.calls);
expect(debugOutput).not.toContain(reusableIdToken);
expect(debugOutput).not.toContain(signedUserId);
expect(debugOutput).not.toContain('session-access-token');
});
it('falls through to full OpenID refresh when session tokens are expired', async () => {
const expiredToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 });
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: expiredToken,
idToken: expiredToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session tokens are near expiry', async () => {
const nearExpiryToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) + 5 });
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: nearExpiryToken,
idToken: nearExpiryToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session tokens have no exp claim', async () => {
const tokenWithoutExp = jwt.sign({ sub: baseClaims.sub }, idpSigningSecret);
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: tokenWithoutExp,
idToken: tokenWithoutExp,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when the signed reuse user cookie is invalid', async () => {
setOpenIDReuseCookies('tampered-cookie');
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when the reuse user no longer exists', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
getUserById.mockResolvedValueOnce(null);
await refreshController(req, res);
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session tokens are stale', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now() - 16 * 60 * 1000,
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh when session refresh timestamp is in the future', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now() + 60 * 1000,
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('falls through to full OpenID refresh for pre-upgrade sessions without lastRefreshedAt', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
},
};
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
});
it('sanitizes Mongoose-style user documents on the OpenID reuse path', async () => {
const reusableIdToken = makeSessionToken();
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: reusableIdToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
},
};
const userDocument = {
toObject: () => ({
...defaultUser,
federatedTokens: { access_token: 'do-not-return' },
}),
};
getUserById.mockResolvedValue(userDocument);
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, userDocument);
expect(sentPayload).toEqual({
token: reusableIdToken,
user: expect.objectContaining({
_id: 'user-db-id',
email: baseClaims.email,
}),
});
expect(sentPayload.user).not.toHaveProperty('password');
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
});
it('should pass scope-only OpenID refresh params when OPENID_SCOPE is set', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{ scope: 'openid profile email' },
);
});
it('should pass scope and audience OpenID refresh params when both are set', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{
scope: 'openid profile email',
audience: 'https://api.example.com',
},
);
});
it('should pass audience-only OpenID refresh params when scope is unset', async () => {
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{ audience: 'https://api.example.com' },
);
});
it('should omit empty OpenID refresh audience', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REFRESH_AUDIENCE = '';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
{ some: 'config' },
'stored-refresh',
{ scope: 'openid profile email' },
);
});
it('should keep OpenID refresh diagnostics free of token and audience values', async () => {
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
await refreshController(req, res);
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh params', {
has_scope: true,
has_refresh_audience: true,
});
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh succeeded', {
has_access_token: true,
has_id_token: true,
has_refresh_token: true,
expires_in: 3600,
});
const debugOutput = JSON.stringify(logger.debug.mock.calls);
expect(debugOutput).not.toContain('stored-refresh');
expect(debugOutput).not.toContain('new-access');
expect(debugOutput).not.toContain('new-id');
expect(debugOutput).not.toContain('new-refresh');
expect(debugOutput).not.toContain('https://api.example.com');
});
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',
openidIssuer: baseClaims.iss,
}),
);
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);
await refreshController(req, res);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({
email: baseClaims.email,
openidIssuer: baseClaims.iss,
}),
);
});
it('should not expose sensitive fields or federatedTokens in refresh response', async () => {
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(sentPayload).toEqual({
token: 'new-app-token',
user: expect.objectContaining({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
}),
});
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
expect(sentPayload.user).not.toHaveProperty('password');
expect(sentPayload.user).not.toHaveProperty('totpSecret');
expect(sentPayload.user).not.toHaveProperty('backupCodes');
expect(sentPayload.user).not.toHaveProperty('__v');
});
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,
openidIssuer: baseClaims.iss,
}),
);
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 preserve invalid OpenID refresh token behavior', async () => {
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
await refreshController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
});
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');
});
});
describe('refreshController LibreChat path', () => {
let req, res;
const refreshSecret = 'test-refresh-secret';
beforeEach(() => {
jest.clearAllMocks();
process.env.JWT_REFRESH_SECRET = refreshSecret;
process.env.NODE_ENV = 'test';
setAuthTokens.mockResolvedValue('local-app-token');
findSession.mockResolvedValue({ expiration: new Date(Date.now() + 60_000) });
const refreshToken = jwt.sign({ id: 'local-user-id' }, refreshSecret, {
expiresIn: '1h',
});
req = {
headers: { cookie: `refreshToken=${refreshToken}` },
query: {},
session: {},
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
redirect: jest.fn(),
};
});
afterAll(() => {
if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
}
if (ORIGINAL_NODE_ENV === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = ORIGINAL_NODE_ENV;
}
});
it('sanitizes user documents before returning local refresh responses', async () => {
getUserById.mockResolvedValue({
toObject: () => ({
_id: 'local-user-id',
email: 'local@example.com',
password: 'hashed-password',
__v: 1,
totpSecret: 'totp-secret',
backupCodes: ['backup-code'],
federatedTokens: { access_token: 'do-not-return' },
}),
});
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(setAuthTokens).toHaveBeenCalledWith(
'local-user-id',
res,
{ expiration: expect.any(Date) },
req,
);
expect(sentPayload).toEqual({
token: 'local-app-token',
user: {
_id: 'local-user-id',
email: 'local@example.com',
},
});
});
it('sanitizes user documents before returning CI refresh responses', async () => {
process.env.NODE_ENV = 'CI';
getUserById.mockResolvedValue({
toObject: () => ({
_id: 'local-user-id',
email: 'local@example.com',
password: 'hashed-password',
__v: 1,
totpSecret: 'totp-secret',
backupCodes: ['backup-code'],
federatedTokens: { access_token: 'do-not-return' },
}),
});
await refreshController(req, res);
const sentPayload = res.send.mock.calls[0][0];
expect(findSession).not.toHaveBeenCalled();
expect(setAuthTokens).toHaveBeenCalledWith('local-user-id', res, null, req);
expect(sentPayload).toEqual({
token: 'local-app-token',
user: {
_id: 'local-user-id',
email: 'local@example.com',
},
});
});
});
+28
View File
@@ -0,0 +1,28 @@
const { findBalanceByUser } = require('~/models');
async function balanceController(req, res) {
const balanceLocals = res.locals || {};
if (balanceLocals.balanceConfigEnabled === false) {
return res.sendStatus(204);
}
const balanceData = balanceLocals.balanceData ?? (await findBalanceByUser(req.user.id));
if (!balanceData) {
return res.status(404).json({ error: 'Balance not found' });
}
const { _id: _, ...result } = balanceData;
if (!result.autoRefillEnabled) {
delete result.refillIntervalValue;
delete result.refillIntervalUnit;
delete result.lastRefill;
delete result.refillAmount;
}
res.status(200).json(result);
}
module.exports = balanceController;
+72
View File
@@ -0,0 +1,72 @@
jest.mock('~/models', () => ({
findBalanceByUser: jest.fn(),
}));
const { findBalanceByUser } = require('~/models');
const balanceController = require('./Balance');
describe('balanceController', () => {
const createResponse = () => ({
locals: {},
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
sendStatus: jest.fn().mockReturnThis(),
});
beforeEach(() => {
jest.clearAllMocks();
});
it('returns no content without reading balance when balance config is disabled', async () => {
const req = {
user: { id: 'user-1' },
};
const res = createResponse();
res.locals.balanceConfigEnabled = false;
await balanceController(req, res);
expect(findBalanceByUser).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(204);
expect(res.status).not.toHaveBeenCalled();
});
it('uses balance data attached by middleware without a second read', async () => {
const req = {
user: { id: 'user-1' },
};
const res = createResponse();
res.locals.balanceConfigEnabled = true;
res.locals.balanceData = {
_id: 'balance-1',
user: 'user-1',
tokenCredits: 100,
autoRefillEnabled: false,
};
await balanceController(req, res);
expect(findBalanceByUser).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
user: 'user-1',
tokenCredits: 100,
autoRefillEnabled: false,
});
});
it('returns not found when balance is enabled and no record exists', async () => {
findBalanceByUser.mockResolvedValue(null);
const req = {
user: { id: 'user-1' },
};
const res = createResponse();
res.locals.balanceConfigEnabled = true;
await balanceController(req, res);
expect(findBalanceByUser).toHaveBeenCalledWith('user-1');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Balance not found' });
});
});
@@ -0,0 +1,8 @@
const { getEndpointsConfig } = require('~/server/services/Config');
async function endpointController(req, res) {
const endpointsConfig = await getEndpointsConfig(req);
res.send(JSON.stringify(endpointsConfig));
}
module.exports = endpointController;
@@ -0,0 +1,128 @@
const { updateUser, getUserById } = require('~/models');
const MAX_FAVORITES = 50;
const MAX_STRING_LENGTH = 256;
const updateFavoritesController = async (req, res) => {
try {
const { favorites } = req.body;
const userId = req.user.id;
if (!favorites) {
return res.status(400).json({ message: 'Favorites data is required' });
}
if (!Array.isArray(favorites)) {
return res.status(400).json({ message: 'Favorites must be an array' });
}
if (favorites.length > MAX_FAVORITES) {
return res.status(400).json({
code: 'MAX_FAVORITES_EXCEEDED',
message: `Maximum ${MAX_FAVORITES} favorites allowed`,
limit: MAX_FAVORITES,
});
}
for (const fav of favorites) {
const hasAgent = !!fav.agentId;
const hasModel = !!(fav.model && fav.endpoint);
const hasSpec = !!fav.spec;
if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) {
return res
.status(400)
.json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` });
}
if (fav.model && fav.model.length > MAX_STRING_LENGTH) {
return res
.status(400)
.json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` });
}
if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) {
return res
.status(400)
.json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` });
}
if (fav.spec !== undefined && fav.spec !== null) {
if (typeof fav.spec !== 'string' || fav.spec.length === 0) {
return res.status(400).json({ message: 'spec must be a non-empty string' });
}
if (fav.spec.length > MAX_STRING_LENGTH) {
return res
.status(400)
.json({ message: `spec exceeds maximum length of ${MAX_STRING_LENGTH}` });
}
}
const hasPartialModel = !hasModel && !!(fav.model || fav.endpoint);
if (hasPartialModel && !hasAgent && !hasSpec) {
return res.status(400).json({ message: 'model and endpoint must be provided together' });
}
const typeCount = [hasAgent, hasModel, hasSpec].filter(Boolean).length;
if (typeCount === 0) {
return res.status(400).json({
message: 'Each favorite must have either agentId, model+endpoint, or spec',
});
}
if (typeCount > 1) {
return res.status(400).json({
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
});
}
if (hasSpec && (fav.agentId || fav.model || fav.endpoint)) {
return res
.status(400)
.json({ message: 'spec cannot be combined with agentId, model, or endpoint' });
}
if (hasAgent && (fav.model || fav.endpoint)) {
return res
.status(400)
.json({ message: 'agentId cannot be combined with model or endpoint' });
}
}
const user = await updateUser(userId, { favorites });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
return res.status(200).json(user.favorites);
} catch (error) {
console.error('Error updating favorites:', error);
return res.status(500).json({ message: 'Internal server error' });
}
};
const getFavoritesController = async (req, res) => {
try {
const userId = req.user.id;
const user = await getUserById(userId, 'favorites');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
let favorites = user.favorites || [];
if (!Array.isArray(favorites)) {
favorites = [];
await updateUser(userId, { favorites: [] });
}
return res.status(200).json(favorites);
} catch (error) {
console.error('Error fetching favorites:', error);
return res.status(500).json({ message: 'Internal server error' });
}
};
module.exports = {
updateFavoritesController,
getFavoritesController,
};
@@ -0,0 +1,308 @@
jest.mock('~/models', () => ({
updateUser: jest.fn(),
getUserById: jest.fn(),
}));
const { updateUser, getUserById } = require('~/models');
const { updateFavoritesController, getFavoritesController } = require('./FavoritesController');
const makeRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
const makeReq = (body = {}) => ({
body,
user: { id: 'user-123' },
});
describe('FavoritesController', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('updateFavoritesController - payload envelope', () => {
it('rejects missing favorites key with 400', async () => {
const req = makeReq({});
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'Favorites data is required' });
expect(updateUser).not.toHaveBeenCalled();
});
it('rejects non-array favorites with 400', async () => {
const req = makeReq({ favorites: 'not-an-array' });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'Favorites must be an array' });
});
it('rejects favorites over MAX_FAVORITES with 400 + code', async () => {
const favorites = Array.from({ length: 51 }, (_, i) => ({ agentId: `agent-${i}` }));
const req = makeReq({ favorites });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
code: 'MAX_FAVORITES_EXCEEDED',
message: 'Maximum 50 favorites allowed',
limit: 50,
});
});
});
describe('updateFavoritesController - agent/model length validation', () => {
it('rejects oversized agentId', async () => {
const req = makeReq({ favorites: [{ agentId: 'a'.repeat(257) }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'agentId exceeds maximum length of 256' });
});
it('rejects oversized model', async () => {
const req = makeReq({ favorites: [{ model: 'm'.repeat(257), endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'model exceeds maximum length of 256' });
});
});
describe('updateFavoritesController - spec validation', () => {
it('accepts a valid spec favorite', async () => {
updateUser.mockResolvedValue({ favorites: [{ spec: 'my-spec' }] });
const req = makeReq({ favorites: [{ spec: 'my-spec' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith([{ spec: 'my-spec' }]);
expect(updateUser).toHaveBeenCalledWith('user-123', {
favorites: [{ spec: 'my-spec' }],
});
});
it('rejects non-string spec with 400', async () => {
const req = makeReq({ favorites: [{ spec: 42 }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
});
it('rejects empty string spec with 400', async () => {
const req = makeReq({ favorites: [{ spec: '' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
});
it('rejects oversized spec with 400', async () => {
const req = makeReq({ favorites: [{ spec: 's'.repeat(257) }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'spec exceeds maximum length of 256' });
});
it('allows undefined/null spec (treated as absent)', async () => {
updateUser.mockResolvedValue({ favorites: [{ agentId: 'a1' }] });
const req = makeReq({ favorites: [{ agentId: 'a1', spec: null }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
});
});
describe('updateFavoritesController - exclusivity (typeCount)', () => {
it('rejects empty favorite entry with 400', async () => {
const req = makeReq({ favorites: [{}] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'Each favorite must have either agentId, model+endpoint, or spec',
});
});
it('rejects agentId + model combination', async () => {
const req = makeReq({
favorites: [{ agentId: 'a1', model: 'gpt-5', endpoint: 'openai' }],
});
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
});
});
it('rejects agentId + spec combination', async () => {
const req = makeReq({ favorites: [{ agentId: 'a1', spec: 's1' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
});
});
it('rejects model + spec combination', async () => {
const req = makeReq({
favorites: [{ model: 'gpt-5', endpoint: 'openai', spec: 's1' }],
});
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('rejects spec with stray endpoint field', async () => {
const req = makeReq({ favorites: [{ spec: 's1', endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'spec cannot be combined with agentId, model, or endpoint',
});
});
it('rejects spec with stray model field', async () => {
const req = makeReq({ favorites: [{ spec: 's1', model: 'gpt-5' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'spec cannot be combined with agentId, model, or endpoint',
});
});
it('rejects agentId with stray model field (no endpoint)', async () => {
const req = makeReq({ favorites: [{ agentId: 'a1', model: 'gpt-5' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'agentId cannot be combined with model or endpoint',
});
});
it('rejects agentId with stray endpoint field (no model)', async () => {
const req = makeReq({ favorites: [{ agentId: 'a1', endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'agentId cannot be combined with model or endpoint',
});
});
it('rejects model without endpoint (partial model pair)', async () => {
const req = makeReq({ favorites: [{ model: 'gpt-5' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'model and endpoint must be provided together',
});
});
it('rejects endpoint without model (partial model pair)', async () => {
const req = makeReq({ favorites: [{ endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'model and endpoint must be provided together',
});
});
it('accepts a mixed array of valid single-type favorites', async () => {
const favorites = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' }];
updateUser.mockResolvedValue({ favorites });
const req = makeReq({ favorites });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(favorites);
});
});
describe('updateFavoritesController - persistence', () => {
it('returns 404 when user is not found', async () => {
updateUser.mockResolvedValue(null);
const req = makeReq({ favorites: [{ spec: 's1' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
});
it('returns 500 when updateUser throws', async () => {
updateUser.mockRejectedValue(new Error('db down'));
const req = makeReq({ favorites: [{ spec: 's1' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ message: 'Internal server error' });
});
});
describe('getFavoritesController', () => {
it('returns the user favorites array', async () => {
const favorites = [{ agentId: 'a1' }, { spec: 's1' }];
getUserById.mockResolvedValue({ favorites });
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(favorites);
});
it('returns [] when user.favorites is null (falsy)', async () => {
getUserById.mockResolvedValue({ favorites: null });
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(updateUser).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith([]);
});
it('repairs corrupt favorites field (non-array truthy)', async () => {
getUserById.mockResolvedValue({ favorites: 'corrupt' });
updateUser.mockResolvedValue({ favorites: [] });
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(updateUser).toHaveBeenCalledWith('user-123', { favorites: [] });
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith([]);
});
it('returns 404 when user not found', async () => {
getUserById.mockResolvedValue(null);
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(404);
});
it('returns 500 when getUserById throws', async () => {
getUserById.mockRejectedValue(new Error('db down'));
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
});
});
});
+22
View File
@@ -0,0 +1,22 @@
const { logger } = require('@librechat/data-schemas');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const getModelsConfig = (req) => loadModels(req);
async function loadModels(req) {
const defaultModelsConfig = await loadDefaultModels(req);
const customModelsConfig = await loadConfigModels(req);
return { ...defaultModelsConfig, ...customModelsConfig };
}
async function modelController(req, res) {
try {
const modelConfig = await loadModels(req);
res.send(modelConfig);
} catch (error) {
logger.error('Error fetching models:', error);
res.status(500).send({ error: error.message });
}
}
module.exports = { modelController, loadModels, getModelsConfig };
@@ -0,0 +1,570 @@
/**
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
*/
const mongoose = require('mongoose');
const { logger, getTenantId, SYSTEM_TENANT_ID } = require('@librechat/data-schemas');
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@librechat/api');
const {
bulkUpdateResourcePermissions,
ensureGroupPrincipalExists,
getResourcePermissionsMap,
findAccessibleResources,
getEffectivePermissions,
ensurePrincipalExists,
getAvailableRoles,
} = require('~/server/services/PermissionService');
const {
entraIdPrincipalFeatureEnabled,
searchEntraIdPrincipals,
} = require('~/server/services/GraphApiService');
const db = require('~/models');
const matchesCurrentTenant = (principal, tenantId) => {
if (!tenantId || tenantId === SYSTEM_TENANT_ID) {
return true;
}
return principal?.tenantId === tenantId;
};
/**
* Generic controller for resource permission endpoints
* Delegates validation and logic to PermissionService
*/
/**
* Validates that the resourceType is one of the supported enum values
* @param {string} resourceType - The resource type to validate
* @throws {Error} If resourceType is not valid
*/
const validateResourceType = (resourceType) => {
const validTypes = Object.values(ResourceType);
if (!validTypes.includes(resourceType)) {
throw new Error(`Invalid resourceType: ${resourceType}. Valid types: ${validTypes.join(', ')}`);
}
};
/**
* Bulk update permissions for a resource (grant, update, remove)
* @route PUT /api/{resourceType}/{resourceId}/permissions
* @param {Object} req - Express request object
* @param {Object} req.params - Route parameters
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
* @param {string} req.params.resourceId - Resource ID
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
* @param {Object} res - Express response object
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
*/
const updateResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
validateResourceType(resourceType);
/** @type {TUpdateResourcePermissionsRequest} */
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
const { id: userId } = req.user;
// Prepare principals for the service call
const updatedPrincipals = [];
const revokedPrincipals = [];
// Add updated principals
if (updated && Array.isArray(updated)) {
updatedPrincipals.push(...updated);
}
// Add public permission if enabled
if (isPublic && publicAccessRoleId) {
updatedPrincipals.push({
type: PrincipalType.PUBLIC,
id: null,
accessRoleId: publicAccessRoleId,
});
}
// Prepare authentication context for enhanced group member fetching
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
const authContext =
useEntraId && accessToken
? {
accessToken,
sub: req.user.openidId,
}
: null;
// Ensure updated principals exist in the database before processing permissions
const validatedPrincipals = [];
for (const principal of updatedPrincipals) {
try {
let principalId;
if (principal.type === PrincipalType.PUBLIC) {
principalId = null; // Public principals don't need database records
} else if (principal.type === PrincipalType.ROLE) {
principalId = principal.id; // Role principals use role name as ID
} else if (principal.type === PrincipalType.USER) {
principalId = await ensurePrincipalExists(principal);
} else if (principal.type === PrincipalType.GROUP) {
// Pass authContext to enable member fetching for Entra ID groups when available
principalId = await ensureGroupPrincipalExists(principal, authContext);
} else {
logger.error(`Unsupported principal type: ${principal.type}`);
continue; // Skip invalid principal types
}
// Update the principal with the validated ID for ACL operations
validatedPrincipals.push({
...principal,
id: principalId,
});
} catch (error) {
logger.error('Error ensuring principal exists:', {
principal: {
type: principal.type,
id: principal.id,
name: principal.name,
source: principal.source,
},
error: error.message,
});
// Continue with other principals instead of failing the entire operation
continue;
}
}
// Add removed principals
if (removed && Array.isArray(removed)) {
revokedPrincipals.push(...removed);
}
// If public is explicitly disabled, add public to revoked list
if (isPublic === false) {
revokedPrincipals.push({
type: PrincipalType.PUBLIC,
id: null,
});
}
const results = await bulkUpdateResourcePermissions({
resourceType,
resourceId,
updatedPrincipals: validatedPrincipals,
revokedPrincipals,
grantedBy: userId,
});
const isAgentResource =
resourceType === ResourceType.AGENT || resourceType === ResourceType.REMOTE_AGENT;
const revokedUserIds = results.revoked
.filter((p) => p.type === PrincipalType.USER && p.id)
.map((p) => p.id);
if (isAgentResource && revokedUserIds.length > 0) {
db.removeAgentFromUserFavorites(resourceId, revokedUserIds).catch((err) => {
logger.error('[removeRevokedAgentFromFavorites] Error cleaning up favorites', err);
});
}
/** @type {TUpdateResourcePermissionsResponse} */
const response = {
message: 'Permissions updated successfully',
results: {
principals: results.granted,
...(isPublic !== undefined ? { public: isPublic } : {}),
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
},
};
res.status(200).json(response);
} catch (error) {
logger.error('Error updating resource permissions:', error);
res.status(400).json({
error: 'Failed to update permissions',
details: error.message,
});
}
};
/**
* Get principals with their permission roles for a resource (UI-friendly format)
* Uses efficient aggregation pipeline to join User/Group data in single query
* @route GET /api/permissions/{resourceType}/{resourceId}
*/
const getResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
validateResourceType(resourceType);
const tenantId = getTenantId();
const results = await db.aggregateAclEntries([
// Match ACL entries for this resource
{
$match: {
resourceType,
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
? mongoose.Types.ObjectId.createFromHexString(resourceId)
: resourceId,
},
},
// Lookup AccessRole information
{
$lookup: {
from: 'accessroles',
localField: 'roleId',
foreignField: '_id',
as: 'role',
},
},
// Lookup User information (for user principals)
{
$lookup: {
from: 'users',
localField: 'principalId',
foreignField: '_id',
as: 'userInfo',
},
},
// Lookup Group information (for group principals)
{
$lookup: {
from: 'groups',
localField: 'principalId',
foreignField: '_id',
as: 'groupInfo',
},
},
// Project final structure
{
$project: {
principalType: 1,
principalId: 1,
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
userInfo: { $arrayElemAt: ['$userInfo', 0] },
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
},
},
]);
let principals = [];
let publicPermission = null;
for (const result of results) {
if (result.principalType === PrincipalType.PUBLIC) {
publicPermission = {
public: true,
publicAccessRoleId: result.accessRoleId,
};
} else if (
result.principalType === PrincipalType.USER &&
result.userInfo &&
matchesCurrentTenant(result.userInfo, tenantId)
) {
principals.push({
type: PrincipalType.USER,
id: result.userInfo._id.toString(),
name: result.userInfo.name || result.userInfo.username,
email: result.userInfo.email,
avatar: result.userInfo.avatar,
source: !result.userInfo._id ? 'entra' : 'local',
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
} else if (
result.principalType === PrincipalType.GROUP &&
result.groupInfo &&
matchesCurrentTenant(result.groupInfo, tenantId)
) {
principals.push({
type: PrincipalType.GROUP,
id: result.groupInfo._id.toString(),
name: result.groupInfo.name,
email: result.groupInfo.email,
description: result.groupInfo.description,
avatar: result.groupInfo.avatar,
source: result.groupInfo.source || 'local',
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
} else if (result.principalType === PrincipalType.ROLE) {
principals.push({
type: PrincipalType.ROLE,
/** Role name as ID */
id: result.principalId,
/** Display the role name */
name: result.principalId,
description: `System role: ${result.principalId}`,
accessRoleId: result.accessRoleId,
});
}
}
if (resourceType === ResourceType.REMOTE_AGENT) {
const enricherDeps = {
aggregateAclEntries: db.aggregateAclEntries,
bulkWriteAclEntries: db.bulkWriteAclEntries,
findRoleByIdentifier: db.findRoleByIdentifier,
logger,
};
const enrichResult = await enrichRemoteAgentPrincipals(enricherDeps, resourceId, principals);
principals = enrichResult.principals;
backfillRemoteAgentPermissions(enricherDeps, resourceId, enrichResult.entriesToBackfill);
}
// Return response in format expected by frontend
const response = {
resourceType,
resourceId,
principals,
public: publicPermission?.public || false,
...(publicPermission?.publicAccessRoleId && {
publicAccessRoleId: publicPermission.publicAccessRoleId,
}),
};
res.status(200).json(response);
} catch (error) {
logger.error('Error getting resource permissions principals:', error);
res.status(500).json({
error: 'Failed to get permissions principals',
details: error.message,
});
}
};
/**
* Get available roles for a resource type
* @route GET /api/{resourceType}/roles
*/
const getResourceRoles = async (req, res) => {
try {
const { resourceType } = req.params;
validateResourceType(resourceType);
const roles = await getAvailableRoles({ resourceType });
res.status(200).json(
roles.map((role) => ({
accessRoleId: role.accessRoleId,
name: role.name,
description: role.description,
permBits: role.permBits,
})),
);
} catch (error) {
logger.error('Error getting resource roles:', error);
res.status(500).json({
error: 'Failed to get roles',
details: error.message,
});
}
};
/**
* Get user's effective permission bitmask for a resource
* @route GET /api/{resourceType}/{resourceId}/effective
*/
const getUserEffectivePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
validateResourceType(resourceType);
const { id: userId } = req.user;
const permissionBits = await getEffectivePermissions({
userId,
role: req.user.role,
resourceType,
resourceId,
});
res.status(200).json({
permissionBits,
});
} catch (error) {
logger.error('Error getting user effective permissions:', error);
res.status(500).json({
error: 'Failed to get effective permissions',
details: error.message,
});
}
};
/**
* Search for users and groups to grant permissions
* Supports hybrid local database + Entra ID search when configured
* @route GET /api/permissions/search-principals
*/
const searchPrincipals = async (req, res) => {
try {
const { q: rawQuery, limit = 20, types } = req.query;
if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) {
return res.status(400).json({
error: 'Query parameter "q" is required and must not be empty',
});
}
const query = rawQuery.trim();
if (query.length < 2) {
return res.status(400).json({
error: 'Query must be at least 2 characters long',
});
}
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
let typeFilters = null;
if (types) {
const typesArray = Array.isArray(types) ? types : types.split(',');
const validTypes = typesArray.filter((t) =>
[PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(t),
);
typeFilters = validTypes.length > 0 ? validTypes : null;
}
const localResults = await db.searchPrincipals(query, searchLimit, typeFilters);
let allPrincipals = [...localResults];
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
if (useEntraId && localResults.length < searchLimit) {
try {
let graphType = 'all';
if (typeFilters && typeFilters.length === 1) {
const graphTypeMap = {
[PrincipalType.USER]: 'users',
[PrincipalType.GROUP]: 'groups',
};
const mappedType = graphTypeMap[typeFilters[0]];
if (mappedType) {
graphType = mappedType;
}
}
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
if (accessToken) {
const graphResults = await searchEntraIdPrincipals(
accessToken,
req.user.openidId,
query,
graphType,
searchLimit - localResults.length,
);
const localEmails = new Set(
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
);
const localGroupSourceIds = new Set(
localResults.map((p) => p.idOnTheSource).filter(Boolean),
);
for (const principal of graphResults) {
const isDuplicateByEmail =
principal.email && localEmails.has(principal.email.toLowerCase());
const isDuplicateBySourceId =
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
allPrincipals.push(principal);
}
}
}
} catch (graphError) {
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
}
}
const scoredResults = allPrincipals.map((item) => ({
...item,
_searchScore: db.calculateRelevanceScore(item, query),
}));
const finalResults = db
.sortPrincipalsByRelevance(scoredResults)
.slice(0, searchLimit)
.map((result) => {
const { _searchScore, ...resultWithoutScore } = result;
return resultWithoutScore;
});
res.status(200).json({
query,
limit: searchLimit,
types: typeFilters,
results: finalResults,
count: finalResults.length,
sources: {
local: finalResults.filter((r) => r.source === 'local').length,
entra: finalResults.filter((r) => r.source === 'entra').length,
},
});
} catch (error) {
logger.error('Error searching principals:', error);
res.status(500).json({
error: 'Failed to search principals',
});
}
};
/**
* Get user's effective permissions for all accessible resources of a type
* @route GET /api/permissions/{resourceType}/effective/all
*/
const getAllEffectivePermissions = async (req, res) => {
try {
const { resourceType } = req.params;
validateResourceType(resourceType);
const { id: userId } = req.user;
// Find all resources the user has at least VIEW access to
const accessibleResourceIds = await findAccessibleResources({
userId,
role: req.user.role,
resourceType,
requiredPermissions: PermissionBits.VIEW,
});
if (accessibleResourceIds.length === 0) {
return res.status(200).json({});
}
// Get effective permissions for all accessible resources
const permissionsMap = await getResourcePermissionsMap({
userId,
role: req.user.role,
resourceType,
resourceIds: accessibleResourceIds,
});
// Convert Map to plain object for JSON response
const result = {};
for (const [resourceId, permBits] of permissionsMap) {
result[resourceId] = permBits;
}
res.status(200).json(result);
} catch (error) {
logger.error('Error getting all effective permissions:', error);
res.status(500).json({
error: 'Failed to get all effective permissions',
details: error.message,
});
}
};
module.exports = {
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
getUserEffectivePermissions,
getAllEffectivePermissions,
searchPrincipals,
};
+114
View File
@@ -0,0 +1,114 @@
const { logger } = require('@librechat/data-schemas');
const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getAppConfig } = require('~/server/services/Config');
const getAvailablePluginsController = async (req, res) => {
try {
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
const { filteredTools = [], includedTools = [] } = appConfig;
const uniquePlugins = filterUniquePlugins(availableTools);
const includeSet = new Set(includedTools);
const filterSet = new Set(filteredTools);
/** includedTools takes precedence — filteredTools ignored when both are set. */
const plugins = [];
for (const plugin of uniquePlugins) {
/** Agents-runtime-only tools (e.g. ask_user_question) never work on the
* legacy plugins endpoint — no run to pause, no resume surface. */
if (plugin.agentsOnly === true) {
continue;
}
if (includeSet.size > 0) {
if (!includeSet.has(plugin.pluginKey)) {
continue;
}
} else if (filterSet.has(plugin.pluginKey)) {
continue;
}
plugins.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
}
res.status(200).json(plugins);
} catch (error) {
res.status(500).json({ message: error.message });
}
};
const getAvailableTools = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
let toolDefinitions = await getCachedTools();
if (toolDefinitions == null && appConfig?.availableTools != null) {
logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
await setCachedTools(appConfig.availableTools);
toolDefinitions = appConfig.availableTools;
}
const uniquePlugins = filterUniquePlugins(availableTools);
const toolDefKeysList = toolDefinitions ? Object.keys(toolDefinitions) : null;
const toolDefKeys = toolDefKeysList ? new Set(toolDefKeysList) : null;
/**
* `getAvailableTools` serves BOTH tool dialogs — /api/agents/tools and
* /api/assistants/tools. Tools flagged `agentsOnly` in the manifest (e.g.
* ask_user_question, which pauses an agents run via a LangGraph interrupt)
* cannot work on the assistants runtime: it executes tools directly with no
* run to pause and no resume surface, so attaching one there guarantees a
* permanent tool error. Scope them out of the assistants listing by route.
*/
const isAssistantsRoute = req.baseUrl?.includes('/assistants') === true;
const toolsOutput = [];
for (const plugin of uniquePlugins) {
if (plugin.agentsOnly === true && isAssistantsRoute) {
continue;
}
const isToolDefined = toolDefKeys?.has(plugin.pluginKey) === true;
const isToolkit =
plugin.toolkit === true &&
toolDefKeysList != null &&
toolDefKeysList.some(
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
);
if (!isToolDefined && !isToolkit) {
continue;
}
toolsOutput.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
}
res.status(200).json(toolsOutput);
} catch (error) {
logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message });
}
};
module.exports = {
getAvailableTools,
getAvailablePluginsController,
};
@@ -0,0 +1,478 @@
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
getAppConfig: jest.fn().mockResolvedValue({
filteredTools: [],
includedTools: [],
}),
setCachedTools: jest.fn(),
}));
jest.mock('~/app/clients/tools', () => ({
availableTools: [],
toolkits: [],
}));
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
describe('PluginController', () => {
let mockReq, mockRes;
beforeEach(() => {
jest.clearAllMocks();
mockReq = {
user: { id: 'test-user-id' },
config: {
filteredTools: [],
includedTools: [],
},
};
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
require('~/app/clients/tools').availableTools.length = 0;
require('~/app/clients/tools').toolkits.length = 0;
getCachedTools.mockReset();
getAppConfig.mockReset();
getAppConfig.mockResolvedValue({
filteredTools: [],
includedTools: [],
});
});
describe('getAvailablePluginsController', () => {
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: [],
});
await getAvailablePluginsController(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
expect(responseData[0].pluginKey).toBe('key1');
expect(responseData[1].pluginKey).toBe('key2');
});
it('should use checkPluginAuth to verify plugin authentication', async () => {
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
require('~/app/clients/tools').availableTools.push(mockPlugin);
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: [],
});
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBeUndefined();
});
it('excludes agentsOnly plugins from the legacy plugins endpoint (no run to pause)', async () => {
require('~/app/clients/tools').availableTools.push(
{ name: 'Ask User', pluginKey: 'ask_user_question', description: 'q', agentsOnly: true },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
);
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData.map((p) => p.pluginKey)).toEqual(['key2']);
});
it('should filter plugins based on includedTools', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockReq.config = {
filteredTools: [],
includedTools: ['key1'],
};
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(1);
expect(responseData[0].pluginKey).toBe('key1');
});
it('should exclude plugins in filteredTools', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockReq.config = {
filteredTools: ['key2'],
includedTools: [],
};
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(1);
expect(responseData[0].pluginKey).toBe('key1');
});
it('should ignore filteredTools when includedTools is set', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
{ name: 'Plugin3', pluginKey: 'key3', description: 'Third' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockReq.config = {
includedTools: ['key1', 'key2'],
filteredTools: ['key2'],
};
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
expect(responseData.map((p) => p.pluginKey)).toEqual(['key1', 'key2']);
});
});
describe('getAvailableTools', () => {
it('scopes agentsOnly plugins out of the ASSISTANTS listing but keeps them for agents', async () => {
const cached = {
ask_user_question: {
type: 'function',
function: { name: 'ask_user_question', description: 'q', parameters: {} },
},
};
require('~/app/clients/tools').availableTools.push({
name: 'Ask User',
pluginKey: 'ask_user_question',
description: 'q',
agentsOnly: true,
});
// Agents route: listed.
getCachedTools.mockResolvedValueOnce(cached);
mockReq.baseUrl = '/api/agents/tools';
await getAvailableTools(mockReq, mockRes);
expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).toContain('ask_user_question');
// Assistants route: the runtime executes tools with no run to pause — excluded.
mockRes.json.mockClear();
getCachedTools.mockResolvedValueOnce(cached);
mockReq.baseUrl = '/api/assistants/v2/tools';
await getAvailableTools(mockReq, mockRes);
expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).not.toContain(
'ask_user_question',
);
});
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
const mockUserTools = {
'user-tool': {
type: 'function',
function: {
name: 'user-tool',
description: 'User tool',
parameters: { type: 'object', properties: {} },
},
},
};
require('~/app/clients/tools').availableTools.push(
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
);
getCachedTools.mockResolvedValueOnce(mockUserTools);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
expect(userToolCount).toBe(1);
});
it('should use checkPluginAuth to verify authentication status', async () => {
const mockPlugin = {
name: 'Tool1',
pluginKey: 'tool1',
description: 'Tool 1',
};
require('~/app/clients/tools').availableTools.push(mockPlugin);
getCachedTools.mockResolvedValueOnce({
tool1: {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
const tool = responseData.find((t) => t.pluginKey === 'tool1');
expect(tool).toBeDefined();
expect(tool.authenticated).toBeUndefined();
});
it('should use getToolkitKey for toolkit validation', async () => {
const mockToolkit = {
name: 'Toolkit1',
pluginKey: 'toolkit1',
description: 'Toolkit 1',
toolkit: true,
};
require('~/app/clients/tools').availableTools.push(mockToolkit);
require('~/app/clients/tools').toolkits.push({
name: 'Toolkit1',
pluginKey: 'toolkit1',
tools: ['toolkit1_function'],
});
getCachedTools.mockResolvedValueOnce({
toolkit1_function: {
type: 'function',
function: {
name: 'toolkit1_function',
description: 'Toolkit function',
parameters: {},
},
},
});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
expect(toolkit).toBeDefined();
});
});
describe('helper function integration', () => {
it('should handle error cases gracefully', async () => {
getCachedTools.mockRejectedValue(new Error('Cache error'));
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
});
});
describe('edge cases with undefined/null values', () => {
it('should handle null cachedTools', async () => {
getCachedTools.mockResolvedValueOnce({});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle when getCachedTools returns undefined', async () => {
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
getCachedTools.mockReset();
getCachedTools.mockResolvedValueOnce(undefined);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle empty toolDefinitions object', async () => {
getCachedTools.mockReset();
getCachedTools.mockResolvedValue({});
mockReq.config = {};
require('~/app/clients/tools').availableTools.length = 0;
await getAvailableTools(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle undefined filteredTools and includedTools', async () => {
mockReq.config = {};
getAppConfig.mockResolvedValueOnce({});
await getAvailablePluginsController(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle toolkit with undefined toolDefinitions keys', async () => {
const mockToolkit = {
name: 'Toolkit1',
pluginKey: 'toolkit1',
description: 'Toolkit 1',
toolkit: true,
};
require('~/app/clients/tools').availableTools.push(mockToolkit);
getCachedTools.mockResolvedValueOnce({});
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('should handle undefined toolDefinitions when checking isToolDefined', async () => {
const mockPlugin = {
name: 'Traversaal Search',
pluginKey: 'traversaal_search',
description: 'Search plugin',
};
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
getCachedTools.mockResolvedValueOnce(undefined);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should re-initialize tools from appConfig when cache returns null', async () => {
const mockAppTools = {
tool1: {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
tool2: {
type: 'function',
function: {
name: 'tool2',
description: 'Tool 2',
parameters: {},
},
},
};
require('~/app/clients/tools').availableTools.push(
{ name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
);
getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
filteredTools: [],
includedTools: [],
availableTools: mockAppTools,
};
const { setCachedTools } = require('~/server/services/Config');
await getAvailableTools(mockReq, mockRes);
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined();
expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined();
});
it('should handle cache clear without appConfig.availableTools gracefully', async () => {
getAppConfig.mockResolvedValue({
filteredTools: [],
includedTools: [],
});
require('~/app/clients/tools').availableTools.length = 0;
getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
filteredTools: [],
includedTools: [],
};
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
});
});
@@ -0,0 +1,100 @@
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const {
MAX_SKILL_STATES,
toSkillStatesRecord,
validateSkillStatesPayload,
pruneOrphanSkillStates,
getDeploymentSkillIds,
mergeDeploymentSkillIds,
} = require('@librechat/api');
const { ResourceType, PermissionBits } = require('librechat-data-provider');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { updateUser, getUserById } = require('~/models');
/** Builds the injected deps for `pruneOrphanSkillStates` from live models. */
function buildPruneDeps(user) {
return {
findExistingSkillIds: async (validIds) => {
const Skill = mongoose.models.Skill;
if (!Skill) {
return validIds;
}
const existing = await Skill.find({ _id: { $in: validIds } })
.select('_id')
.lean();
const deploymentIds = getDeploymentSkillIds()
.map((id) => id.toString())
.filter((id) => validIds.includes(id));
return [...existing.map((doc) => doc._id.toString()), ...deploymentIds];
},
findAccessibleSkillIds: async () =>
mergeDeploymentSkillIds(
await findAccessibleResources({
userId: user.id,
role: user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
),
};
}
const getSkillStatesController = async (req, res) => {
try {
const userId = req.user.id;
const user = await getUserById(userId, 'skillStates');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const states = toSkillStatesRecord(user.skillStates);
const pruned = await pruneOrphanSkillStates(states, buildPruneDeps(req.user));
return res.status(200).json(pruned);
} catch (error) {
logger.error('[SkillStatesController] Error fetching skill states:', error);
return res.status(500).json({ message: 'Internal server error' });
}
};
const updateSkillStatesController = async (req, res) => {
try {
const { skillStates } = req.body;
const validationError = validateSkillStatesPayload(skillStates);
if (validationError) {
const { message, code, limit } = validationError;
const payload = { message };
if (code) payload.code = code;
if (limit != null) payload.limit = limit;
return res.status(400).json(payload);
}
const pruned = await pruneOrphanSkillStates(skillStates, buildPruneDeps(req.user));
if (Object.keys(pruned).length > MAX_SKILL_STATES) {
return res.status(400).json({
code: 'MAX_SKILL_STATES_EXCEEDED',
message: `Maximum ${MAX_SKILL_STATES} skill state overrides allowed`,
limit: MAX_SKILL_STATES,
});
}
const user = await updateUser(req.user.id, { skillStates: pruned });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
return res.status(200).json(toSkillStatesRecord(user.skillStates));
} catch (error) {
logger.error('[SkillStatesController] Error updating skill states:', error);
return res.status(500).json({ message: 'Internal server error' });
}
};
module.exports = {
getSkillStatesController,
updateSkillStatesController,
};
@@ -0,0 +1,32 @@
const { logger } = require('@librechat/data-schemas');
const { resolveTokenConfigMap } = require('@librechat/api');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { getValueKey, getMultiplier, getCacheMultiplier } = require('~/models');
/**
* Returns server-resolved context windows (and pricing when
* `interface.contextCost` is enabled) for every configured model. Resolution
* lives in `@librechat/api`; this controller only supplies request-scoped deps.
* @param {ServerRequest} req
* @param {ServerResponse} res
*/
async function tokenConfigController(req, res) {
try {
const modelsConfig = await getModelsConfig(req);
const tokenConfigMap = await resolveTokenConfigMap(
{
appConfig: req.config,
modelsConfig,
userId: req.user.id,
tenantId: req.user.tenantId,
},
{ getValueKey, getMultiplier, getCacheMultiplier },
);
res.json(tokenConfigMap);
} catch (error) {
logger.error('[tokenConfigController]', error);
res.status(500).json({ error: 'Failed to resolve token config' });
}
}
module.exports = tokenConfigController;
@@ -0,0 +1,210 @@
const { encryptV3, logger } = require('@librechat/data-schemas');
const {
verifyOTPOrBackupCode,
generateBackupCodes,
generateTOTPSecret,
verifyBackupCode,
getTOTPSecret,
verifyTOTP,
} = require('~/server/services/twoFactorService');
const { getUserById, updateUser } = require('~/models');
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
/**
* Enable 2FA for the user by generating a new TOTP secret and backup codes.
* The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
* If 2FA is already enabled, requires OTP or backup code verification to re-enroll.
*/
const enable2FA = async (req, res) => {
try {
const userId = req.user.id;
const existingUser = await getUserById(
userId,
'+totpSecret +backupCodes _id twoFactorEnabled email',
);
if (existingUser && existingUser.twoFactorEnabled) {
const { token, backupCode } = req.body;
const result = await verifyOTPOrBackupCode({
user: existingUser,
token,
backupCode,
persistBackupUse: false,
});
if (!result.verified) {
const msg = result.message ?? 'TOTP token or backup code is required to re-enroll 2FA';
return res.status(result.status ?? 400).json({ message: msg });
}
}
const secret = generateTOTPSecret();
const { plainCodes, codeObjects } = await generateBackupCodes();
const encryptedSecret = encryptV3(secret);
const user = await updateUser(userId, {
pendingTotpSecret: encryptedSecret,
pendingBackupCodes: codeObjects,
});
const email = user.email || (existingUser && existingUser.email) || '';
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${email}?secret=${secret}&issuer=${safeAppTitle}`;
return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
} catch (err) {
logger.error('[enable2FA]', err);
return res.status(500).json({ message: err.message });
}
};
/**
* Verify a 2FA code (either TOTP or backup code) during setup.
*/
const verify2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId, '+totpSecret +pendingTotpSecret +backupCodes _id');
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
if (!user || !secretSource) {
return res.status(400).json({ message: '2FA not initiated' });
}
const secret = await getTOTPSecret(secretSource);
let isVerified = false;
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
}
if (isVerified) {
return res.status(200).json();
}
return res.status(400).json({ message: 'Invalid token or backup code.' });
} catch (err) {
logger.error('[verify2FA]', err);
return res.status(500).json({ message: err.message });
}
};
/**
* Confirm and enable 2FA after a successful verification.
*/
const confirm2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token } = req.body;
const user = await getUserById(
userId,
'+totpSecret +pendingTotpSecret +pendingBackupCodes _id',
);
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
if (!user || !secretSource) {
return res.status(400).json({ message: '2FA not initiated' });
}
const secret = await getTOTPSecret(secretSource);
if (await verifyTOTP(secret, token)) {
const update = {
totpSecret: user.pendingTotpSecret ?? user.totpSecret,
twoFactorEnabled: true,
pendingTotpSecret: null,
pendingBackupCodes: [],
};
if (user.pendingBackupCodes?.length) {
update.backupCodes = user.pendingBackupCodes;
}
await updateUser(userId, update);
return res.status(200).json();
}
return res.status(400).json({ message: 'Invalid token.' });
} catch (err) {
logger.error('[confirm2FA]', err);
return res.status(500).json({ message: err.message });
}
};
/**
* Disable 2FA by clearing the stored secret and backup codes.
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
*/
const disable2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA is not setup for this user' });
}
if (user.twoFactorEnabled) {
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
if (!result.verified) {
const msg = result.message ?? 'Either token or backup code is required to disable 2FA';
return res.status(result.status ?? 400).json({ message: msg });
}
}
await updateUser(userId, {
totpSecret: null,
backupCodes: [],
twoFactorEnabled: false,
pendingTotpSecret: null,
pendingBackupCodes: [],
});
return res.status(200).json();
} catch (err) {
logger.error('[disable2FA]', err);
return res.status(500).json({ message: err.message });
}
};
/**
* Regenerate backup codes for the user.
* Requires OTP or backup code verification if 2FA is already enabled.
*/
const regenerateBackupCodes = async (req, res) => {
try {
const userId = req.user.id;
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
if (user.twoFactorEnabled) {
const { token, backupCode } = req.body;
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
if (!result.verified) {
const msg =
result.message ?? 'TOTP token or backup code is required to regenerate backup codes';
return res.status(result.status ?? 400).json({ message: msg });
}
}
const { plainCodes, codeObjects } = await generateBackupCodes();
await updateUser(userId, { backupCodes: codeObjects });
return res.status(200).json({
backupCodes: plainCodes,
backupCodesHash: codeObjects,
});
} catch (err) {
logger.error('[regenerateBackupCodes]', err);
return res.status(500).json({ message: err.message });
}
};
module.exports = {
enable2FA,
verify2FA,
confirm2FA,
disable2FA,
regenerateBackupCodes,
};
+611
View File
@@ -0,0 +1,611 @@
const mongoose = require('mongoose');
const { logger, getTenantId, webSearchKeys } = require('@librechat/data-schemas');
const {
getNewS3URL,
needsRefresh,
MCPOAuthHandler,
MCPTokenStorage,
getAppConfigOptionsFromUser,
normalizeHttpError,
extractWebSearchEnvVars,
deleteAgentCheckpoints,
deleteAllSharedLinksWithCleanup,
} = require('@librechat/api');
const {
Tools,
CacheKeys,
Constants,
FileSources,
ResourceType,
} = require('librechat-data-provider');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
const db = require('~/models');
const PUBLIC_USER_RESPONSE_FIELDS = [
'_id',
'id',
'name',
'username',
'email',
'emailVerified',
'avatar',
'provider',
'role',
'plugins',
'twoFactorEnabled',
'termsAccepted',
'personalization',
'favorites',
'skillStates',
'createdAt',
'updatedAt',
'tenantId',
];
const sanitizeUserForResponse = (user) => {
const source = user.toObject != null ? user.toObject() : user;
return PUBLIC_USER_RESPONSE_FIELDS.reduce((userData, field) => {
if (source[field] !== undefined) {
userData[field] = source[field];
}
return userData;
}, {});
};
const getUserController = async (req, res) => {
const appConfig = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
/** @type {IUser} */
const userData = sanitizeUserForResponse(req.user);
if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) {
const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
if (!avatarNeedsRefresh) {
return res.status(200).send(userData);
}
const originalAvatar = userData.avatar;
try {
userData.avatar = await getNewS3URL(userData.avatar);
await db.updateUser(userData.id, { avatar: userData.avatar });
} catch (error) {
userData.avatar = originalAvatar;
logger.error('Error getting new S3 URL for avatar:', error);
}
}
res.status(200).send(userData);
};
const getTermsStatusController = async (req, res) => {
try {
const user = await db.getUserById(req.user.id, 'termsAccepted termsAcceptedAt');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({
termsAccepted: !!user.termsAccepted,
termsAcceptedAt: user.termsAcceptedAt || null,
});
} catch (error) {
logger.error('Error fetching terms acceptance status:', error);
res.status(500).json({ message: 'Error fetching terms acceptance status' });
}
};
const acceptTermsController = async (req, res) => {
try {
const user = await db.acceptTerms(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({
message: 'Terms accepted successfully',
termsAcceptedAt: user.termsAcceptedAt,
});
} catch (error) {
logger.error('Error accepting terms:', error);
res.status(500).json({ message: 'Error accepting terms' });
}
};
const deleteUserFiles = async (req) => {
try {
const userFiles = await db.getFiles({ user: req.user.id });
await processDeleteRequest({
req,
files: userFiles,
});
} catch (error) {
logger.error('[deleteUserFiles]', error);
}
};
/**
* Deletes MCP servers solely owned by the user and cleans up their ACLs.
* Disconnects live sessions for deleted servers before removing DB records.
* Servers with other owners are left intact; the caller is responsible for
* removing the user's own ACL principal entries separately.
*
* Also handles legacy (pre-ACL) MCP servers that only have the author field set,
* ensuring they are not orphaned if no permission migration has been run.
* @param {string} userId - The ID of the user.
*/
const deleteUserMcpServers = async (userId) => {
try {
const MCPServer = mongoose.models.MCPServer;
const AclEntry = mongoose.models.AclEntry;
if (!MCPServer) {
return;
}
const userObjectId = new mongoose.Types.ObjectId(userId);
const soleOwnedIds = await db.getSoleOwnedResourceIds(userObjectId, ResourceType.MCPSERVER);
const authoredServers = await MCPServer.find({ author: userObjectId })
.select('_id serverName')
.lean();
const migratedEntries =
authoredServers.length > 0
? await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: { $in: authoredServers.map((s) => s._id) },
})
.select('resourceId')
.lean()
: [];
const migratedIds = new Set(migratedEntries.map((e) => e.resourceId.toString()));
const legacyServers = authoredServers.filter((s) => !migratedIds.has(s._id.toString()));
const legacyServerIds = legacyServers.map((s) => s._id);
const allServerIdsToDelete = [...soleOwnedIds, ...legacyServerIds];
if (allServerIdsToDelete.length === 0) {
return;
}
const aclOwnedServers =
soleOwnedIds.length > 0
? await MCPServer.find({ _id: { $in: soleOwnedIds } })
.select('serverName')
.lean()
: [];
const allServersToDelete = [...aclOwnedServers, ...legacyServers];
const mcpManager = getMCPManager();
if (mcpManager) {
await Promise.all(
allServersToDelete.map(async (s) => {
await mcpManager.disconnectUserConnection(userId, s.serverName);
await invalidateCachedTools({ userId, serverName: s.serverName });
}),
);
}
await AclEntry.deleteMany({
resourceType: ResourceType.MCPSERVER,
resourceId: { $in: allServerIdsToDelete },
});
await MCPServer.deleteMany({ _id: { $in: allServerIdsToDelete } });
} catch (error) {
logger.error('[deleteUserMcpServers] General error:', error);
}
};
const updateUserPluginsController = async (req, res) => {
const appConfig = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
const { user } = req;
const { pluginKey, action, auth, isEntityTool } = req.body;
try {
if (!isEntityTool) {
await db.updateUserPlugins(user._id, user.plugins, pluginKey, action);
}
if (auth == null) {
return res.status(200).send();
}
let keys = Object.keys(auth);
const values = Object.values(auth); // Used in 'install' block
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
// Early exit condition:
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
// THEN return.
if (
keys.length === 0 &&
pluginKey !== Tools.web_search &&
!(action === 'uninstall' && isMCPTool)
) {
return res.status(200).send();
}
/** @type {number} */
let status = 200;
/** @type {string} */
let message;
/** @type {IPluginAuth | Error} */
let authService;
if (pluginKey === Tools.web_search) {
/** @type {TCustomConfig['webSearch']} */
const webSearchConfig = appConfig?.webSearch;
keys = extractWebSearchEnvVars({
keys: action === 'install' ? keys : webSearchKeys,
config: webSearchConfig,
});
}
if (action === 'install') {
for (let i = 0; i < keys.length; i++) {
authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
if (authService instanceof Error) {
logger.error('[authService]', authService);
({ status, message } = normalizeHttpError(authService));
}
}
} else if (action === 'uninstall') {
// const isMCPTool was defined earlier
if (isMCPTool && keys.length === 0) {
// This handles the case where auth: {} is sent for an MCP tool uninstall.
// It means "delete all credentials associated with this MCP pluginKey".
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
if (authService instanceof Error) {
logger.error(
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
authService,
);
({ status, message } = normalizeHttpError(authService));
}
try {
// if the MCP server uses OAuth, perform a full cleanup and token revocation
await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
} catch (error) {
logger.error(
`[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
error,
);
}
} else {
// This handles:
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
// 2. Other tools uninstall (if keys were provided).
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
if (authService instanceof Error) {
logger.error('[authService] Error deleting specific auth key:', authService);
({ status, message } = normalizeHttpError(authService));
}
}
}
}
if (status === 200) {
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
if (pluginKey.startsWith(Constants.mcp_prefix)) {
try {
const mcpManager = getMCPManager();
if (mcpManager) {
// Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
logger.info(
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
);
await mcpManager.disconnectUserConnection(user.id, serverName);
await invalidateCachedTools({ userId: user.id, serverName });
}
} catch (disconnectError) {
logger.error(
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
disconnectError,
);
// Do not fail the request for this, but log it.
}
}
return res.status(status).send();
}
const normalized = normalizeHttpError({ status, message });
return res.status(normalized.status).send({ message: normalized.message });
} catch (err) {
logger.error('[updateUserPluginsController]', err);
return res.status(500).json({ message: 'Something went wrong.' });
}
};
const deleteUserController = async (req, res) => {
const { user } = req;
try {
const existingUser = await db.getUserById(
user.id,
'+totpSecret +backupCodes _id twoFactorEnabled',
);
if (existingUser && existingUser.twoFactorEnabled) {
const { token, backupCode } = req.body;
const result = await verifyOTPOrBackupCode({ user: existingUser, token, backupCode });
if (!result.verified) {
const msg =
result.message ??
'TOTP token or backup code is required to delete account with 2FA enabled';
return res.status(result.status ?? 400).json({ message: msg });
}
}
await db.deleteMessages({ user: user.id });
await db.deleteAllUserSessions({ userId: user.id });
await db.deleteTransactions({ user: user.id });
await db.deleteUserKey({ userId: user.id, all: true });
await db.deleteBalances({ user: user._id });
await db.deletePresets(user.id);
try {
const convoDeletion = await db.deleteConvos(user.id);
// HITL: prune the deleted conversations' durable checkpoints — a paused run's
// checkpoint would otherwise persist until the Mongo TTL. Never throws.
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
await deleteAgentCheckpoints(
convoDeletion?.conversationIds,
appConfig?.endpoints?.agents?.checkpointer,
);
} catch (error) {
logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
}
await deleteUserPluginAuth(user.id, null, true);
await db.deleteUserById(user.id);
await deleteAllSharedLinksWithCleanup(user.id);
await deleteUserFiles(req);
await db.deleteFiles(null, user.id);
await db.deleteToolCalls(user.id);
await db.deleteUserAgents(user.id);
await db.deleteAllAgentApiKeys(user._id);
await db.deleteAssistants({ user: user.id });
await db.deleteConversationTags({ user: user.id });
await db.deleteAllUserMemories(user.id);
await db.deleteUserPrompts(user.id);
await db.deleteUserSkills(user.id);
await deleteUserMcpServers(user.id);
await db.deleteActions({ user: user.id });
await db.deleteTokens({ userId: user.id });
await db.removeUserFromAllGroups(user.id);
await db.deleteAclEntries({ principalId: user._id });
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {
logger.error('[deleteUserController]', err);
return res.status(500).json({ message: 'Something went wrong.' });
}
};
const verifyEmailController = async (req, res) => {
try {
const verifyEmailService = await verifyEmail(req);
if (verifyEmailService instanceof Error) {
return res.status(400).json({ message: verifyEmailService.message });
} else {
return res.status(200).json(verifyEmailService);
}
} catch (e) {
logger.error('[verifyEmailController]', e);
return res.status(500).json({ message: 'Something went wrong.' });
}
};
const resendVerificationController = async (req, res) => {
try {
const result = await resendVerificationEmail(req);
if (result instanceof Error) {
return res.status(400).json({ message: result.message });
} else {
return res.status(result.status ?? 200).json({ message: result.message });
}
} catch (e) {
logger.error('[verifyEmailController]', e);
return res.status(500).json({ message: 'Something went wrong.' });
}
};
/** Best-effort cleanup of stored MCP OAuth tokens and flow state. */
const clearStoredMCPOAuthState = async (userId, serverName) => {
try {
await MCPTokenStorage.deleteUserTokens({
userId,
serverName,
deleteToken: async (filter) => {
await db.deleteTokens(filter);
},
});
} catch (error) {
logger.warn(
`[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for ${serverName}:`,
error,
);
}
try {
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const baseFlowId = MCPOAuthHandler.generateFlowId(userId, serverName);
const tenantId = getTenantId();
const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(userId, serverName, tenantId);
const oauthFlowId = MCPOAuthHandler.generateFlowId(userId, serverName, tenantId);
const flowDeletes = [
[tokenFlowId, 'mcp_get_tokens'],
[oauthFlowId, 'mcp_oauth'],
[baseFlowId, 'mcp_get_tokens'],
[baseFlowId, 'mcp_oauth'],
].filter(
([flowId, type], index, deletes) =>
deletes.findIndex(([candidateId, candidateType]) => {
return candidateId === flowId && candidateType === type;
}) === index,
);
const results = await Promise.allSettled(
flowDeletes.map(([flowId, type]) => flowManager.deleteFlow(flowId, type)),
);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn(
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
result.reason,
);
}
}
} catch (error) {
logger.warn(
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
error,
);
}
};
/** Revokes MCP OAuth tokens at the provider when possible, then clears local state. */
const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
if (!pluginKey.startsWith(Constants.mcp_prefix)) {
// this is not an MCP server, so nothing to do here
return;
}
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
const serverConfig =
(await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
appConfig?.mcpServers?.[serverName];
const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
if (!oauthServers.has(serverName) || !serverConfig) {
await clearStoredMCPOAuthState(userId, serverName);
return;
}
// 1. get client info used for revocation (client id, secret)
let clientTokenData = null;
try {
clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
userId,
serverName,
findToken: db.findToken,
});
} catch (error) {
logger.warn(
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
error,
);
await clearStoredMCPOAuthState(userId, serverName);
return;
}
if (clientTokenData == null) {
logger.info(
`[maybeUninstallOAuthMCP] Missing OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
);
await clearStoredMCPOAuthState(userId, serverName);
return;
}
const { clientInfo, clientMetadata } = clientTokenData;
// 2. get decrypted tokens before deletion
let tokens = null;
try {
tokens = await MCPTokenStorage.getTokens({
userId,
serverName,
findToken: db.findToken,
});
} catch (error) {
logger.warn(
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
error,
);
}
// 3. revoke OAuth tokens at the provider
const revocationEndpoint =
serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
const revocationEndpointAuthMethodsSupported =
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
clientMetadata.revocation_endpoint_auth_methods_supported;
const oauthHeaders = serverConfig.oauth_headers ?? {};
// Use the request's merged (tenant/principal-scoped) allowlists so admin-panel mcpSettings
// overrides are honored for OAuth revocation, consistent with inspection/connection.
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
if (tokens?.access_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(
serverName,
tokens.access_token,
'access',
{
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
},
oauthHeaders,
allowedDomains,
allowedAddresses,
);
} catch (error) {
logger.error(
`[maybeUninstallOAuthMCP] Error revoking OAuth access token for ${serverName}:`,
error,
);
}
}
if (tokens?.refresh_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(
serverName,
tokens.refresh_token,
'refresh',
{
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
},
oauthHeaders,
allowedDomains,
allowedAddresses,
);
} catch (error) {
logger.error(
`[maybeUninstallOAuthMCP] Error revoking OAuth refresh token for ${serverName}:`,
error,
);
}
}
// 4. delete tokens from the DB and clear the flow state after revocation attempts
await clearStoredMCPOAuthState(userId, serverName);
};
module.exports = {
getUserController,
getTermsStatusController,
acceptTermsController,
deleteUserController,
verifyEmailController,
updateUserPluginsController,
resendVerificationController,
deleteUserMcpServers,
maybeUninstallOAuthMCP,
};
@@ -0,0 +1,416 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
};
});
jest.mock('~/models', () => {
const _mongoose = require('mongoose');
return {
deleteAllUserSessions: jest.fn().mockResolvedValue(undefined),
deleteAllSharedLinks: jest.fn().mockResolvedValue(undefined),
deleteAllAgentApiKeys: jest.fn().mockResolvedValue(undefined),
deleteConversationTags: jest.fn().mockResolvedValue(undefined),
deleteAllUserMemories: jest.fn().mockResolvedValue(undefined),
deleteTransactions: jest.fn().mockResolvedValue(undefined),
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
updateUserPlugins: jest.fn(),
deleteAssistants: jest.fn().mockResolvedValue(undefined),
deleteUserById: jest.fn().mockResolvedValue(undefined),
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
deleteUserSkills: jest.fn().mockResolvedValue(undefined),
deleteMessages: jest.fn().mockResolvedValue(undefined),
deleteBalances: jest.fn().mockResolvedValue(undefined),
deleteActions: jest.fn().mockResolvedValue(undefined),
deletePresets: jest.fn().mockResolvedValue(undefined),
deleteUserKey: jest.fn().mockResolvedValue(undefined),
deleteToolCalls: jest.fn().mockResolvedValue(undefined),
deleteUserAgents: jest.fn().mockResolvedValue(undefined),
deleteTokens: jest.fn().mockResolvedValue(undefined),
deleteConvos: jest.fn().mockResolvedValue(undefined),
deleteFiles: jest.fn().mockResolvedValue(undefined),
updateUser: jest.fn(),
acceptTerms: jest.fn(),
getUserById: jest.fn().mockResolvedValue(null),
findToken: jest.fn(),
getFiles: jest.fn().mockResolvedValue([]),
removeUserFromAllGroups: jest.fn().mockImplementation(async (userId) => {
const Group = _mongoose.models.Group;
await Group.updateMany({ memberIds: userId }, { $pullAll: { memberIds: [userId] } });
}),
};
});
jest.mock('~/server/services/PluginService', () => ({
updateUserPluginAuth: jest.fn(),
deleteUserPluginAuth: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('~/server/services/AuthService', () => ({
verifyEmail: jest.fn(),
resendVerificationEmail: jest.fn(),
}));
jest.mock('sharp', () =>
jest.fn(() => ({
metadata: jest.fn().mockResolvedValue({}),
toFormat: jest.fn().mockReturnThis(),
toBuffer: jest.fn().mockResolvedValue(Buffer.alloc(0)),
})),
);
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({}),
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
const {
deleteUserController,
getUserController,
acceptTermsController,
resendVerificationController,
verifyEmailController,
} = require('./UserController');
const { Group } = require('~/db/models');
const { deleteConvos, acceptTerms } = require('~/models');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
describe('verifyEmailController', () => {
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('returns the generic verification error message from service failures', async () => {
verifyEmail.mockResolvedValue(new Error('Invalid or expired email verification token'));
await verifyEmailController(
{ body: { email: 'user%40example.com', token: 'not-the-token' } },
mockRes,
);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Invalid or expired email verification token',
});
});
it('uses the service status for resend verification responses', async () => {
resendVerificationEmail.mockResolvedValue({ status: 500, message: 'Something went wrong.' });
await resendVerificationController({ body: { email: 'user@example.com' } }, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
});
});
describe('getUserController', () => {
const mockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should only expose public user response fields from the request user', async () => {
const createdAt = new Date('2026-01-01T00:00:00.000Z');
const updatedAt = new Date('2026-01-02T00:00:00.000Z');
const req = {
config: {},
user: {
id: 'user-id',
_id: 'user-id',
name: 'OpenID User',
username: 'openid-user',
email: 'openid@test.com',
emailVerified: true,
avatar: '/avatars/user-id.png',
provider: 'openid',
role: 'USER',
plugins: ['web_search'],
twoFactorEnabled: true,
termsAccepted: true,
personalization: { memories: false },
favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
skillStates: { skill_one: true },
createdAt,
updatedAt,
tenantId: 'tenant-id',
password: 'hashed-password',
__v: 1,
totpSecret: 'totp-secret',
backupCodes: [{ codeHash: 'backup-code' }],
pendingTotpSecret: 'pending-totp-secret',
pendingBackupCodes: [{ codeHash: 'pending-backup-code' }],
refreshToken: [{ refreshToken: 'legacy-refresh-token' }],
googleId: 'google-id',
openidId: 'openid-id',
openidIssuer: 'openid-issuer',
idOnTheSource: 'external-source-id',
federatedTokens: {
access_token: 'access-token',
id_token: 'id-token',
refresh_token: 'refresh-token',
},
openidTokens: {
access_token: 'openid-access-token',
refresh_token: 'openid-refresh-token',
},
tokenset: {
access_token: 'tokenset-access-token',
refresh_token: 'tokenset-refresh-token',
},
safeLookingRuntimeField: 'internal-value',
},
};
await getUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const sentUser = mockRes.send.mock.calls[0][0];
expect(sentUser).toMatchObject({
id: 'user-id',
_id: 'user-id',
name: 'OpenID User',
username: 'openid-user',
email: 'openid@test.com',
emailVerified: true,
avatar: '/avatars/user-id.png',
provider: 'openid',
role: 'USER',
plugins: ['web_search'],
twoFactorEnabled: true,
termsAccepted: true,
personalization: { memories: false },
favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
skillStates: { skill_one: true },
createdAt,
updatedAt,
tenantId: 'tenant-id',
});
expect(sentUser).not.toHaveProperty('password');
expect(sentUser).not.toHaveProperty('__v');
expect(sentUser).not.toHaveProperty('totpSecret');
expect(sentUser).not.toHaveProperty('backupCodes');
expect(sentUser).not.toHaveProperty('pendingTotpSecret');
expect(sentUser).not.toHaveProperty('pendingBackupCodes');
expect(sentUser).not.toHaveProperty('refreshToken');
expect(sentUser).not.toHaveProperty('googleId');
expect(sentUser).not.toHaveProperty('openidId');
expect(sentUser).not.toHaveProperty('openidIssuer');
expect(sentUser).not.toHaveProperty('idOnTheSource');
expect(sentUser).not.toHaveProperty('federatedTokens');
expect(sentUser).not.toHaveProperty('openidTokens');
expect(sentUser).not.toHaveProperty('tokenset');
expect(sentUser).not.toHaveProperty('safeLookingRuntimeField');
});
});
describe('acceptTermsController', () => {
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('returns 404 when the user does not exist', async () => {
acceptTerms.mockResolvedValueOnce(null);
await acceptTermsController({ user: { id: 'missing-user' } }, mockRes);
expect(acceptTerms).toHaveBeenCalledWith('missing-user');
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not found' });
});
it('returns the recorded acceptance timestamp on success', async () => {
const acceptedAt = new Date('2026-06-14T10:00:00.000Z');
acceptTerms.mockResolvedValueOnce({ termsAccepted: true, termsAcceptedAt: acceptedAt });
await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
expect(acceptTerms).toHaveBeenCalledWith('user-id');
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Terms accepted successfully',
termsAcceptedAt: acceptedAt,
});
});
it('returns 500 when the update throws', async () => {
acceptTerms.mockRejectedValueOnce(new Error('db down'));
await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Error accepting terms' });
});
});
describe('deleteUserController', () => {
const mockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should return 200 on successful deletion', async () => {
const userId = new mongoose.Types.ObjectId();
const req = { user: { id: userId.toString(), _id: userId, email: 'test@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
});
it('should remove the user from all groups via $pullAll', async () => {
const userId = new mongoose.Types.ObjectId();
const userIdStr = userId.toString();
const otherUser = new mongoose.Types.ObjectId().toString();
await Group.create([
{ name: 'Group A', memberIds: [userIdStr, otherUser], source: 'local' },
{ name: 'Group B', memberIds: [userIdStr], source: 'local' },
{ name: 'Group C', memberIds: [otherUser], source: 'local' },
]);
const req = { user: { id: userIdStr, _id: userId, email: 'del@test.com' } };
await deleteUserController(req, mockRes);
const groups = await Group.find({}).sort({ name: 1 }).lean();
expect(groups[0].memberIds).toEqual([otherUser]);
expect(groups[1].memberIds).toEqual([]);
expect(groups[2].memberIds).toEqual([otherUser]);
});
it('should handle user that exists in no groups', async () => {
const userId = new mongoose.Types.ObjectId();
await Group.create({ name: 'Empty', memberIds: ['someone-else'], source: 'local' });
const req = { user: { id: userId.toString(), _id: userId, email: 'no-groups@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const group = await Group.findOne({ name: 'Empty' }).lean();
expect(group.memberIds).toEqual(['someone-else']);
});
it('should remove duplicate memberIds if the user appears more than once', async () => {
const userId = new mongoose.Types.ObjectId();
const userIdStr = userId.toString();
await Group.create({
name: 'Dupes',
memberIds: [userIdStr, 'other', userIdStr],
source: 'local',
});
const req = { user: { id: userIdStr, _id: userId, email: 'dupe@test.com' } };
await deleteUserController(req, mockRes);
const group = await Group.findOne({ name: 'Dupes' }).lean();
expect(group.memberIds).toEqual(['other']);
});
it('should still succeed when deleteConvos throws', async () => {
const userId = new mongoose.Types.ObjectId();
deleteConvos.mockRejectedValueOnce(new Error('no convos'));
const req = { user: { id: userId.toString(), _id: userId, email: 'convos@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
});
it('should return 500 when a critical operation fails', async () => {
const userId = new mongoose.Types.ObjectId();
const { deleteMessages } = require('~/models');
deleteMessages.mockRejectedValueOnce(new Error('db down'));
const req = { user: { id: userId.toString(), _id: userId, email: 'fail@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
});
it('should use string user.id (not ObjectId user._id) for memberIds removal', async () => {
const userId = new mongoose.Types.ObjectId();
const userIdStr = userId.toString();
const otherUser = 'other-user-id';
await Group.create({
name: 'StringCheck',
memberIds: [userIdStr, otherUser],
source: 'local',
});
const req = { user: { id: userIdStr, _id: userId, email: 'stringcheck@test.com' } };
await deleteUserController(req, mockRes);
const group = await Group.findOne({ name: 'StringCheck' }).lean();
expect(group.memberIds).toEqual([otherUser]);
expect(group.memberIds).not.toContain(userIdStr);
});
});
@@ -0,0 +1,426 @@
const mongoose = require('mongoose');
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
const mockGetTenantId = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
getTenantId: mockGetTenantId,
SYSTEM_TENANT_ID: '__SYSTEM__',
}));
const { AccessRoleIds, ResourceType, PrincipalType } =
jest.requireActual('librechat-data-provider');
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
}));
jest.mock('@librechat/api', () => ({
enrichRemoteAgentPrincipals: jest.fn(),
backfillRemoteAgentPermissions: jest.fn(),
}));
const mockBulkUpdateResourcePermissions = jest.fn();
jest.mock('~/server/services/PermissionService', () => ({
bulkUpdateResourcePermissions: (...args) => mockBulkUpdateResourcePermissions(...args),
ensureGroupPrincipalExists: jest.fn(),
getEffectivePermissions: jest.fn(),
ensurePrincipalExists: jest.fn(),
getAvailableRoles: jest.fn(),
findAccessibleResources: jest.fn(),
getResourcePermissionsMap: jest.fn(),
}));
const mockRemoveAgentFromUserFavorites = jest.fn();
jest.mock('~/models', () => ({
aggregateAclEntries: jest.fn(),
searchPrincipals: jest.fn(),
sortPrincipalsByRelevance: jest.fn(),
calculateRelevanceScore: jest.fn(),
removeAgentFromUserFavorites: (...args) => mockRemoveAgentFromUserFavorites(...args),
}));
jest.mock('~/server/services/GraphApiService', () => ({
entraIdPrincipalFeatureEnabled: jest.fn(() => false),
searchEntraIdPrincipals: jest.fn(),
}));
const db = require('~/models');
const {
updateResourcePermissions,
searchPrincipals,
getResourcePermissions,
} = require('../PermissionsController');
const createMockReq = (overrides = {}) => ({
params: { resourceType: ResourceType.AGENT, resourceId: '507f1f77bcf86cd799439011' },
body: { updated: [], removed: [], public: false },
user: { id: 'user-1', role: 'USER' },
headers: { authorization: '' },
...overrides,
});
const createMockRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
describe('PermissionsController', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetTenantId.mockReturnValue(undefined);
});
describe('searchPrincipals', () => {
beforeEach(() => {
db.searchPrincipals.mockResolvedValue([]);
db.calculateRelevanceScore.mockReturnValue(50);
db.sortPrincipalsByRelevance.mockImplementation((results) => results);
});
it('rejects non-string query parameters', async () => {
const req = createMockReq({
query: { q: ['alice'] },
});
const res = createMockRes();
await searchPrincipals(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Query parameter "q" is required and must not be empty',
});
expect(db.searchPrincipals).not.toHaveBeenCalled();
});
it('searches with the trimmed literal query', async () => {
db.searchPrincipals.mockResolvedValue([
{
id: 'user-1',
type: PrincipalType.USER,
name: 'Regex [invalid User',
source: 'local',
},
]);
const req = createMockReq({
query: { q: ' [invalid ', limit: '5', types: PrincipalType.USER },
});
const res = createMockRes();
await searchPrincipals(req, res);
expect(db.searchPrincipals).toHaveBeenCalledWith('[invalid', 5, [PrincipalType.USER]);
expect(db.calculateRelevanceScore).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Regex [invalid User' }),
'[invalid',
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
query: '[invalid',
limit: 5,
count: 1,
}),
);
});
it('does not expose internal error details on search failures', async () => {
db.searchPrincipals.mockRejectedValue(new Error('database failure with internal detail'));
const req = createMockReq({
query: { q: 'alice' },
});
const res = createMockRes();
await searchPrincipals(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Failed to search principals',
});
});
});
describe('getResourcePermissions — principal details', () => {
const currentTenantId = 'tenant-a';
const otherTenantId = 'tenant-b';
const userId = new mongoose.Types.ObjectId();
const groupId = new mongoose.Types.ObjectId();
it('omits joined user and group details outside the current request context', async () => {
mockGetTenantId.mockReturnValue(currentTenantId);
db.aggregateAclEntries.mockResolvedValue([
{
principalType: PrincipalType.USER,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
userInfo: {
_id: userId,
tenantId: otherTenantId,
name: 'Outside User',
email: 'outside-user@example.com',
avatar: 'outside-user.png',
},
},
{
principalType: PrincipalType.GROUP,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
groupInfo: {
_id: groupId,
tenantId: otherTenantId,
name: 'Outside Group',
email: 'outside-group@example.com',
avatar: 'outside-group.png',
},
},
{
principalType: PrincipalType.PUBLIC,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
},
]);
const req = createMockReq();
const res = createMockRes();
await getResourcePermissions(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
resourceType: ResourceType.AGENT,
resourceId: req.params.resourceId,
principals: [],
public: true,
publicAccessRoleId: AccessRoleIds.AGENT_VIEWER,
});
expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-user@example.com');
expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-group@example.com');
});
it('includes joined user and group details in the current request context', async () => {
mockGetTenantId.mockReturnValue(currentTenantId);
db.aggregateAclEntries.mockResolvedValue([
{
principalType: PrincipalType.USER,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
userInfo: {
_id: userId,
tenantId: currentTenantId,
name: 'Current User',
email: 'current-user@example.com',
avatar: 'current-user.png',
},
},
{
principalType: PrincipalType.GROUP,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
groupInfo: {
_id: groupId,
tenantId: currentTenantId,
name: 'Current Group',
email: 'current-group@example.com',
avatar: 'current-group.png',
},
},
]);
const req = createMockReq();
const res = createMockRes();
await getResourcePermissions(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json.mock.calls[0][0].principals).toEqual([
expect.objectContaining({
type: PrincipalType.USER,
id: userId.toString(),
email: 'current-user@example.com',
}),
expect.objectContaining({
type: PrincipalType.GROUP,
id: groupId.toString(),
email: 'current-group@example.com',
}),
]);
});
});
describe('updateResourcePermissions — favorites cleanup', () => {
const agentObjectId = new mongoose.Types.ObjectId().toString();
const revokedUserId = new mongoose.Types.ObjectId().toString();
beforeEach(() => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.USER, id: revokedUserId, name: 'Revoked User' }],
errors: [],
});
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
});
it('removes agent from revoked users favorites on AGENT resource type', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(res.status).toHaveBeenCalledWith(200);
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
});
it('removes agent from revoked users favorites on REMOTE_AGENT resource type', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.REMOTE_AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
});
it('uses results.revoked (validated) not raw request payload', async () => {
const validId = new mongoose.Types.ObjectId().toString();
const invalidId = 'not-a-valid-id';
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.USER, id: validId }],
errors: [{ principal: { type: PrincipalType.USER, id: invalidId }, error: 'Invalid ID' }],
});
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [
{ type: PrincipalType.USER, id: validId },
{ type: PrincipalType.USER, id: invalidId },
],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [validId]);
});
it('skips cleanup when no USER principals are revoked', async () => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.GROUP, id: 'group-1' }],
errors: [],
});
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.GROUP, id: 'group-1' }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
});
it('skips cleanup for non-agent resource types', async () => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.USER, id: revokedUserId }],
errors: [],
});
const req = createMockReq({
params: { resourceType: ResourceType.PROMPTGROUP, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(res.status).toHaveBeenCalledWith(200);
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
});
it('handles agent not found gracefully', async () => {
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it('logs error when removeAgentFromUserFavorites fails without blocking response', async () => {
mockRemoveAgentFromUserFavorites.mockRejectedValue(new Error('DB connection lost'));
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(res.status).toHaveBeenCalledWith(200);
expect(mockLogger.error).toHaveBeenCalledWith(
'[removeRevokedAgentFromFavorites] Error cleaning up favorites',
expect.any(Error),
);
});
});
});
@@ -0,0 +1,264 @@
const mockGetUserById = jest.fn();
const mockUpdateUser = jest.fn();
const mockVerifyOTPOrBackupCode = jest.fn();
const mockGenerateTOTPSecret = jest.fn();
const mockGenerateBackupCodes = jest.fn();
const mockEncryptV3 = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
encryptV3: (...args) => mockEncryptV3(...args),
logger: { error: jest.fn() },
}));
jest.mock('~/server/services/twoFactorService', () => ({
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
generateBackupCodes: (...args) => mockGenerateBackupCodes(...args),
generateTOTPSecret: (...args) => mockGenerateTOTPSecret(...args),
verifyBackupCode: jest.fn(),
getTOTPSecret: jest.fn(),
verifyTOTP: jest.fn(),
}));
jest.mock('~/models', () => ({
getUserById: (...args) => mockGetUserById(...args),
updateUser: (...args) => mockUpdateUser(...args),
}));
const { enable2FA, regenerateBackupCodes } = require('~/server/controllers/TwoFactorController');
function createRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
const PLAIN_CODES = ['code1', 'code2', 'code3'];
const CODE_OBJECTS = [
{ codeHash: 'h1', used: false, usedAt: null },
{ codeHash: 'h2', used: false, usedAt: null },
{ codeHash: 'h3', used: false, usedAt: null },
];
beforeEach(() => {
jest.clearAllMocks();
mockGenerateTOTPSecret.mockReturnValue('NEWSECRET');
mockGenerateBackupCodes.mockResolvedValue({ plainCodes: PLAIN_CODES, codeObjects: CODE_OBJECTS });
mockEncryptV3.mockReturnValue('encrypted-secret');
});
describe('enable2FA', () => {
it('allows first-time setup without token — writes to pending fields', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false, email: 'a@b.com' });
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
await enable2FA(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ otpauthUrl: expect.any(String), backupCodes: PLAIN_CODES }),
);
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
const updateCall = mockUpdateUser.mock.calls[0][1];
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
expect(updateCall).not.toHaveProperty('totpSecret');
expect(updateCall).not.toHaveProperty('backupCodes');
});
it('re-enrollment writes to pending fields, leaving live 2FA intact', async () => {
const req = { user: { id: 'user1' }, body: { token: '123456' } };
const res = createRes();
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
email: 'a@b.com',
};
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
await enable2FA(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: '123456',
backupCode: undefined,
persistBackupUse: false,
});
expect(res.status).toHaveBeenCalledWith(200);
const updateCall = mockUpdateUser.mock.calls[0][1];
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
expect(updateCall).not.toHaveProperty('totpSecret');
});
it('allows re-enrollment with valid backup code (persistBackupUse: false)', async () => {
const req = { user: { id: 'user1' }, body: { backupCode: 'backup123' } };
const res = createRes();
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
email: 'a@b.com',
};
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
await enable2FA(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith(
expect.objectContaining({ persistBackupUse: false }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('returns error when no token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
await enable2FA(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(mockUpdateUser).not.toHaveBeenCalled();
});
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await enable2FA(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
expect(mockUpdateUser).not.toHaveBeenCalled();
});
});
describe('regenerateBackupCodes', () => {
it('returns 404 when user not found', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue(null);
await regenerateBackupCodes(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
});
it('requires OTP when 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: { token: '123456' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({});
await regenerateBackupCodes(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
backupCodes: PLAIN_CODES,
backupCodesHash: CODE_OBJECTS,
});
});
it('returns error when no token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
await regenerateBackupCodes(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await regenerateBackupCodes(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
});
it('includes backupCodesHash in response', async () => {
const req = { user: { id: 'user1' }, body: { token: '123456' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({});
await regenerateBackupCodes(req, res);
const responseBody = res.json.mock.calls[0][0];
expect(responseBody).toHaveProperty('backupCodesHash', CODE_OBJECTS);
expect(responseBody).toHaveProperty('backupCodes', PLAIN_CODES);
});
it('allows regeneration without token when 2FA is not enabled', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: false,
});
mockUpdateUser.mockResolvedValue({});
await regenerateBackupCodes(req, res);
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
backupCodes: PLAIN_CODES,
backupCodesHash: CODE_OBJECTS,
});
});
});
@@ -0,0 +1,467 @@
const mockUpdateUserPlugins = jest.fn();
const mockFindToken = jest.fn();
const mockDeleteUserPluginAuth = jest.fn();
const mockGetAppConfig = jest.fn();
const mockInvalidateCachedTools = jest.fn();
const mockGetLogStores = jest.fn();
const mockGetMCPManager = jest.fn();
const mockGetFlowStateManager = jest.fn();
const mockGetMCPServersRegistry = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
getTenantId: jest.fn(),
webSearchKeys: [],
}));
jest.mock('librechat-data-provider', () => ({
Tools: {},
CacheKeys: { FLOWS: 'flows' },
Constants: { mcp_delimiter: '_mcp_', mcp_prefix: 'mcp_' },
FileSources: {},
}));
jest.mock('@librechat/api', () => ({
MCPOAuthHandler: {
generateFlowId: jest.fn((userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
}),
generateTokenFlowId: jest.fn((userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
}),
revokeOAuthToken: jest.fn(),
},
MCPTokenStorage: {
getClientInfoAndMetadata: jest.fn(),
getTokens: jest.fn(),
deleteUserTokens: jest.fn().mockResolvedValue(undefined),
},
normalizeHttpError: jest.fn((error) => error),
extractWebSearchEnvVars: jest.fn((params) => params.keys),
getAppConfigOptionsFromUser: jest.fn((user) => {
const hasSourceIdentity =
user != null && Object.prototype.hasOwnProperty.call(user, 'idOnTheSource');
return {
role: user?.role,
userId: user?.id,
idOnTheSource: user?.id && hasSourceIdentity ? (user.idOnTheSource ?? null) : undefined,
tenantId: user?.tenantId,
};
}),
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
}));
jest.mock('~/models', () => ({
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
findToken: mockFindToken,
deleteTokens: jest.fn(),
}));
jest.mock('~/server/services/PluginService', () => ({
updateUserPluginAuth: jest.fn(),
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
}));
jest.mock('~/server/services/twoFactorService', () => ({
verifyOTPOrBackupCode: jest.fn(),
}));
jest.mock('~/server/services/AuthService', () => ({
verifyEmail: jest.fn(),
resendVerificationEmail: jest.fn(),
}));
jest.mock('~/config', () => ({
getMCPManager: (...args) => mockGetMCPManager(...args),
getFlowStateManager: (...args) => mockGetFlowStateManager(...args),
getMCPServersRegistry: (...args) => mockGetMCPServersRegistry(...args),
}));
jest.mock('~/server/services/Config/getCachedTools', () => ({
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
}));
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: (...args) => mockGetAppConfig(...args),
}));
jest.mock('~/cache', () => ({
getLogStores: (...args) => mockGetLogStores(...args),
}));
const { logger, getTenantId } = require('@librechat/data-schemas');
const { MCPTokenStorage, MCPOAuthHandler } = require('@librechat/api');
const { updateUserPluginsController } = require('~/server/controllers/UserController');
function createResponse() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
return res;
}
function createRequest() {
return {
user: {
id: 'user-1',
_id: 'user-1',
plugins: [],
role: 'USER',
},
body: {
pluginKey: 'mcp_test-server',
action: 'uninstall',
auth: {},
},
};
}
function setupMCPMocks() {
const flowManager = {
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mcpManager = {
disconnectUserConnection: jest.fn().mockResolvedValue(),
};
const registry = {
getServerConfig: jest.fn().mockResolvedValue({
url: 'https://example.com/mcp',
oauth: {},
oauth_headers: {},
}),
getOAuthServers: jest.fn().mockResolvedValue(new Set(['test-server'])),
getAllowedDomains: jest.fn().mockReturnValue([]),
getAllowedAddresses: jest.fn().mockReturnValue(null),
};
// Revocation reads the merged config's mcpSettings allowlists (not the registry getters).
mockGetAppConfig.mockResolvedValue({
mcpSettings: { allowedDomains: [], allowedAddresses: null },
});
mockUpdateUserPlugins.mockResolvedValue();
mockDeleteUserPluginAuth.mockResolvedValue();
mockInvalidateCachedTools.mockResolvedValue();
mockGetLogStores.mockReturnValue({});
mockGetFlowStateManager.mockReturnValue(flowManager);
mockGetMCPManager.mockReturnValue(mcpManager);
mockGetMCPServersRegistry.mockReturnValue(registry);
return { flowManager, mcpManager, registry };
}
beforeEach(() => {
jest.clearAllMocks();
getTenantId.mockReturnValue(undefined);
});
describe('updateUserPluginsController MCP OAuth cleanup', () => {
it('clears stored OAuth token state when client metadata is missing', async () => {
const { flowManager, mcpManager } = setupMCPMocks();
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(MCPTokenStorage.getClientInfoAndMetadata).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
findToken: mockFindToken,
});
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
expect(mcpManager.disconnectUserConnection).toHaveBeenCalledWith('user-1', 'test-server');
});
it('still clears OAuth flow state when stored token deletion fails', async () => {
const { flowManager } = setupMCPMocks();
const cleanupError = new Error('DB down');
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
MCPTokenStorage.deleteUserTokens.mockRejectedValueOnce(cleanupError);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
expect(logger.warn).toHaveBeenCalledWith(
'[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for test-server:',
cleanupError,
);
});
it('logs all flow cleanup failures without failing MCP OAuth cleanup', async () => {
const { flowManager } = setupMCPMocks();
const getTokensFlowError = new Error('get tokens flow cache down');
const oauthFlowError = new Error('oauth flow cache down');
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
flowManager.deleteFlow
.mockRejectedValueOnce(getTokensFlowError)
.mockRejectedValueOnce(oauthFlowError);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
expect(logger.warn).toHaveBeenCalledWith(
'[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
getTokensFlowError,
);
expect(logger.warn).toHaveBeenCalledWith(
'[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
oauthFlowError,
);
});
it('clears stored OAuth token state when client metadata cannot be loaded', async () => {
const { flowManager } = setupMCPMocks();
MCPTokenStorage.getClientInfoAndMetadata.mockRejectedValue(new Error('invalid client info'));
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(logger.warn).toHaveBeenCalledWith(
'[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for test-server; clearing local MCP OAuth state only.',
expect.any(Error),
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
expect(MCPTokenStorage.getTokens).not.toHaveBeenCalled();
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
});
it('clears tenant-scoped and legacy OAuth flow state when tenant context exists', async () => {
const { flowManager } = setupMCPMocks();
getTenantId.mockReturnValue('tenant-a');
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(flowManager.deleteFlow).toHaveBeenCalledWith(
'tenant:tenant-a:user-1:test-server',
'mcp_get_tokens',
);
expect(flowManager.deleteFlow).toHaveBeenCalledWith(
'tenant:tenant-a:user-1:test-server',
'mcp_oauth',
);
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
});
it('clears stored OAuth token state when server config is missing', async () => {
const { flowManager, registry } = setupMCPMocks();
registry.getServerConfig.mockResolvedValue(undefined);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
});
it('clears stored OAuth token state when server no longer requires OAuth', async () => {
const { flowManager, registry } = setupMCPMocks();
registry.getOAuthServers.mockResolvedValue(new Set());
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
});
it('clears stored OAuth token state when token loading fails before provider revocation', async () => {
const { flowManager } = setupMCPMocks();
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
clientInfo: { client_id: 'client-1' },
clientMetadata: {},
});
MCPTokenStorage.getTokens.mockRejectedValue(new Error('token lookup failed'));
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
findToken: mockFindToken,
});
expect(logger.warn).toHaveBeenCalledWith(
'[maybeUninstallOAuthMCP] Unable to load OAuth tokens for test-server; clearing local token state.',
expect.any(Error),
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
});
it('revokes provider tokens before clearing local token state when token data is available', async () => {
setupMCPMocks();
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
clientMetadata: { revocation_endpoint: 'https://example.com/revoke' },
});
MCPTokenStorage.getTokens.mockResolvedValue({
access_token: 'access-token',
refresh_token: 'refresh-token',
});
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
findToken: mockFindToken,
});
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
'test-server',
'access-token',
'access',
{
serverUrl: 'https://example.com/mcp',
clientId: 'client-1',
clientSecret: 'secret-1',
revocationEndpoint: 'https://example.com/revoke',
revocationEndpointAuthMethodsSupported: undefined,
},
{},
[],
null,
);
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
'test-server',
'refresh-token',
'refresh',
{
serverUrl: 'https://example.com/mcp',
clientId: 'client-1',
clientSecret: 'secret-1',
revocationEndpoint: 'https://example.com/revoke',
revocationEndpointAuthMethodsSupported: undefined,
},
{},
[],
null,
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
});
it('revokes only the access token when refresh token data is absent', async () => {
setupMCPMocks();
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
clientMetadata: {},
});
MCPTokenStorage.getTokens.mockResolvedValue({
access_token: 'access-token',
});
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
'test-server',
'access-token',
'access',
expect.objectContaining({ clientId: 'client-1' }),
{},
[],
null,
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
});
it('revokes only the refresh token when access token data is absent', async () => {
setupMCPMocks();
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
clientMetadata: {},
});
MCPTokenStorage.getTokens.mockResolvedValue({
refresh_token: 'refresh-token',
});
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
'test-server',
'refresh-token',
'refresh',
expect.objectContaining({ clientId: 'client-1' }),
{},
[],
null,
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
deleteToken: expect.any(Function),
});
});
});
@@ -0,0 +1,296 @@
const mockGetUserById = jest.fn();
const mockDeleteMessages = jest.fn();
const mockDeleteAllUserSessions = jest.fn();
const mockDeleteUserById = jest.fn();
const mockDeleteAllSharedLinks = jest.fn();
const mockDeleteAllSharedLinksWithCleanup = jest.fn();
const mockDeletePresets = jest.fn();
const mockDeleteUserKey = jest.fn();
const mockDeleteConvos = jest.fn();
const mockDeleteFiles = jest.fn();
const mockGetFiles = jest.fn();
const mockUpdateUserPlugins = jest.fn();
const mockUpdateUser = jest.fn();
const mockFindToken = jest.fn();
const mockVerifyOTPOrBackupCode = jest.fn();
const mockDeleteUserPluginAuth = jest.fn();
const mockProcessDeleteRequest = jest.fn();
const mockDeleteToolCalls = jest.fn();
const mockDeleteUserAgents = jest.fn();
const mockDeleteUserPrompts = jest.fn();
const mockDeleteUserSkills = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), info: jest.fn() },
webSearchKeys: [],
}));
jest.mock('librechat-data-provider', () => ({
Tools: {},
CacheKeys: {},
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
FileSources: {},
}));
jest.mock('@librechat/api', () => ({
MCPOAuthHandler: {},
MCPTokenStorage: {},
normalizeHttpError: jest.fn(),
extractWebSearchEnvVars: jest.fn(),
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
deleteAllSharedLinksWithCleanup: (...args) => mockDeleteAllSharedLinksWithCleanup(...args),
}));
jest.mock('~/models', () => ({
deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args),
deleteAllSharedLinks: (...args) => mockDeleteAllSharedLinks(...args),
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
deleteUserById: (...args) => mockDeleteUserById(...args),
deleteMessages: (...args) => mockDeleteMessages(...args),
deletePresets: (...args) => mockDeletePresets(...args),
deleteUserKey: (...args) => mockDeleteUserKey(...args),
getUserById: (...args) => mockGetUserById(...args),
deleteConvos: (...args) => mockDeleteConvos(...args),
deleteFiles: (...args) => mockDeleteFiles(...args),
updateUser: (...args) => mockUpdateUser(...args),
findToken: (...args) => mockFindToken(...args),
getFiles: (...args) => mockGetFiles(...args),
deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
deleteUserSkills: (...args) => mockDeleteUserSkills(...args),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
deleteAllAgentApiKeys: jest.fn(),
deleteAssistants: jest.fn(),
deleteConversationTags: jest.fn(),
deleteAllUserMemories: jest.fn(),
deleteActions: jest.fn(),
deleteTokens: jest.fn(),
removeUserFromAllGroups: jest.fn(),
deleteAclEntries: jest.fn(),
getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/server/services/PluginService', () => ({
updateUserPluginAuth: jest.fn(),
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
}));
jest.mock('~/server/services/twoFactorService', () => ({
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
}));
jest.mock('~/server/services/AuthService', () => ({
verifyEmail: jest.fn(),
resendVerificationEmail: jest.fn(),
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
}));
jest.mock('~/server/services/Config/getCachedTools', () => ({
invalidateCachedTools: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: (...args) => mockProcessDeleteRequest(...args),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
const { deleteUserController } = require('~/server/controllers/UserController');
function createRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
return res;
}
function stubDeletionMocks() {
mockDeleteMessages.mockResolvedValue();
mockDeleteAllUserSessions.mockResolvedValue();
mockDeleteUserKey.mockResolvedValue();
mockDeletePresets.mockResolvedValue();
mockDeleteConvos.mockResolvedValue();
mockDeleteUserPluginAuth.mockResolvedValue();
mockDeleteUserById.mockResolvedValue();
mockDeleteAllSharedLinks.mockResolvedValue();
mockDeleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 });
mockGetFiles.mockResolvedValue([]);
mockProcessDeleteRequest.mockResolvedValue({ deletedFileIds: [], failedFileIds: [] });
mockDeleteFiles.mockResolvedValue();
mockDeleteToolCalls.mockResolvedValue();
mockDeleteUserAgents.mockResolvedValue();
mockDeleteUserPrompts.mockResolvedValue();
mockDeleteUserSkills.mockResolvedValue(0);
}
beforeEach(() => {
jest.clearAllMocks();
stubDeletionMocks();
});
describe('deleteUserController - 2FA enforcement', () => {
it('proceeds with deletion when 2FA is not enabled', async () => {
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false });
await deleteUserController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
expect(mockDeleteMessages).toHaveBeenCalled();
expect(mockDeleteUserAgents).toHaveBeenCalledWith('user1');
expect(mockDeleteUserPrompts).toHaveBeenCalledWith('user1');
expect(mockDeleteUserSkills).toHaveBeenCalledWith('user1');
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
});
it('proceeds with deletion when user has no 2FA record', async () => {
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue(null);
await deleteUserController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
});
it('returns error when 2FA is enabled and verification fails with 400', async () => {
const req = { user: { id: 'user1', _id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
await deleteUserController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(mockDeleteMessages).not.toHaveBeenCalled();
});
it('returns 401 when 2FA is enabled and invalid TOTP token provided', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
};
const req = { user: { id: 'user1', _id: 'user1' }, body: { token: 'wrong' } };
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: 'wrong',
backupCode: undefined,
});
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
expect(mockDeleteMessages).not.toHaveBeenCalled();
});
it('returns 401 when 2FA is enabled and invalid backup code provided', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
backupCodes: [],
};
const req = { user: { id: 'user1', _id: 'user1' }, body: { backupCode: 'bad-code' } };
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: undefined,
backupCode: 'bad-code',
});
expect(res.status).toHaveBeenCalledWith(401);
expect(mockDeleteMessages).not.toHaveBeenCalled();
});
it('deletes account when valid TOTP token provided with 2FA enabled', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
};
const req = {
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
body: { token: '123456' },
};
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: '123456',
backupCode: undefined,
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
expect(mockDeleteMessages).toHaveBeenCalled();
});
it('deletes account when valid backup code provided with 2FA enabled', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
backupCodes: [{ codeHash: 'h1', used: false }],
};
const req = {
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
body: { backupCode: 'valid-code' },
};
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: undefined,
backupCode: 'valid-code',
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
expect(mockDeleteMessages).toHaveBeenCalled();
});
});
@@ -0,0 +1,319 @@
const mockGetMCPManager = jest.fn();
const mockInvalidateCachedTools = jest.fn();
jest.mock('~/config', () => ({
getMCPManager: (...args) => mockGetMCPManager(...args),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
}));
jest.mock('~/server/services/Config/getCachedTools', () => ({
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
getMCPServerTools: jest.fn(),
}));
const mongoose = require('mongoose');
const { mcpServerSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
ResourceType,
AccessRoleIds,
PrincipalType,
PermissionBits,
} = require('librechat-data-provider');
const permissionService = require('~/server/services/PermissionService');
const { deleteUserMcpServers } = require('~/server/controllers/UserController');
const { AclEntry, AccessRole } = require('~/db/models');
let MCPServer;
describe('deleteUserMcpServers', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
MCPServer = mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema);
await mongoose.connect(mongoUri);
await AccessRole.create({
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
name: 'MCP Server Owner',
resourceType: ResourceType.MCPSERVER,
permBits:
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
});
await AccessRole.create({
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
name: 'MCP Server Viewer',
resourceType: ResourceType.MCPSERVER,
permBits: PermissionBits.VIEW,
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await MCPServer.deleteMany({});
await AclEntry.deleteMany({});
jest.clearAllMocks();
});
test('should delete solely-owned MCP servers and their ACL entries', async () => {
const userId = new mongoose.Types.ObjectId();
const server = await MCPServer.create({
serverName: 'sole-owned-server',
config: { title: 'Test Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(server._id)).toBeNull();
const aclEntries = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
});
expect(aclEntries).toHaveLength(0);
});
test('should disconnect MCP sessions and invalidate tool cache before deletion', async () => {
const userId = new mongoose.Types.ObjectId();
const mockDisconnect = jest.fn().mockResolvedValue(undefined);
const server = await MCPServer.create({
serverName: 'session-server',
config: { title: 'Session Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
mockGetMCPManager.mockReturnValue({ disconnectUserConnection: mockDisconnect });
await deleteUserMcpServers(userId.toString());
expect(mockDisconnect).toHaveBeenCalledWith(userId.toString(), 'session-server');
expect(mockInvalidateCachedTools).toHaveBeenCalledWith({
userId: userId.toString(),
serverName: 'session-server',
});
});
test('should preserve multi-owned MCP servers', async () => {
const deletingUserId = new mongoose.Types.ObjectId();
const otherOwnerId = new mongoose.Types.ObjectId();
const soleServer = await MCPServer.create({
serverName: 'sole-server',
config: { title: 'Sole Server' },
author: deletingUserId,
});
const multiServer = await MCPServer.create({
serverName: 'multi-server',
config: { title: 'Multi Server' },
author: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: deletingUserId,
resourceType: ResourceType.MCPSERVER,
resourceId: soleServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: deletingUserId,
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: otherOwnerId,
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: otherOwnerId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(deletingUserId.toString());
expect(await MCPServer.findById(soleServer._id)).toBeNull();
expect(await MCPServer.findById(multiServer._id)).not.toBeNull();
const soleAcl = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: soleServer._id,
});
expect(soleAcl).toHaveLength(0);
const multiAclOther = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
principalId: otherOwnerId,
});
expect(multiAclOther).toHaveLength(1);
expect(multiAclOther[0].permBits & PermissionBits.DELETE).toBeTruthy();
const multiAclDeleting = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
principalId: deletingUserId,
});
expect(multiAclDeleting).toHaveLength(1);
});
test('should be a no-op when user has no owned MCP servers', async () => {
const userId = new mongoose.Types.ObjectId();
const otherUserId = new mongoose.Types.ObjectId();
const server = await MCPServer.create({
serverName: 'other-server',
config: { title: 'Other Server' },
author: otherUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: otherUserId,
});
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(server._id)).not.toBeNull();
expect(mockGetMCPManager).not.toHaveBeenCalled();
});
test('should handle gracefully when MCPServer model is not registered', async () => {
const originalModel = mongoose.models.MCPServer;
delete mongoose.models.MCPServer;
try {
const userId = new mongoose.Types.ObjectId();
await expect(deleteUserMcpServers(userId.toString())).resolves.toBeUndefined();
} finally {
mongoose.models.MCPServer = originalModel;
}
});
test('should handle gracefully when MCPManager is not available', async () => {
const userId = new mongoose.Types.ObjectId();
const server = await MCPServer.create({
serverName: 'no-manager-server',
config: { title: 'No Manager Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
mockGetMCPManager.mockReturnValue(null);
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(server._id)).toBeNull();
});
test('should delete legacy MCP servers that have author but no ACL entries', async () => {
const legacyUserId = new mongoose.Types.ObjectId();
const legacyServer = await MCPServer.create({
serverName: 'legacy-server',
config: { title: 'Legacy Server' },
author: legacyUserId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(legacyUserId.toString());
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
});
test('should delete both ACL-owned and legacy servers in one call', async () => {
const userId = new mongoose.Types.ObjectId();
const aclServer = await MCPServer.create({
serverName: 'acl-server',
config: { title: 'ACL Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: aclServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
const legacyServer = await MCPServer.create({
serverName: 'legacy-mixed-server',
config: { title: 'Legacy Mixed' },
author: userId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(aclServer._id)).toBeNull();
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
});
});
@@ -0,0 +1,55 @@
const fs = require('fs');
const path = require('path');
const { ResourceType } = require('librechat-data-provider');
/**
* Maps each ResourceType to the cleanup function name that must appear in
* deleteUserController's source to prove it is handled during user deletion.
*
* When a new ResourceType is added, this test will fail until a corresponding
* entry is added here (or to NO_USER_CLEANUP_NEEDED) AND the actual cleanup
* logic is implemented.
*/
const HANDLED_RESOURCE_TYPES = {
[ResourceType.AGENT]: 'deleteUserAgents',
[ResourceType.REMOTE_AGENT]: 'deleteUserAgents',
[ResourceType.PROMPTGROUP]: 'deleteUserPrompts',
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',
[ResourceType.SKILL]: 'deleteUserSkills',
[ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup',
};
/**
* ResourceTypes that are ACL-tracked but have no per-user deletion semantics
* (e.g., system resources, public-only). Must be explicitly listed here with
* a justification to prevent silent omissions.
*/
const NO_USER_CLEANUP_NEEDED = new Set([
// Example: ResourceType.SYSTEM_TEMPLATE — public/system; not user-owned
]);
describe('deleteUserController - resource type coverage guard', () => {
let controllerSource;
beforeAll(() => {
controllerSource = fs.readFileSync(path.resolve(__dirname, '../UserController.js'), 'utf-8');
});
test('every ResourceType must have a documented cleanup handler or explicit exclusion', () => {
const allTypes = Object.values(ResourceType);
const handledTypes = Object.keys(HANDLED_RESOURCE_TYPES);
const unhandledTypes = allTypes.filter(
(t) => !handledTypes.includes(t) && !NO_USER_CLEANUP_NEEDED.has(t),
);
expect(unhandledTypes).toEqual([]);
});
test('every cleanup handler referenced in HANDLED_RESOURCE_TYPES must appear in the controller source', () => {
const uniqueHandlers = [...new Set(Object.values(HANDLED_RESOURCE_TYPES))];
for (const handler of uniqueHandlers) {
expect(controllerSource).toContain(handler);
}
});
});
@@ -0,0 +1,324 @@
const mockGetTokens = jest.fn();
const mockDeleteUserTokens = jest.fn();
const mockGetClientInfoAndMetadata = jest.fn();
const mockRevokeOAuthToken = jest.fn();
const mockGetServerConfig = jest.fn();
const mockGetOAuthServers = jest.fn();
const mockGetAllowedDomains = jest.fn();
const mockGetAllowedAddresses = jest.fn();
const mockDeleteFlow = jest.fn();
const mockGetLogStores = jest.fn();
const mockFindToken = jest.fn();
const mockDeleteTokens = jest.fn();
const mockLoggerInfo = jest.fn();
const mockLoggerWarn = jest.fn();
const mockLoggerError = jest.fn();
const mockGetTenantId = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError },
getTenantId: (...args) => mockGetTenantId(...args),
webSearchKeys: [],
}));
jest.mock('@librechat/api', () => {
return {
MCPOAuthHandler: {
revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args),
generateFlowId: (userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
},
generateTokenFlowId: (userId, serverName, tenantId) => {
const flowId = `${userId}:${serverName}`;
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
},
},
MCPTokenStorage: {
getTokens: (...args) => mockGetTokens(...args),
getClientInfoAndMetadata: (...args) => mockGetClientInfoAndMetadata(...args),
deleteUserTokens: (...args) => mockDeleteUserTokens(...args),
},
normalizeHttpError: jest.fn(),
extractWebSearchEnvVars: jest.fn(),
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
};
});
jest.mock('librechat-data-provider', () => ({
Tools: {},
CacheKeys: { FLOWS: 'flows' },
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
FileSources: {},
ResourceType: {},
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(() => ({
deleteFlow: (...args) => mockDeleteFlow(...args),
})),
getMCPServersRegistry: jest.fn(() => ({
getServerConfig: (...args) => mockGetServerConfig(...args),
getOAuthServers: (...args) => mockGetOAuthServers(...args),
getAllowedDomains: (...args) => mockGetAllowedDomains(...args),
getAllowedAddresses: (...args) => mockGetAllowedAddresses(...args),
})),
}));
jest.mock('~/cache', () => ({
getLogStores: (...args) => mockGetLogStores(...args),
}));
jest.mock('~/server/services/PluginService', () => ({
updateUserPluginAuth: jest.fn(),
deleteUserPluginAuth: jest.fn(),
}));
jest.mock('~/server/services/twoFactorService', () => ({
verifyOTPOrBackupCode: jest.fn(),
}));
jest.mock('~/server/services/AuthService', () => ({
verifyEmail: jest.fn(),
resendVerificationEmail: jest.fn(),
}));
jest.mock('~/server/services/Config/getCachedTools', () => ({
invalidateCachedTools: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
jest.mock('~/models', () => ({
findToken: (...args) => mockFindToken(...args),
deleteTokens: (...args) => mockDeleteTokens(...args),
updateUser: jest.fn(),
deleteAllUserSessions: jest.fn(),
deleteAllSharedLinks: jest.fn(),
updateUserPlugins: jest.fn(),
deleteUserById: jest.fn(),
deleteMessages: jest.fn(),
deletePresets: jest.fn(),
deleteUserKey: jest.fn(),
getUserById: jest.fn(),
deleteConvos: jest.fn(),
deleteFiles: jest.fn(),
getFiles: jest.fn(),
deleteToolCalls: jest.fn(),
deleteUserAgents: jest.fn(),
deleteUserPrompts: jest.fn(),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
deleteAllAgentApiKeys: jest.fn(),
deleteAssistants: jest.fn(),
deleteConversationTags: jest.fn(),
deleteAllUserMemories: jest.fn(),
deleteActions: jest.fn(),
removeUserFromAllGroups: jest.fn(),
deleteAclEntries: jest.fn(),
getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
}));
const { maybeUninstallOAuthMCP } = require('~/server/controllers/UserController');
const userId = 'user-123';
const pluginKey = 'mcp_acme';
const serverName = 'acme';
const serverConfig = {
url: 'https://acme.example.com',
oauth: {
revocation_endpoint: 'https://acme.example.com/revoke',
revocation_endpoint_auth_methods_supported: ['client_secret_basic'],
},
oauth_headers: { 'X-Tenant': 'acme' },
};
const appConfig = {
mcpServers: { acme: serverConfig },
};
const clientInfo = { client_id: 'cid', client_secret: 'csec' };
const clientMetadata = {};
function setupOAuthServerFound() {
mockGetServerConfig.mockResolvedValue(serverConfig);
mockGetOAuthServers.mockResolvedValue(new Set([serverName]));
mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']);
mockGetAllowedAddresses.mockReturnValue(null);
mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata });
}
describe('maybeUninstallOAuthMCP', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetTenantId.mockReturnValue(undefined);
});
test('is a no-op when pluginKey is not an MCP key', async () => {
await maybeUninstallOAuthMCP(userId, 'plugin_google_calendar', appConfig);
expect(mockGetServerConfig).not.toHaveBeenCalled();
expect(mockGetTokens).not.toHaveBeenCalled();
expect(mockDeleteUserTokens).not.toHaveBeenCalled();
expect(mockDeleteFlow).not.toHaveBeenCalled();
});
test('clears stored state when the MCP server is not an OAuth server', async () => {
mockGetServerConfig.mockResolvedValue(serverConfig);
mockGetOAuthServers.mockResolvedValue(new Set(['other']));
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
expect(mockGetClientInfoAndMetadata).not.toHaveBeenCalled();
expect(mockGetTokens).not.toHaveBeenCalled();
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
});
test('clears stored state when client info is missing', async () => {
setupOAuthServerFound();
mockGetClientInfoAndMetadata.mockResolvedValue(null);
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
expect(mockGetTokens).not.toHaveBeenCalled();
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
});
test('clears stored state when client info cannot be loaded', async () => {
setupOAuthServerFound();
mockGetClientInfoAndMetadata.mockRejectedValue(new Error('bad client data'));
mockDeleteUserTokens.mockResolvedValue(undefined);
mockDeleteFlow.mockResolvedValue(undefined);
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
expect(mockGetTokens).not.toHaveBeenCalled();
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
expect(mockLoggerWarn).toHaveBeenCalledWith(
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
expect.any(Error),
);
});
test('clears tenant-scoped and legacy flow state when tenant context exists', async () => {
setupOAuthServerFound();
mockGetTenantId.mockReturnValue('tenant-a');
mockGetClientInfoAndMetadata.mockResolvedValue(null);
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
expect(mockDeleteFlow).toHaveBeenCalledTimes(4);
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_get_tokens');
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_oauth');
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_get_tokens');
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_oauth');
});
test('revokes both tokens and runs cleanup on happy path', async () => {
setupOAuthServerFound();
mockGetTokens.mockResolvedValue({
access_token: 'access-abc',
refresh_token: 'refresh-xyz',
});
mockRevokeOAuthToken.mockResolvedValue(undefined);
mockDeleteUserTokens.mockResolvedValue(undefined);
mockDeleteFlow.mockResolvedValue(undefined);
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
expect(mockRevokeOAuthToken.mock.calls[0][1]).toBe('access-abc');
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
expect(mockRevokeOAuthToken.mock.calls[1][1]).toBe('refresh-xyz');
expect(mockRevokeOAuthToken.mock.calls[1][2]).toBe('refresh');
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
expect(mockDeleteFlow.mock.calls[0][1]).toBe('mcp_get_tokens');
expect(mockDeleteFlow.mock.calls[1][1]).toBe('mcp_oauth');
});
test('skips revocation but still runs cleanup when token retrieval fails', async () => {
setupOAuthServerFound();
mockGetTokens.mockRejectedValue(new Error('missing'));
mockDeleteUserTokens.mockResolvedValue(undefined);
mockDeleteFlow.mockResolvedValue(undefined);
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
expect(mockLoggerWarn).toHaveBeenCalledWith(
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
expect.any(Error),
);
});
test('skips revocation, logs warn, and still runs cleanup on unexpected token-retrieval error', async () => {
setupOAuthServerFound();
mockGetTokens.mockRejectedValue(new Error('boom: unreachable'));
mockDeleteUserTokens.mockResolvedValue(undefined);
mockDeleteFlow.mockResolvedValue(undefined);
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
expect(mockLoggerWarn).toHaveBeenCalledWith(
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
expect.any(Error),
);
});
test('continues cleanup when only one token type is present', async () => {
setupOAuthServerFound();
mockGetTokens.mockResolvedValue({ access_token: 'only-access' });
mockRevokeOAuthToken.mockResolvedValue(undefined);
mockDeleteUserTokens.mockResolvedValue(undefined);
mockDeleteFlow.mockResolvedValue(undefined);
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(1);
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
});
test('still runs cleanup even when both revocation calls fail', async () => {
setupOAuthServerFound();
mockGetTokens.mockResolvedValue({
access_token: 'a',
refresh_token: 'r',
});
mockRevokeOAuthToken.mockRejectedValue(new Error('network down'));
mockDeleteUserTokens.mockResolvedValue(undefined);
mockDeleteFlow.mockResolvedValue(undefined);
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
expect(mockLoggerError).toHaveBeenCalled();
});
});
@@ -0,0 +1,258 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { SystemCapabilities } = require('@librechat/data-schemas');
const {
SystemRoles,
ResourceType,
AccessRoleIds,
PrincipalType,
} = require('librechat-data-provider');
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
getTransactionSupport: jest.fn().mockResolvedValue(false),
}));
jest.mock('~/server/services/GraphApiService', () => ({
entraIdPrincipalFeatureEnabled: jest.fn().mockReturnValue(false),
getUserOwnedEntraGroups: jest.fn().mockResolvedValue([]),
getUserEntraGroups: jest.fn().mockResolvedValue([]),
getEntraGroupDetailsBatch: jest.fn().mockResolvedValue([]),
getGroupMembers: jest.fn().mockResolvedValue([]),
getGroupOwners: jest.fn().mockResolvedValue([]),
}));
const mockRegistryInstance = {
getServerConfig: jest.fn(),
};
jest.mock('~/config', () => ({
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
getMCPManager: jest.fn(),
getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
}));
const mockResolveAllMcpConfigs = jest.fn();
jest.mock('~/server/services/MCP', () => ({
resolveConfigServers: jest.fn().mockResolvedValue({}),
resolveMcpConfigNames: jest.fn().mockResolvedValue([]),
resolveAllMcpConfigs: (...args) => mockResolveAllMcpConfigs(...args),
}));
jest.mock('~/server/services/Config', () => ({
cacheMCPServerTools: jest.fn(),
getMCPServerTools: jest.fn(),
}));
const { getMCPServersList, getMCPServerById } = require('~/server/controllers/mcp');
const { grantPermission } = require('~/server/services/PermissionService');
const { seedDefaultRoles } = require('~/models');
let mongoServer;
let SystemGrant;
let AclEntry;
let User;
const yamlConfig = {
type: 'streamable-http',
url: 'https://internal.example.com/mcp',
title: 'YAML Server',
source: 'yaml',
oauth: {
client_id: 'client-id',
authorization_url: 'https://internal.example.com/auth',
token_url: 'https://internal.example.com/token',
},
};
const createRes = () => {
const res = {};
res.status = jest.fn(() => res);
res.json = jest.fn(() => res);
return res;
};
const createDbConfig = (dbId) => ({
type: 'streamable-http',
url: 'https://user.example.com/mcp',
title: 'DB Server',
source: 'user',
dbId: String(dbId),
});
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const { createModels } = jest.requireActual('@librechat/data-schemas');
createModels(mongoose);
const dbModels = require('~/db/models');
Object.assign(mongoose.models, dbModels);
SystemGrant = dbModels.SystemGrant;
AclEntry = dbModels.AclEntry;
User = dbModels.User;
await seedDefaultRoles();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
let existsSpy;
beforeEach(async () => {
await SystemGrant.deleteMany({});
await AclEntry.deleteMany({});
await User.deleteMany({});
mockResolveAllMcpConfigs.mockReset();
mockRegistryInstance.getServerConfig.mockReset();
existsSpy = jest.spyOn(SystemGrant, 'exists');
});
afterEach(() => {
existsSpy.mockRestore();
});
const seedManageMcpGrant = async (role = SystemRoles.ADMIN) => {
await SystemGrant.create({
principalType: PrincipalType.ROLE,
principalId: role,
capability: SystemCapabilities.MANAGE_MCP_SERVERS,
grantedAt: new Date(),
});
};
const createUser = async (role = SystemRoles.USER) => {
const user = await User.create({
name: 'Test User',
email: `user-${new mongoose.Types.ObjectId().toString()}@example.com`,
provider: 'local',
role,
});
return { id: user._id.toString(), role, idOnTheSource: null };
};
describe('getMCPServersList', () => {
it('skips the capability probe when no server is DB-backed', async () => {
await seedManageMcpGrant();
const reqUser = await createUser(SystemRoles.ADMIN);
mockResolveAllMcpConfigs.mockResolvedValue({ yamlServer: { ...yamlConfig } });
const res = createRes();
await getMCPServersList({ user: reqUser }, res);
expect(existsSpy).not.toHaveBeenCalled();
const payload = res.json.mock.calls[0][0];
expect(payload.yamlServer.title).toBe('YAML Server');
expect(payload.yamlServer.url).toBeUndefined();
expect(payload.yamlServer.oauth.authorization_url).toBeUndefined();
});
it('skips the probe entirely for an empty server map', async () => {
const reqUser = await createUser();
mockResolveAllMcpConfigs.mockResolvedValue({});
const res = createRes();
await getMCPServersList({ user: reqUser }, res);
expect(existsSpy).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({});
});
it('applies the capability bypass to all servers when a DB-backed server is present', async () => {
await seedManageMcpGrant();
const reqUser = await createUser(SystemRoles.ADMIN);
const dbId = new mongoose.Types.ObjectId();
mockResolveAllMcpConfigs.mockResolvedValue({
dbServer: createDbConfig(dbId),
yamlServer: { ...yamlConfig },
});
const res = createRes();
await getMCPServersList({ user: reqUser }, res);
expect(existsSpy).toHaveBeenCalledTimes(1);
const payload = res.json.mock.calls[0][0];
expect(payload.dbServer.url).toBe('https://user.example.com/mcp');
expect(payload.yamlServer.url).toBe('https://internal.example.com/mcp');
});
it('falls back to ACL EDIT for DB-backed servers without the capability', async () => {
const reqUser = await createUser();
const dbId = new mongoose.Types.ObjectId();
await grantPermission({
principalType: PrincipalType.USER,
principalId: reqUser.id,
resourceType: ResourceType.MCPSERVER,
resourceId: dbId,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: reqUser.id,
});
mockResolveAllMcpConfigs.mockResolvedValue({
dbServer: createDbConfig(dbId),
yamlServer: { ...yamlConfig },
});
const res = createRes();
await getMCPServersList({ user: reqUser }, res);
expect(existsSpy).toHaveBeenCalledTimes(1);
const payload = res.json.mock.calls[0][0];
expect(payload.dbServer.url).toBe('https://user.example.com/mcp');
expect(payload.yamlServer.url).toBeUndefined();
});
it('leaves DB-backed servers redacted for viewer-only ACL', async () => {
const reqUser = await createUser();
const dbId = new mongoose.Types.ObjectId();
await grantPermission({
principalType: PrincipalType.USER,
principalId: reqUser.id,
resourceType: ResourceType.MCPSERVER,
resourceId: dbId,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: reqUser.id,
});
mockResolveAllMcpConfigs.mockResolvedValue({ dbServer: createDbConfig(dbId) });
const res = createRes();
await getMCPServersList({ user: reqUser }, res);
expect(existsSpy).toHaveBeenCalledTimes(1);
const payload = res.json.mock.calls[0][0];
expect(payload.dbServer.title).toBe('DB Server');
expect(payload.dbServer.url).toBeUndefined();
});
});
describe('getMCPServerById', () => {
it('still runs the capability probe for YAML servers on the detail route', async () => {
await seedManageMcpGrant();
const reqUser = await createUser(SystemRoles.ADMIN);
mockRegistryInstance.getServerConfig.mockResolvedValue({ ...yamlConfig });
const res = createRes();
await getMCPServerById({ user: reqUser, params: { serverName: 'yamlServer' } }, res);
expect(existsSpy).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
const payload = res.json.mock.calls[0][0];
expect(payload.url).toBe('https://internal.example.com/mcp');
expect(payload.oauth.authorization_url).toBe('https://internal.example.com/auth');
});
it('redacts YAML server details for users without the capability', async () => {
const reqUser = await createUser();
mockRegistryInstance.getServerConfig.mockResolvedValue({ ...yamlConfig });
const res = createRes();
await getMCPServerById({ user: reqUser, params: { serverName: 'yamlServer' } }, res);
expect(existsSpy).toHaveBeenCalledTimes(1);
const payload = res.json.mock.calls[0][0];
expect(payload.url).toBeUndefined();
expect(payload.oauth.authorization_url).toBeUndefined();
});
});
@@ -0,0 +1,102 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
jest.mock('@librechat/api', () => ({
checkAccess: jest.fn(),
loadWebSearchAuth: jest.fn(),
}));
jest.mock('~/models', () => ({
getRoleByName: jest.fn(),
createToolCall: jest.fn(),
getToolCallsByConvo: jest.fn(),
getMessage: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
processFileURL: jest.fn(),
uploadImageBuffer: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/app/clients/tools/util', () => ({
loadTools: jest.fn(),
}));
const { Tools, AuthType } = require('librechat-data-provider');
const { verifyToolAuth } = require('../tools');
/**
* Phase 8 behavioral pin: `verifyToolAuth(execute_code)` unconditionally
* returns system-authenticated. Sandbox auth moved server-side into the
* agents library, so the per-user `CODE_API_KEY` check that previously
* gated this endpoint is gone. The deployment contract is: if the
* admin enabled the `execute_code` capability, the sandbox is
* reachable. This endpoint does not probe reachability (would be too
* expensive per UI-gate query); failures surface at execution time.
*
* A regression where someone re-adds an auth check here would
* resurrect the per-user key-entry dialog on the client, which Phase 8
* explicitly removed. Pin the contract.
*/
describe('verifyToolAuth — execute_code system-auth contract', () => {
const makeReq = (toolId) => ({
params: { toolId },
user: { id: 'user-1' },
config: {},
});
const makeRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
it('returns authenticated: true with SYSTEM_DEFINED for execute_code', async () => {
const res = makeRes();
await verifyToolAuth(makeReq(Tools.execute_code), res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
authenticated: true,
message: AuthType.SYSTEM_DEFINED,
});
});
it('returns 404 for unknown tool ids (not in directCallableTools)', async () => {
const res = makeRes();
await verifyToolAuth(makeReq('not_a_real_tool'), res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ message: 'Tool not found' });
});
it('does NOT invoke loadAuthValues for execute_code (no per-user credential check)', async () => {
/* Regression guard: a future refactor that threads per-user auth back
in would resurface the key-entry dialog on the client. Pin that
the auth path is never consulted. */
const { loadAuthValues } = require('~/server/services/Tools/credentials');
loadAuthValues.mockClear();
await verifyToolAuth(makeReq(Tools.execute_code), makeRes());
expect(loadAuthValues).not.toHaveBeenCalled();
});
it('does NOT reference AuthType.USER_PROVIDED in the response (Phase 8 removed the path)', async () => {
const res = makeRes();
await verifyToolAuth(makeReq(Tools.execute_code), res);
const payload = res.json.mock.calls[0][0];
expect(payload.message).not.toBe(AuthType.USER_PROVIDED);
});
});
@@ -0,0 +1,489 @@
/**
* Full-wiring ask_user_question lifecycle e2e.
*
* Companion to `hitlCheckpoint.e2e.spec.js`, same REAL components: the
* `@librechat/agents` Run (FakeChatModel scripted to call the ask tool), the
* LazyMongoSaver over mongodb-memory-server, the GenerationJobManager, and the
* `/agents/chat/resume` controller via supertest. The seam under test here is
* different, though: the interrupt is raised INSIDE a tool body (the tool's
* func calls the SDK's `askUserQuestion()` helper, which wraps LangGraph
* `interrupt()`), not by the PreToolUse approval gate — and the run carries a
* checkpointer but NO `humanInTheLoop` switch and NO hooks, proving the
* question flow works with the approval policy fully disabled.
*/
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { HumanMessage } = require('@langchain/core/messages');
const { Run, Providers, FakeChatModel, askUserQuestion } = require('@librechat/agents');
const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: mockLogger,
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
checkAndIncrementPendingRequest: jest.fn(async () => ({ allowed: true })),
decrementPendingRequest: jest.fn(async () => {}),
}));
jest.mock('~/models', () => ({
saveMessage: jest.fn(async (req, message) => message),
getConvo: jest.fn(async () => null),
getMessages: jest.fn(async () => []),
}));
jest.mock('~/server/cleanup', () => ({
disposeClient: jest.fn(),
}));
jest.mock('~/server/services/MCPRequestContext', () => ({
getMCPRequestContext: jest.fn(() => null),
cleanupMCPRequestContextForReq: jest.fn(),
}));
// Import after mocks — these are the REAL implementations.
const {
GenerationJobManager,
createStreamServices,
buildPendingAction,
getAgentCheckpointer,
deleteAgentCheckpoint,
__resetCheckpointerForTests,
} = require('@librechat/api');
const ResumeAgentController = require('~/server/controllers/agents/resume');
const USER_ID = 'ask-e2e-user';
const MONGO_CFG = { type: 'mongo', ttl: 3600 };
const ASK_TOOL = 'ask_user_question';
/**
* Body-run counter + captured resolution. The body executes TWICE per answered
* question by LangGraph contract (pass 1 runs until `interrupt()` throws; the
* resume pass re-runs the body from the top and `askUserQuestion()` returns the
* host's answer), so `bodyRuns` proves the re-entry semantics and
* `resolvedAnswers` proves the answer round-trip.
*/
let bodyRuns = 0;
let resolvedAnswers = [];
const askTool = tool(
async (input) => {
bodyRuns += 1;
const { answer } = askUserQuestion(input);
resolvedAnswers.push(answer);
return answer;
},
{
name: ASK_TOOL,
description: 'Ask the user a clarifying question and wait for their answer.',
schema: z.object({
question: z.string(),
description: z.string().optional(),
options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
}),
},
);
/**
* Build a REAL run shaped like production `createRun` for the ask-only case:
* durable checkpointer attached, `eagerEventToolExecution` on with the ask
* tool excluded (mirrors the planned run.ts wiring) — and, deliberately, NO
* `humanInTheLoop` and NO hooks.
*/
async function buildAskRun({ saver, responses, toolCalls, runId }) {
const run = await Run.create({
runId,
graphConfig: {
type: 'standard',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-4o-mini',
streaming: true,
streamUsage: false,
},
instructions: 'You are a helpful assistant.',
tools: [askTool],
compileOptions: { checkpointer: saver },
},
returnContent: true,
customHandlers: {},
tokenCounter: (text) => String(text ?? '').length,
indexTokenCountMap: {},
eagerEventToolExecution: { enabled: true, excludeToolNames: [ASK_TOOL] },
});
run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
return run;
}
/**
* Build a REAL run in the PRODUCTION shape: the agents endpoint loads tools
* definitions-only, so the run is EVENT-DRIVEN (`toolDefinitions` non-empty flips
* the SDK ToolNode to event dispatch) and the ask tool rides `graphTools` — the
* SDK's in-graph direct-tool seam (agents#289, > 3.2.57) — because an event-
* dispatched tool body executes in the host handler outside the Pregel task
* frame, where `interrupt()` throws instead of pausing. This is the mode
* `createRun` produces via `buildAgentInput`; the traditional-mode harness above
* covers runs with zero toolDefinitions.
*/
async function buildAskRunEventMode({ saver, responses, toolCalls, runId }) {
const run = await Run.create({
runId,
graphConfig: {
type: 'standard',
agents: [
{
agentId: 'agent-ask-event',
provider: Providers.OPENAI,
clientOptions: { model: 'gpt-4o-mini', streaming: true, streamUsage: false },
instructions: 'You are a helpful assistant.',
maxContextTokens: 8000,
toolDefinitions: [{ name: 'dummy_event_tool', description: 'host-executed event tool' }],
graphTools: [askTool],
},
],
compileOptions: { checkpointer: saver },
},
returnContent: true,
customHandlers: {},
tokenCounter: (text) => String(text ?? '').length,
indexTokenCountMap: {},
eagerEventToolExecution: { enabled: true, excludeToolNames: [ASK_TOOL] },
});
run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
return run;
}
const runConfig = (conversationId) => ({
runName: 'AgentRun',
configurable: { thread_id: conversationId, user_id: USER_ID },
streamMode: 'values',
version: 'v2',
});
/** Poll until `predicate` returns true (the resume continuation is fire-and-forget). */
async function waitFor(predicate, { timeoutMs = 10_000, intervalMs = 50 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('waitFor: condition not met within timeout');
}
async function checkpointCounts(conversationId) {
const db = mongoose.connection.db;
return {
checkpoints: await db
.collection('agent_checkpoints')
.countDocuments({ thread_id: conversationId }),
writes: await db
.collection('agent_checkpoint_writes')
.countDocuments({ thread_id: conversationId }),
};
}
let mongoServer;
let saver;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
__resetCheckpointerForTests();
saver = await getAgentCheckpointer(MONGO_CFG);
GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
GenerationJobManager.initialize();
GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
});
}, 60000);
afterAll(async () => {
GenerationJobManager.setApprovalExpiredHandler(null);
await GenerationJobManager.destroy();
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(() => {
bodyRuns = 0;
resolvedAnswers = [];
jest.clearAllMocks();
});
describe('ask_user_question lifecycle (full wiring, approval policy disabled)', () => {
jest.setTimeout(30000);
test('a tool-body interrupt pauses durably and the REAL /resume controller delivers the answer as the tool result', async () => {
const conversationId = `ask-e2e-resume-${Date.now()}`;
const responseMessageId = 'resp-ask-1';
// --- Turn 1: the model calls the ask tool → interrupt() from inside the tool body. ---
const run = await buildAskRun({
saver,
responses: ['Let me check with you.'],
toolCalls: [
{
name: ASK_TOOL,
args: {
question: 'Which environment should I deploy to?',
options: [
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
],
},
id: 'tc_ask_1',
type: 'tool_call',
},
],
runId: responseMessageId,
});
await run.processStream(
{ messages: [new HumanMessage('deploy the app')] },
runConfig(conversationId),
);
const interrupt = run.getInterrupt();
expect(interrupt?.payload?.type).toBe('ask_user_question');
expect(interrupt.payload.question).toEqual({
question: 'Which environment should I deploy to?',
options: [
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
],
});
expect(bodyRuns).toBe(1); // body entered once; interrupt() threw before any answer
expect(resolvedAnswers).toEqual([]);
const paused = await checkpointCounts(conversationId);
expect(paused.checkpoints).toBeGreaterThan(0); // the interrupt checkpoint is durable
// --- Pause bookkeeping (mirrors AgentClient.handleRunInterrupt). ---
await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
await GenerationJobManager.updateMetadata(conversationId, {
endpoint: 'agents',
agent_id: 'agent-ask-e2e',
responseMessageId,
});
const pendingAction = buildPendingAction(interrupt.payload, {
streamId: conversationId,
conversationId,
runId: responseMessageId,
responseMessageId,
ttlMs: 60_000,
});
expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
// --- Turn 2: answer through the REAL controller; the thin client rebuilds a REAL run. ---
const thinClient = {
contentParts: [],
artifactPromises: [],
conversationId,
responseMessageId,
pendingApproval: null,
async resumeCompletion({ resumeValue, abortController }) {
const resumed = await buildAskRun({
saver,
responses: ['Deploying to staging.'],
runId: responseMessageId,
});
await resumed.resume(resumeValue, {
...runConfig(conversationId),
signal: (abortController ?? new AbortController()).signal,
});
const reInterrupt = resumed.getInterrupt?.();
if (reInterrupt?.payload) {
this.pendingApproval = reInterrupt.payload;
}
this.contentParts.push({ type: 'text', text: 'Deploying to staging.' });
return resumed;
},
};
const initializeClient = jest.fn(async () => ({ client: thinClient }));
const addTitle = jest.fn();
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: USER_ID };
req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
next();
});
app.post('/api/agents/chat/resume', (req, res, next) =>
ResumeAgentController(req, res, next, initializeClient, addTitle),
);
const response = await request(app).post('/api/agents/chat/resume').send({
conversationId,
actionId: pendingAction.actionId,
agent_id: 'agent-ask-e2e',
endpoint: 'agents',
answer: 'staging',
});
expect(response.status).toBe(200);
expect(response.body.status).toBe('resuming');
await waitFor(async () => {
const liveJob = await GenerationJobManager.getJob(conversationId);
return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
});
expect(initializeClient).toHaveBeenCalledTimes(1);
// Resume pass re-ran the body from the top; askUserQuestion() returned the answer.
expect(bodyRuns).toBe(2);
expect(resolvedAnswers).toEqual(['staging']);
expect(thinClient.pendingApproval).toBeNull(); // no second question — turn completed
// Terminal state: the checkpoint was pruned by the REAL finalize path.
await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
});
test('EVENT-DRIVEN mode (production shape): the graphTools ask tool pauses and resumes over the REAL /resume controller', async () => {
const conversationId = `ask-e2e-event-${Date.now()}`;
const responseMessageId = 'resp-ask-event-1';
const run = await buildAskRunEventMode({
saver,
responses: ['Let me check with you.'],
toolCalls: [
{
name: ASK_TOOL,
args: { question: 'Proceed with the migration?' },
id: 'tc_ask_ev1',
type: 'tool_call',
},
],
runId: responseMessageId,
});
await run.processStream(
{ messages: [new HumanMessage('run the migration')] },
runConfig(conversationId),
);
const interrupt = run.getInterrupt();
expect(interrupt?.payload?.type).toBe('ask_user_question');
expect(interrupt.payload.question).toEqual({ question: 'Proceed with the migration?' });
expect(bodyRuns).toBe(1);
expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0);
await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
await GenerationJobManager.updateMetadata(conversationId, {
endpoint: 'agents',
agent_id: 'agent-ask-e2e',
responseMessageId,
});
const pendingAction = buildPendingAction(interrupt.payload, {
streamId: conversationId,
conversationId,
runId: responseMessageId,
responseMessageId,
ttlMs: 60_000,
});
expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
const thinClient = {
contentParts: [],
artifactPromises: [],
conversationId,
responseMessageId,
pendingApproval: null,
async resumeCompletion({ resumeValue, abortController }) {
const resumed = await buildAskRunEventMode({
saver,
responses: ['Migration underway.'],
runId: responseMessageId,
});
await resumed.resume(resumeValue, {
...runConfig(conversationId),
signal: (abortController ?? new AbortController()).signal,
});
this.contentParts.push({ type: 'text', text: 'Migration underway.' });
return resumed;
},
};
const initializeClient = jest.fn(async () => ({ client: thinClient }));
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: USER_ID };
req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
next();
});
app.post('/api/agents/chat/resume', (req, res, next) =>
ResumeAgentController(req, res, next, initializeClient, jest.fn()),
);
const response = await request(app).post('/api/agents/chat/resume').send({
conversationId,
actionId: pendingAction.actionId,
agent_id: 'agent-ask-e2e',
endpoint: 'agents',
answer: 'yes, proceed',
});
expect(response.status).toBe(200);
await waitFor(async () => {
const liveJob = await GenerationJobManager.getJob(conversationId);
return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
});
expect(bodyRuns).toBe(2);
expect(resolvedAnswers).toEqual(['yes, proceed']);
await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
});
test('a second question raised after resume re-pauses with a fresh ask_user_question interrupt', async () => {
const conversationId = `ask-e2e-seq-${Date.now()}`;
const run = await buildAskRun({
saver,
responses: ['First question coming.'],
toolCalls: [
{
name: ASK_TOOL,
args: { question: 'Pick a color?' },
id: 'tc_ask_q1',
type: 'tool_call',
},
],
runId: 'resp-ask-seq',
});
await run.processStream({ messages: [new HumanMessage('start')] }, runConfig(conversationId));
expect(run.getInterrupt()?.payload?.type).toBe('ask_user_question');
expect(bodyRuns).toBe(1);
// Resume with the first answer; the NEXT model turn asks a second question.
const resumed = await buildAskRun({
saver,
responses: ['And one more thing.'],
toolCalls: [
{
name: ASK_TOOL,
args: { question: 'Pick a size?' },
id: 'tc_ask_q2',
type: 'tool_call',
},
],
runId: 'resp-ask-seq',
});
await resumed.resume({ answer: 'blue' }, runConfig(conversationId));
expect(resolvedAnswers).toEqual(['blue']);
const second = resumed.getInterrupt();
expect(second?.payload?.type).toBe('ask_user_question');
expect(second.payload.question).toEqual({ question: 'Pick a size?' });
// q1 body ran twice (pass + resume); q2 body entered once and interrupted.
expect(bodyRuns).toBe(3);
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
});
});
@@ -0,0 +1,685 @@
const { Tools } = require('librechat-data-provider');
// Mock all dependencies before requiring the module
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-id'),
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
HOST_FILE_AUTHORING_ARTIFACT_KEY: '__librechat_file_authoring',
isCodeSessionToolName: jest.fn((name) =>
['execute_code', 'bash_tool', 'read_file'].includes(name),
),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
getMessageId: jest.fn(),
ToolEndHandler: jest.fn(),
handleToolCalls: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
/* `runPreviewFinalize` is the runtime pairing for `finalize` (defined
* alongside processCodeOutput in process.js). The callback wires
* the deferred render through it; reproduce the basic happy-path here so the
* SSE-emit assertions still work. The catch/defensive-updateFile
* branch is unit-tested directly against the real helper in
* process.spec.js — exercising it here would add test coupling
* without coverage benefit. */
runPreviewFinalize: ({ finalize, onResolved }) => {
if (typeof finalize !== 'function') {
return;
}
finalize()
.then((updated) => {
if (!updated || !onResolved) {
return;
}
onResolved(updated);
})
.catch(() => {
/* swallowed in the mock — see process.spec.js for catch coverage */
});
},
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
describe('createToolEndCallback', () => {
let req, res, artifactPromises, createToolEndCallback;
let logger;
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked logger
logger = require('@librechat/data-schemas').logger;
// Now require the module after all mocks are set up
const callbacks = require('../callbacks');
createToolEndCallback = callbacks.createToolEndCallback;
req = {
user: { id: 'user123' },
};
res = {
headersSent: false,
write: jest.fn(),
};
artifactPromises = [];
});
describe('ui_resources artifact handling', () => {
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: [
{ type: 'button', label: 'Click me' },
{ type: 'input', placeholder: 'Enter text' },
],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
// Wait for all promises to resolve
const results = await Promise.all(artifactPromises);
// When headers are not sent, it returns attachment without writing
expect(res.write).not.toHaveBeenCalled();
const attachment = results[0];
expect(attachment).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: [
{ type: 'button', label: 'Click me' },
{ type: 'input', placeholder: 'Enter text' },
],
});
});
it('should write to response when headers are already sent', async () => {
res.headersSent = true;
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: [{ type: 'carousel', items: [] }],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(res.write).toHaveBeenCalled();
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: [{ type: 'carousel', items: [] }],
});
});
it('should handle errors when processing ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
// Mock res.write to throw an error
res.headersSent = true;
res.write.mockImplementation(() => {
throw new Error('Write failed');
});
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: [{ type: 'test' }],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(logger.error).toHaveBeenCalledWith(
'Error processing artifact content:',
expect.any(Error),
);
expect(results[0]).toBeNull();
});
it('should handle multiple artifacts including ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: [{ type: 'chart', data: [] }],
},
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
// Both ui_resources and web_search should be processed
expect(artifactPromises).toHaveLength(2);
expect(results).toHaveLength(2);
// Check ui_resources attachment
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
expect(uiResourceAttachment).toBeTruthy();
expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
// Check web_search attachment
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
expect(webSearchAttachment).toBeTruthy();
expect(webSearchAttachment[Tools.web_search]).toEqual({
results: ['result1', 'result2'],
});
});
it('should not process artifacts when output has no artifacts', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
content: 'Some regular content',
// No artifact property
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle empty ui_resources data object', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: [],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: [],
});
});
it('should handle ui_resources with complex nested data', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const complexData = {
0: {
type: 'form',
fields: [
{ name: 'field1', type: 'text', required: true },
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
],
nested: {
deep: {
value: 123,
array: [1, 2, 3],
},
},
},
};
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: complexData,
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0][Tools.ui_resources]).toEqual(complexData);
});
it('should handle when output is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output: undefined }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
it('should handle when data parameter is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback(undefined, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
describe('code execution deferred-preview emit', () => {
/* The deferred-preview code-execution flow emits the attachment twice over
* SSE: the initial emit with `status: 'pending'` and the current run's
* messageId, the deferred render with the resolved record. The preview update emit
* must use the CURRENT run's messageId (not the persisted DB one)
* because `processCodeOutput` intentionally preserves the original
* `messageId` on cross-turn filename reuse — `getCodeGeneratedFiles`
* needs that for prior-turn priming.
*
* Codex P1 review on PR #12957: shipping `updated.messageId`
* straight from the DB record routed preview-update patches to the wrong
* message slot, leaving the current turn's pending chip stuck. */
const { processCodeOutput } = require('~/server/services/Files/Code/process');
function makeCodeExecutionEvent({
runId,
threadId,
toolCallId,
fileId,
name,
toolName = 'execute_code',
hostFileAuthoring = false,
}) {
return {
output: {
name: toolName,
tool_call_id: toolCallId,
artifact: {
...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}),
session_id: 'sess-1',
files: [{ id: fileId, name, session_id: 'sess-1' }],
},
},
metadata: { run_id: runId, thread_id: threadId },
};
}
/** Parse the SSE frame `res.write` produces back to a payload object. */
function parseSseAttachment(call) {
const frame = call[0];
const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
return JSON.parse(dataLine.slice('data: '.length));
}
it('the preview update emit uses the current run messageId, not the persisted DB messageId (cross-turn filename reuse)', async () => {
/* Simulate turn-2 reusing `output.csv` from turn-1. The DB record
* surfaced by `updateFile` carries the original `turn-1-msg`
* messageId; the runtime emit must rewrite to `turn-2-msg`. */
res.headersSent = true;
const finalize = jest.fn().mockResolvedValue({
file_id: 'fid-shared',
filename: 'output.csv',
filepath: '/uploads/output.csv',
type: 'text/csv',
conversationId: 'thread789',
messageId: 'turn-1-original-msg', // persisted DB id (older turn)
status: 'ready',
text: '<table></table>',
textFormat: 'html',
});
processCodeOutput.mockResolvedValue({
file: {
file_id: 'fid-shared',
filename: 'output.csv',
filepath: '/uploads/output.csv',
type: 'text/csv',
conversationId: 'thread789',
messageId: 'turn-2-current-run', // runtime overlay (current turn)
toolCallId: 'tool-2',
status: 'pending',
text: null,
textFormat: null,
},
finalize,
});
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const event = makeCodeExecutionEvent({
runId: 'turn-2-current-run',
threadId: 'thread789',
toolCallId: 'tool-2',
fileId: 'fid-shared',
name: 'output.csv',
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
// Wait one more tick so the fire-and-forget finalize() chain settles.
await new Promise((resolve) => setImmediate(resolve));
// Two SSE writes: the initial emit (pending) and the deferred render (ready).
expect(res.write).toHaveBeenCalledTimes(2);
const phase1 = parseSseAttachment(res.write.mock.calls[0]);
const phase2 = parseSseAttachment(res.write.mock.calls[1]);
// Initial emit already used the runtime messageId (sourced from result.file).
expect(phase1.messageId).toBe('turn-2-current-run');
expect(phase1.status).toBe('pending');
/* The preview update MUST also route to the current run's messageId so the
* frontend's `useAttachmentHandler` upserts under the same
* messageAttachmentsMap slot as the initial emit. Routing to
* `turn-1-original-msg` would land the patch on a stale message
* and leave turn-2's pending chip stuck. */
expect(phase2.messageId).toBe('turn-2-current-run');
expect(phase2.file_id).toBe('fid-shared');
expect(phase2.status).toBe('ready');
expect(phase2.text).toBe('<table></table>');
expect(phase2.toolCallId).toBe('tool-2');
/* Wire-shape parity with the initial emit: preview update emits the full updated
* record so the client doesn't see one shape on the initial emit and a
* narrower projection on the deferred render. (Codex audit on PR #12957
* Finding 1.) */
expect(phase2.filename).toBe('output.csv');
expect(phase2.filepath).toBe('/uploads/output.csv');
expect(phase2.type).toBe('text/csv');
expect(phase2.conversationId).toBe('thread789');
expect(phase2.textFormat).toBe('html');
});
it('the preview update emit is skipped when finalize resolves to null (no DB update happened)', async () => {
res.headersSent = true;
processCodeOutput.mockResolvedValue({
file: {
file_id: 'fid-1',
filename: 'data.xlsx',
filepath: '/uploads/data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
messageId: 'run-1',
toolCallId: 'tool-1',
status: 'pending',
},
finalize: jest.fn().mockResolvedValue(null),
});
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const event = makeCodeExecutionEvent({
runId: 'run-1',
threadId: 'thread-1',
toolCallId: 'tool-1',
fileId: 'fid-1',
name: 'data.xlsx',
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
await new Promise((resolve) => setImmediate(resolve));
// Only the initial emit fired; preview update noop'd because finalize returned null.
expect(res.write).toHaveBeenCalledTimes(1);
});
it('the preview update emit is skipped when the response stream has already closed', async () => {
res.headersSent = true;
/* Hand-rolled deferred so we can hold finalize() open until
* AFTER setting `res.writableEnded = true`. Otherwise the mock
* resolves synchronously, the .then() runs in the same microtask
* queue as the artifactPromises await, and writableEnded is set
* too late. */
let resolveFinalize;
const finalizeDeferred = new Promise((resolve) => {
resolveFinalize = resolve;
});
processCodeOutput.mockResolvedValue({
file: {
file_id: 'fid-1',
filename: 'data.xlsx',
filepath: '/uploads/data.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
messageId: 'run-1',
toolCallId: 'tool-1',
status: 'pending',
},
finalize: jest.fn().mockReturnValue(finalizeDeferred),
});
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const event = makeCodeExecutionEvent({
runId: 'run-1',
threadId: 'thread-1',
toolCallId: 'tool-1',
fileId: 'fid-1',
name: 'data.xlsx',
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
// Simulate the response closing AFTER the initial emit fires but BEFORE
// the deferred render lands. The frontend's polling path will catch the
// resolved record on its next tick.
res.writableEnded = true;
// Now resolve finalize and let the .then() chain run.
resolveFinalize({
file_id: 'fid-1',
filename: 'data.xlsx',
messageId: 'run-1',
status: 'ready',
text: '<x/>',
textFormat: 'html',
});
await new Promise((resolve) => setImmediate(resolve));
// Initial emit wrote; preview update noop'd because writableEnded.
expect(res.write).toHaveBeenCalledTimes(1);
});
it('does not call finalize for a non-office file (no preview expected)', async () => {
res.headersSent = true;
processCodeOutput.mockResolvedValue({
file: {
file_id: 'fid-txt',
filename: 'note.txt',
filepath: '/uploads/note.txt',
type: 'text/plain',
messageId: 'run-1',
toolCallId: 'tool-1',
// No status — non-office files skip the deferred render entirely.
},
// No finalize key — caller should not call anything.
});
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const event = makeCodeExecutionEvent({
runId: 'run-1',
threadId: 'thread-1',
toolCallId: 'tool-1',
fileId: 'fid-txt',
name: 'note.txt',
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
await new Promise((resolve) => setImmediate(resolve));
expect(res.write).toHaveBeenCalledTimes(1);
});
it('processes create_file sandbox artifacts like code execution outputs', async () => {
res.headersSent = true;
processCodeOutput.mockResolvedValue({
file: {
file_id: 'fid-created',
filename: 'created.txt',
filepath: '/uploads/created.txt',
type: 'text/plain',
conversationId: 'thread789',
messageId: 'run-create',
toolCallId: 'tool-create',
status: 'ready',
},
});
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const event = makeCodeExecutionEvent({
runId: 'run-create',
threadId: 'thread789',
toolCallId: 'tool-create',
fileId: 'fid-created',
name: 'created.txt',
toolName: 'create_file',
hostFileAuthoring: true,
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
expect(processCodeOutput).toHaveBeenCalledWith(
expect.objectContaining({
id: 'fid-created',
name: 'created.txt',
messageId: 'run-create',
toolCallId: 'tool-create',
conversationId: 'thread789',
}),
);
expect(res.write).toHaveBeenCalledTimes(1);
});
it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => {
res.headersSent = true;
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const event = makeCodeExecutionEvent({
runId: 'run-user-create',
threadId: 'thread789',
toolCallId: 'tool-user-create',
fileId: 'fid-user-created',
name: 'created.txt',
toolName: 'create_file',
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
expect(processCodeOutput).not.toHaveBeenCalled();
expect(res.write).not.toHaveBeenCalled();
});
});
});
describe('isStreamWritable', () => {
/* Direct parametric coverage of the predicate that gates SSE writes
* in both the chat-completions and Open Responses callbacks. The
* existing deferred-preview tests cover this indirectly via the
* `writeAttachmentUpdate` writableEnded path; these tests pin down
* each individual branch so a future modification (e.g. adding a
* new condition) can't silently regress.
* (Comprehensive review NIT on PR #12957.) */
const { isStreamWritable } = require('../callbacks');
it('returns true when streamId is truthy regardless of res state', () => {
/* Resumable mode writes go to the job emitter; res state is
* irrelevant. Even a closed res with no headers should not block. */
expect(isStreamWritable(null, 'stream-1')).toBe(true);
expect(isStreamWritable({ headersSent: false, writableEnded: true }, 'stream-1')).toBe(true);
expect(isStreamWritable(undefined, 'stream-1')).toBe(true);
});
it('returns false when streamId is falsy and res is null/undefined', () => {
expect(isStreamWritable(null, null)).toBe(false);
expect(isStreamWritable(undefined, null)).toBe(false);
});
it('returns false when headers have not been sent yet', () => {
expect(isStreamWritable({ headersSent: false, writableEnded: false }, null)).toBe(false);
});
it('returns false when the stream has already ended', () => {
expect(isStreamWritable({ headersSent: true, writableEnded: true }, null)).toBe(false);
});
it('returns true on the happy path: headers sent, not ended, no streamId', () => {
expect(isStreamWritable({ headersSent: true, writableEnded: false }, null)).toBe(true);
});
});
@@ -0,0 +1,139 @@
const AgentClient = require('../client');
/** Minimal post-(maybe-)summary snapshot. baseUsed = maxContextTokens(1000) -
* remainingContextTokens(700) = 300, so the marker (summaryUsedTokens) is 300. */
const snapshot = (summaryTokens) => ({
runId: 'run-1',
agentId: 'agent-1',
breakdown: {
maxContextTokens: 1000,
instructionTokens: 50,
systemMessageTokens: 50,
dynamicInstructionTokens: 0,
toolSchemaTokens: 0,
summaryTokens,
toolCount: 0,
messageCount: 1,
messageTokens: 20,
availableForMessages: 900,
},
contextBudget: 1000,
remainingContextTokens: 700,
prePruneContextTokens: 300,
effectiveInstructionTokens: 50,
calibrationRatio: 1,
});
const primary = { input_tokens: 10, output_tokens: 5, total_tokens: 15 };
const summarizationUsage = { ...primary, usage_type: 'summarization' };
const primaryFor = (runId, output_tokens) => ({
input_tokens: 10,
output_tokens,
total_tokens: 10 + output_tokens,
provider: 'openAI',
runId,
});
function buildMeta({ snap, latestUsageIndex, usageEvents }) {
const self = {
collectedThoughtSignatures: null,
usageEmitSink: usageEvents,
contextUsageSink: snap
? { latest: snap, count: 1, latestUsageIndex }
: { latest: null, count: 0 },
};
return AgentClient.prototype.buildResponseMetadata.call(self);
}
describe('AgentClient.buildResponseMetadata — snapshot persistence + summary marker', () => {
it('persists the snapshot when a primary usage follows it (normal turn)', () => {
const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 0, usageEvents: [primary] });
expect(meta.contextUsage).toBeDefined();
expect(meta.summaryUsedTokens).toBeUndefined();
});
it('persists the post-summary snapshot when the only pre-primary usage is the summarization', () => {
/** A summarized turn: the summarization usage precedes the post-summary
* snapshot (index 1), then the model's primary usage follows it. The old
* count guard miscounted and dropped this; the new guard keeps it. The
* marker subtracts the summarization output (5): the generated summary is in
* the snapshot baseline (summaryTokens) AND the response tokenCount, so
* 300 5 = 295 keeps the client estimate from counting it twice. */
const meta = buildMeta({
snap: snapshot(80),
latestUsageIndex: 1,
usageEvents: [summarizationUsage, primary],
});
expect(meta.contextUsage).toBeDefined();
expect(meta.summaryUsedTokens).toBe(295);
});
it('still emits the summary marker when the final call emitted no usage', () => {
/** Interrupted summarized turn: no primary usage follows the latest snapshot,
* so the snapshot is (correctly) not persisted — but the coarse marker
* survives so the client estimate still caps the discarded history. The
* summarization output (5) is subtracted (300 5 = 295). */
const meta = buildMeta({
snap: snapshot(80),
latestUsageIndex: 1,
usageEvents: [summarizationUsage],
});
expect(meta.contextUsage).toBeUndefined();
expect(meta.summaryUsedTokens).toBe(295);
});
it('drops the snapshot and emits no marker when the final call had no usage and no summary', () => {
const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 1, usageEvents: [primary] });
expect(meta.contextUsage).toBeUndefined();
expect(meta.summaryUsedTokens).toBeUndefined();
});
it('does not persist the snapshot when only a parallel run produced post-snapshot usage', () => {
/** A snapshot (run-1) → B snapshot (run-1 is latest) but the only following
* usage belongs to a sibling run (run-2). The guard must NOT persist run-1's
* snapshot with run-2's output — it falls back to the per-message estimate. */
const meta = buildMeta({
snap: snapshot(0),
latestUsageIndex: 0,
usageEvents: [primaryFor('run-2', 99)],
});
expect(meta.contextUsage).toBeUndefined();
});
it('persists with the snapshot run output when its own primary usage follows', () => {
const meta = buildMeta({
snap: snapshot(0),
latestUsageIndex: 0,
usageEvents: [primaryFor('run-2', 99), primaryFor('run-1', 7)],
});
expect(meta.contextUsage).toBeDefined();
expect(meta.contextUsage.completedOutputTokens).toBe(7);
});
it('subtracts earlier tool-loop output from the summary marker (interrupted turn)', () => {
/** Multi-call summarized turn stopped before the final usage: the earlier
* call (output 40) is baked into baseUsed (300), so the marker is 300 40 =
* 260. No primary follows the snapshot, so the full snapshot is not persisted
* and the client uses this marker — which must not double-count the 40 that
* the response tokenCount also carries. */
const meta = buildMeta({
snap: snapshot(80),
latestUsageIndex: 1,
usageEvents: [primaryFor('run-1', 40)],
});
expect(meta.contextUsage).toBeUndefined();
expect(meta.summaryUsedTokens).toBe(260);
});
it('subtracts only this runs earlier output, not a parallel runs', () => {
const meta = buildMeta({
snap: snapshot(80),
latestUsageIndex: 2,
usageEvents: [primaryFor('run-2', 999), primaryFor('run-1', 40), primaryFor('run-1', 5)],
});
/** baseUsed 300 run-1's earlier 40 = 260; run-2's 999 is ignored. */
expect(meta.summaryUsedTokens).toBe(260);
/** run-1's own primary follows the snapshot → snapshot persisted with output 5. */
expect(meta.contextUsage.completedOutputTokens).toBe(5);
});
});
@@ -0,0 +1,74 @@
const { EModelEndpoint, AgentCapabilities } = require('librechat-data-provider');
/**
* Pins the capability-flag derivation that `AgentClient::useMemory` uses when
* it calls `initializeAgent` for the memory-extraction agent. The expression
* is trivial but lives in a controller path that's otherwise hard to unit-
* test, so a focused regression guard at the pure-logic layer ensures any
* drift in config-key names (`agents`, `capabilities`) or capability enum
* values (`execute_code`) surfaces here instead of silently stripping
* `bash_tool` + `read_file` from memory agents in production.
*
* The expression mirrored below is the one in
* `api/server/controllers/agents/client.js::useMemory`:
*
* new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities)
* .has(AgentCapabilities.execute_code)
*/
function deriveMemoryCodeEnvAvailable(appConfig) {
return new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities).has(
AgentCapabilities.execute_code,
);
}
describe('AgentClient::useMemory — codeEnvAvailable derivation', () => {
it('returns true when appConfig lists execute_code under the agents endpoint capabilities', () => {
expect(
deriveMemoryCodeEnvAvailable({
endpoints: {
[EModelEndpoint.agents]: {
capabilities: [AgentCapabilities.execute_code, AgentCapabilities.file_search],
},
},
}),
).toBe(true);
});
it('returns false when the agents endpoint omits execute_code', () => {
expect(
deriveMemoryCodeEnvAvailable({
endpoints: {
[EModelEndpoint.agents]: {
capabilities: [AgentCapabilities.file_search, AgentCapabilities.web_search],
},
},
}),
).toBe(false);
});
it('returns false when the capabilities array is absent', () => {
expect(deriveMemoryCodeEnvAvailable({ endpoints: { [EModelEndpoint.agents]: {} } })).toBe(
false,
);
});
it('returns false when the agents endpoint config is absent', () => {
expect(deriveMemoryCodeEnvAvailable({ endpoints: {} })).toBe(false);
});
it('returns false when appConfig is null / undefined', () => {
/* Defensive — `req.config` can be unset in edge-case test harnesses and
ephemeral-agent flows; the memory path must not throw on access. */
expect(deriveMemoryCodeEnvAvailable(null)).toBe(false);
expect(deriveMemoryCodeEnvAvailable(undefined)).toBe(false);
});
it('matches the literal string "execute_code" — catches enum rename drift', () => {
/* Pins the capability enum value so a rename of `AgentCapabilities.execute_code`
that doesn't propagate to the controllers surfaces here. If this test breaks,
update the underlying expression in `useMemory` and the helpers in
`initialize.js` / `openai.js` / `responses.js` to match. */
expect(AgentCapabilities.execute_code).toBe('execute_code');
expect(EModelEndpoint.agents).toBe('agents');
});
});
@@ -0,0 +1,347 @@
/**
* Full-wiring HITL checkpoint lifecycle e2e.
*
* Every HITL-specific component here is REAL: the `@librechat/agents` Run (driven by the
* SDK's FakeChatModel scripted to call a gated tool), the PreToolUse approval hook +
* `humanInTheLoop` wiring, the LazyMongoSaver over mongodb-memory-server, the
* GenerationJobManager (in-memory services), and the `/agents/chat/resume` controller via
* supertest. Only LibreChat's persistence adapters (`~/models`), request cleanup, and the
* concurrency gate are mocked. This is the cross-layer seam none of the unit suites cover:
* pause → durable checkpoint → HTTP approval → rebuilt-run resume → finalize prune.
*/
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { HumanMessage } = require('@langchain/core/messages');
const { Run, Providers, FakeChatModel } = require('@librechat/agents');
const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: mockLogger,
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
checkAndIncrementPendingRequest: jest.fn(async () => ({ allowed: true })),
decrementPendingRequest: jest.fn(async () => {}),
}));
jest.mock('~/models', () => ({
saveMessage: jest.fn(async (req, message) => message),
getConvo: jest.fn(async () => null),
getMessages: jest.fn(async () => []),
}));
jest.mock('~/server/cleanup', () => ({
disposeClient: jest.fn(),
}));
jest.mock('~/server/services/MCPRequestContext', () => ({
getMCPRequestContext: jest.fn(() => null),
cleanupMCPRequestContextForReq: jest.fn(),
}));
// Import after mocks — these are the REAL implementations.
const {
GenerationJobManager,
createStreamServices,
buildPendingAction,
getAgentCheckpointer,
deleteAgentCheckpoint,
buildHITLRunWiring,
resolveToolApprovalPolicy,
__resetCheckpointerForTests,
} = require('@librechat/api');
const ResumeAgentController = require('~/server/controllers/agents/resume');
const USER_ID = 'hitl-e2e-user';
const MONGO_CFG = { type: 'mongo', ttl: 3600 };
const GATED_TOOL = 'guarded_echo';
/** Side-effect counter: proves the gated tool runs exactly once across pause+resume. */
let toolExecutions = 0;
const guardedTool = tool(async ({ text }) => `echo:${text}`, {
name: GATED_TOOL,
description: 'Echoes text back, but requires human approval first.',
schema: z.object({ text: z.string() }),
});
guardedTool.func = async ({ text }) => {
toolExecutions += 1;
return `echo:${text}`;
};
/** Build a REAL run with the HITL wiring + durable checkpointer attached (mirrors createRun). */
async function buildHitlRun({ saver, conversationId, responses, toolCalls, runId }) {
const hitl = buildHITLRunWiring(
resolveToolApprovalPolicy({ endpoint: { enabled: true, ask: [GATED_TOOL] } }),
{ userId: USER_ID, conversationId, appConfig: {} },
);
const run = await Run.create({
runId,
graphConfig: {
type: 'standard',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-4o-mini',
streaming: true,
streamUsage: false,
},
instructions: 'You are a helpful assistant.',
tools: [guardedTool],
compileOptions: { checkpointer: saver },
},
returnContent: true,
customHandlers: {},
tokenCounter: (text) => String(text ?? '').length,
indexTokenCountMap: {},
...(hitl && { humanInTheLoop: hitl.humanInTheLoop, hooks: hitl.hooks }),
});
run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
return run;
}
const runConfig = (conversationId) => ({
runName: 'AgentRun',
configurable: { thread_id: conversationId, user_id: USER_ID },
streamMode: 'values',
version: 'v2',
});
/** Poll until `predicate` returns true (the resume continuation is fire-and-forget). */
async function waitFor(predicate, { timeoutMs = 10_000, intervalMs = 50 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('waitFor: condition not met within timeout');
}
async function checkpointCounts(conversationId) {
const db = mongoose.connection.db;
return {
checkpoints: await db
.collection('agent_checkpoints')
.countDocuments({ thread_id: conversationId }),
writes: await db
.collection('agent_checkpoint_writes')
.countDocuments({ thread_id: conversationId }),
};
}
let mongoServer;
let saver;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
__resetCheckpointerForTests();
saver = await getAgentCheckpointer(MONGO_CFG);
GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
GenerationJobManager.initialize();
// Mirrors api/server/index.js: expiry prunes the paused run's durable checkpoint.
GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
});
}, 60000);
afterAll(async () => {
GenerationJobManager.setApprovalExpiredHandler(null);
await GenerationJobManager.destroy();
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(() => {
toolExecutions = 0;
jest.clearAllMocks();
});
describe('HITL checkpoint lifecycle (full wiring)', () => {
jest.setTimeout(30000);
test('a clean turn (no tool gating triggered) persists NOTHING durable', async () => {
const conversationId = `e2e-clean-${Date.now()}`;
const run = await buildHitlRun({
saver,
conversationId,
responses: ['Hello there!'],
runId: 'resp-clean',
});
await run.processStream({ messages: [new HumanMessage('hi')] }, runConfig(conversationId));
expect(run.getInterrupt?.()).toBeFalsy();
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
});
test('a turn that ERRORS before pausing persists NOTHING durable', async () => {
const conversationId = `e2e-error-${Date.now()}`;
const run = await buildHitlRun({
saver,
conversationId,
responses: ['unused'],
runId: 'resp-error',
});
class BoomModel extends FakeChatModel {
// eslint-disable-next-line require-yield
async *_streamResponseChunks() {
throw new Error('model boom');
}
}
run.Graph.overrideModel = new BoomModel({ responses: ['unused'] });
await expect(
run.processStream({ messages: [new HumanMessage('hi')] }, runConfig(conversationId)),
).rejects.toThrow('model boom');
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
});
test('pause → approve over the REAL /resume controller → tool runs once → checkpoint pruned', async () => {
const conversationId = `e2e-resume-${Date.now()}`;
const responseMessageId = 'resp-pause-1';
// --- Turn 1: the model calls the gated tool → PreToolUse 'ask' → interrupt. ---
const run = await buildHitlRun({
saver,
conversationId,
responses: ['Let me run that.'],
toolCalls: [{ name: GATED_TOOL, args: { text: 'hi' }, id: 'tc_1', type: 'tool_call' }],
runId: responseMessageId,
});
await run.processStream(
{ messages: [new HumanMessage('run the guarded tool')] },
runConfig(conversationId),
);
const interrupt = run.getInterrupt();
expect(interrupt?.payload?.type).toBe('tool_approval');
expect(toolExecutions).toBe(0); // gated — must NOT have run pre-approval
const paused = await checkpointCounts(conversationId);
expect(paused.checkpoints).toBeGreaterThan(0); // the interrupt checkpoint is durable
// --- Pause bookkeeping (mirrors AgentClient.handleRunInterrupt). ---
const job = await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
await GenerationJobManager.updateMetadata(conversationId, {
endpoint: 'agents',
agent_id: 'agent-e2e',
responseMessageId,
});
const pendingAction = buildPendingAction(interrupt.payload, {
streamId: conversationId,
conversationId,
runId: responseMessageId,
responseMessageId,
ttlMs: 60_000,
});
expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
// --- Turn 2: approve through the REAL controller; the thin client rebuilds a REAL run. ---
const thinClient = {
contentParts: [],
artifactPromises: [],
conversationId,
responseMessageId,
pendingApproval: null,
async resumeCompletion({ resumeValue, abortController }) {
const resumed = await buildHitlRun({
saver,
conversationId,
responses: ['Done after approval.'],
runId: responseMessageId,
});
await resumed.resume(resumeValue, {
...runConfig(conversationId),
signal: (abortController ?? new AbortController()).signal,
});
const reInterrupt = resumed.getInterrupt?.();
if (reInterrupt?.payload) {
this.pendingApproval = reInterrupt.payload;
}
this.contentParts.push({ type: 'text', text: 'Done after approval.' });
return resumed;
},
};
const initializeClient = jest.fn(async () => ({ client: thinClient }));
const addTitle = jest.fn();
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: USER_ID };
req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
next();
});
app.post('/api/agents/chat/resume', (req, res, next) =>
ResumeAgentController(req, res, next, initializeClient, addTitle),
);
const response = await request(app)
.post('/api/agents/chat/resume')
.send({
conversationId,
actionId: pendingAction.actionId,
agent_id: 'agent-e2e',
endpoint: 'agents',
decisions: [{ tool_call_id: 'tc_1', decision: 'approve' }],
});
// The controller ACKs immediately ({ status: 'resuming' }) and drives the resumed run
// asynchronously — wait for the terminal side effects before asserting.
expect(response.status).toBe(200);
expect(response.body.status).toBe('resuming');
await waitFor(async () => {
const liveJob = await GenerationJobManager.getJob(conversationId);
return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
});
expect(initializeClient).toHaveBeenCalledTimes(1);
expect(toolExecutions).toBe(1); // approved tool ran exactly ONCE across pause+resume
// Terminal state: the checkpoint was pruned by the REAL finalize path.
await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
expect(job).toBeDefined();
});
test('an abandoned pause is pruned eagerly on approval EXPIRY (not left to the TTL)', async () => {
const conversationId = `e2e-expiry-${Date.now()}`;
const run = await buildHitlRun({
saver,
conversationId,
responses: ['Let me run that.'],
toolCalls: [{ name: GATED_TOOL, args: { text: 'x' }, id: 'tc_exp', type: 'tool_call' }],
runId: 'resp-expire',
});
await run.processStream({ messages: [new HumanMessage('run it')] }, runConfig(conversationId));
const interrupt = run.getInterrupt();
expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0);
await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
const pendingAction = buildPendingAction(interrupt.payload, {
streamId: conversationId,
conversationId,
runId: 'resp-expire',
responseMessageId: 'resp-expire',
ttlMs: 60_000,
});
await GenerationJobManager.approvals.pause(conversationId, pendingAction);
// The sweeper/stale-submit path: expiry fires the registered checkpoint prune.
expect(await GenerationJobManager.expireApproval(conversationId, pendingAction.actionId)).toBe(
true,
);
expect(await GenerationJobManager.getJobStatus(conversationId)).toBe('aborted');
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
});
});
@@ -0,0 +1,597 @@
/**
* Tests for job replacement detection in ResumableAgentController
*
* Tests the following fixes from PR #11462:
* 1. Job creation timestamp tracking
* 2. Stale job detection and event skipping
* 3. Response message saving before final event emission
*/
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
const mockGenerationJobManager = {
createJob: jest.fn(),
getJob: jest.fn(),
emitDone: jest.fn(),
emitChunk: jest.fn(),
completeJob: jest.fn(),
updateMetadata: jest.fn(),
setContentParts: jest.fn(),
subscribe: jest.fn(),
};
const mockSaveMessage = jest.fn();
const mockDecrementPendingRequest = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn().mockReturnValue(false),
GenerationJobManager: mockGenerationJobManager,
getReferencedQuotes: jest.fn((quotes) => {
if (!Array.isArray(quotes)) {
return null;
}
const normalized = quotes
.filter((quote) => typeof quote === 'string' && quote.trim().length > 0)
.map((quote) => quote.trim());
return normalized.length > 0 ? normalized : null;
}),
checkAndIncrementPendingRequest: jest.fn().mockResolvedValue({ allowed: true }),
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
getViolationInfo: jest.fn(),
sanitizeMessageForTransmit: jest.fn((msg) => msg),
sanitizeFileForTransmit: jest.fn((file) => file),
Constants: { NO_PARENT: '00000000-0000-0000-0000-000000000000' },
}));
jest.mock('~/models', () => ({
saveMessage: (...args) => mockSaveMessage(...args),
}));
describe('Job Replacement Detection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Job Creation Timestamp Tracking', () => {
it('should capture createdAt when job is created', async () => {
const streamId = 'test-stream-123';
const createdAt = Date.now();
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: { on: jest.fn() },
});
const job = await mockGenerationJobManager.createJob(streamId, 'user-123', streamId);
expect(job.createdAt).toBe(createdAt);
});
});
describe('Job Replacement Detection Logic', () => {
/**
* Simulates the job replacement detection logic from request.js
* This is extracted for unit testing since the full controller is complex
*/
const detectJobReplacement = async (streamId, originalCreatedAt) => {
const currentJob = await mockGenerationJobManager.getJob(streamId);
return !currentJob || currentJob.createdAt !== originalCreatedAt;
};
it('should detect when job was replaced (different createdAt)', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const newCreatedAt = 2000;
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: newCreatedAt,
});
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
expect(wasReplaced).toBe(true);
});
it('should detect when job was deleted', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
mockGenerationJobManager.getJob.mockResolvedValue(null);
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
expect(wasReplaced).toBe(true);
});
it('should not detect replacement when same job (same createdAt)', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: originalCreatedAt,
});
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
expect(wasReplaced).toBe(false);
});
});
describe('Event Emission Behavior', () => {
/**
* Simulates the final event emission logic from request.js
*/
const emitFinalEventIfNotReplaced = async ({
streamId,
originalCreatedAt,
finalEvent,
userId,
}) => {
const currentJob = await mockGenerationJobManager.getJob(streamId);
const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
if (jobWasReplaced) {
mockLogger.debug('Skipping FINAL emit - job was replaced', {
streamId,
originalCreatedAt,
currentCreatedAt: currentJob?.createdAt,
});
await mockDecrementPendingRequest(userId);
return false;
}
mockGenerationJobManager.emitDone(streamId, finalEvent);
mockGenerationJobManager.completeJob(streamId);
await mockDecrementPendingRequest(userId);
return true;
};
it('should skip emitting when job was replaced', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const newCreatedAt = 2000;
const userId = 'user-123';
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: newCreatedAt,
});
const emitted = await emitFinalEventIfNotReplaced({
streamId,
originalCreatedAt,
finalEvent: { final: true },
userId,
});
expect(emitted).toBe(false);
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Skipping FINAL emit - job was replaced',
expect.objectContaining({
streamId,
originalCreatedAt,
currentCreatedAt: newCreatedAt,
}),
);
});
it('should emit when job was not replaced', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const userId = 'user-123';
const finalEvent = { final: true, conversation: { conversationId: streamId } };
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: originalCreatedAt,
});
const emitted = await emitFinalEventIfNotReplaced({
streamId,
originalCreatedAt,
finalEvent,
userId,
});
expect(emitted).toBe(true);
expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(streamId, finalEvent);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(streamId);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
});
});
describe('Response Message Saving Order', () => {
/**
* Tests that response messages are saved BEFORE final events are emitted
* This prevents race conditions where clients send follow-up messages
* before the response is in the database
*/
it('should save message before emitting final event', async () => {
const callOrder = [];
mockSaveMessage.mockImplementation(async () => {
callOrder.push('saveMessage');
});
mockGenerationJobManager.emitDone.mockImplementation(() => {
callOrder.push('emitDone');
});
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: 1000,
});
// Simulate the order of operations from request.js
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const response = { messageId: 'response-123' };
const userId = 'user-123';
// Step 1: Save message
await mockSaveMessage({}, { ...response, user: userId }, { context: 'test' });
// Step 2: Check for replacement
const currentJob = await mockGenerationJobManager.getJob(streamId);
const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
// Step 3: Emit if not replaced
if (!jobWasReplaced) {
mockGenerationJobManager.emitDone(streamId, { final: true });
}
expect(callOrder).toEqual(['saveMessage', 'emitDone']);
});
});
describe('Aborted Request Handling', () => {
it('should use unfinished: true instead of error: true for aborted requests', () => {
const response = { messageId: 'response-123', content: [] };
// The new format for aborted responses
const abortedResponse = { ...response, unfinished: true };
expect(abortedResponse.unfinished).toBe(true);
expect(abortedResponse.error).toBeUndefined();
});
it('should include unfinished flag in final event for aborted requests', () => {
const response = { messageId: 'response-123', content: [] };
// Old format (deprecated)
const _oldFinalEvent = {
final: true,
responseMessage: { ...response, error: true },
error: { message: 'Request was aborted' },
};
// New format (PR #11462)
const newFinalEvent = {
final: true,
responseMessage: { ...response, unfinished: true },
};
expect(newFinalEvent.responseMessage.unfinished).toBe(true);
expect(newFinalEvent.error).toBeUndefined();
expect(newFinalEvent.responseMessage.error).toBeUndefined();
});
});
});
/**
* HITL terminal-side-effect guards (PR #13942).
*
* Jobs are keyed by streamId == conversationId, so a NEW request REPLACES the running
* one on the same conversation. The replaced generation's tail (its pause attempt, its
* checkpoint prune, its resume catch-path terminal writes) must not clobber the live
* generation's state. Each guard re-reads the live job and compares createdAt against the
* generation's own captured identity before acting. These mirror the predicates in
* client.js (handleRunInterrupt / chatCompletion finally) and resume.js.
*/
describe('HITL Terminal-Side-Effect Guards', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('F22 — pause is skipped when the generation was replaced', () => {
// Mirrors client.js handleRunInterrupt pre-check, run BEFORE approvals.pause.
const shouldPause = async ({ jobCreatedAt, streamId }) => {
if (jobCreatedAt != null) {
const liveJob = await mockGenerationJobManager.getJob(streamId);
if (!liveJob || liveJob.createdAt !== jobCreatedAt) {
return false;
}
}
return true;
};
it('does not pause when a newer job replaced this one', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
});
it('does not pause when the job is already gone', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(null);
expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
});
it('pauses when this is still the live job', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
});
it('pauses without a lookup when identity is unknown (legacy job)', async () => {
expect(await shouldPause({ jobCreatedAt: null, streamId: 'c1' })).toBe(true);
expect(mockGenerationJobManager.getJob).not.toHaveBeenCalled();
});
});
// (Removed: F21 — the chatCompletion clean-path checkpoint prune + its job-replacement
// guard no longer exist. The lazy checkpointer never writes a clean-exit checkpoint, so
// there is nothing to prune after a non-paused turn; the pre-run prune (before
// processStream) clears any orphaned interrupt checkpoint instead. See
// checkpointer.ts LazyMongoSaver and client.js chatCompletion.)
describe('F24 — resume catch-path terminal writes are skipped when replaced', () => {
// Mirrors resume.js: stillLive gate around emitError/completeJob/deleteAgentCheckpoint.
const stillLive = async ({ streamId, jobCreatedAt }) => {
let live = true;
try {
const liveJob = await mockGenerationJobManager.getJob(streamId);
live = !!liveJob && liveJob.createdAt === jobCreatedAt;
} catch {
live = true; // read failed — fail open and run the terminal writes
}
return live;
};
it('runs terminal writes when this is still the live job', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
});
it('skips terminal writes when a newer job replaced this one', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(false);
});
it('fails open (runs terminal writes) when the liveness read throws', async () => {
mockGenerationJobManager.getJob.mockRejectedValue(new Error('store down'));
expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
});
});
describe('F23 — resumed turn sources files from the job, not the racy DB row', () => {
// Mirrors resume.js: prefer the body, then job.metadata.userMessage.files, then DB.
const resolveFiles = ({ bodyFiles, metaFiles, dbFiles }) => {
if (Array.isArray(bodyFiles) && bodyFiles.length > 0) {
return bodyFiles;
}
if (Array.isArray(metaFiles) && metaFiles.length > 0) {
return metaFiles;
}
return Array.isArray(dbFiles) && dbFiles.length > 0 ? dbFiles : undefined;
};
it('prefers job-metadata files over the DB row (no DB-save race)', () => {
expect(
resolveFiles({
bodyFiles: [],
metaFiles: [{ file_id: 'meta' }],
dbFiles: [{ file_id: 'db' }],
}),
).toEqual([{ file_id: 'meta' }]);
});
it('falls back to the DB row when the job has no persisted files (older job)', () => {
expect(
resolveFiles({ bodyFiles: [], metaFiles: undefined, dbFiles: [{ file_id: 'db' }] }),
).toEqual([{ file_id: 'db' }]);
});
it('keeps files already present on the resume body', () => {
expect(
resolveFiles({
bodyFiles: [{ file_id: 'body' }],
metaFiles: [{ file_id: 'meta' }],
dbFiles: [],
}),
).toEqual([{ file_id: 'body' }]);
});
});
});
/**
* Round-18 follow-ups to the guards above (Codex review 4594099963).
*/
describe('HITL Resume Fidelity Guards (round 18)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('G1 — resume re-checks ownership AGAIN right before terminal writes', () => {
// The start-of-finalize guard can go stale across saveMessage + title generation,
// so resume.js re-reads the live job immediately before emitDone/completeJob/prune.
// Same predicate as the catch-path (F24), applied at the success path's second point.
const stillLiveBeforeFinalize = async ({ streamId, jobCreatedAt }) => {
const liveJob = await mockGenerationJobManager.getJob(streamId);
return !!liveJob && liveJob.createdAt === jobCreatedAt;
};
it('runs terminal writes when still the live job at the second check', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
expect(await stillLiveBeforeFinalize({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
});
it('skips terminal writes when replaced DURING finalize (after the first check passed)', async () => {
// First check passed earlier with createdAt 1000; a new request replaced it to 2000
// while saveMessage + title generation awaited. The second check must catch it.
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
expect(await stillLiveBeforeFinalize({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(false);
});
});
describe('G2 — uploaded files are seeded into the AWAITED preliminary user message', () => {
// Mirrors getPreliminaryUserMessage: files from the request are persisted on the
// preliminary (awaited, pre-run) metadata so they land before any interrupt emits.
const buildPreliminaryUserMessage = ({ messageId, files }) => {
if (typeof messageId !== 'string' || messageId.length === 0) {
return null;
}
return {
messageId,
...(Array.isArray(files) && files.length > 0 && { files }),
};
};
it('includes files when the request carries them', () => {
const msg = buildPreliminaryUserMessage({ messageId: 'm1', files: [{ file_id: 'a' }] });
expect(msg.files).toEqual([{ file_id: 'a' }]);
});
it('omits files when none were uploaded (no empty array)', () => {
const msg = buildPreliminaryUserMessage({ messageId: 'm1', files: [] });
expect(msg).not.toHaveProperty('files');
});
});
describe('G3 — resume replays pre-pause discovered deferred tools', () => {
// Mirrors createRun's merge: discovered set is union(message-extracted, replayed),
// gated entirely on the agent actually having deferred tools.
const resolveDiscovered = ({ hasAnyDeferredTools, messageExtracted, replayed }) => {
const set = new Set();
if (hasAnyDeferredTools) {
for (const n of messageExtracted ?? []) {
set.add(n);
}
for (const n of replayed ?? []) {
set.add(n);
}
}
return set;
};
it('replays captured names on resume (messages empty) so the paused tool is present', () => {
const set = resolveDiscovered({
hasAnyDeferredTools: true,
messageExtracted: [],
replayed: ['deep_tool'],
});
expect(set.has('deep_tool')).toBe(true);
});
it('unions replayed names with message-extracted names', () => {
const set = resolveDiscovered({
hasAnyDeferredTools: true,
messageExtracted: ['from_history'],
replayed: ['deep_tool'],
});
expect([...set].sort()).toEqual(['deep_tool', 'from_history']);
});
it('is inert when the agent has no deferred tools', () => {
const set = resolveDiscovered({
hasAnyDeferredTools: false,
messageExtracted: ['x'],
replayed: ['deep_tool'],
});
expect(set.size).toBe(0);
});
});
describe("H3 — resume replays the paused turn's model parameters (ephemeral agents)", () => {
// Mirrors restoreResumeContext: spread persisted model_parameters back onto the body,
// excluding `model` (replayed via the fingerprinted RESUME_CONTEXT_KEYS path).
const replayModelParameters = (body, resumeContext) => {
const params = resumeContext?.model_parameters;
if (params && typeof params === 'object') {
const { model: _model, ...rest } = params;
Object.assign(body, rest);
}
return body;
};
it('restores non-default params (temperature, max tokens) onto the resume body', () => {
const body = { conversationId: 'c1', endpoint: 'agents' };
replayModelParameters(body, {
model_parameters: { model: 'gpt-4o', temperature: 0.2, max_tokens: 1024 },
});
expect(body).toMatchObject({ temperature: 0.2, max_tokens: 1024 });
});
it('does NOT overwrite model (kept consistent with the resume fingerprint)', () => {
const body = { model: 'pinned-model' };
replayModelParameters(body, { model_parameters: { model: 'other-model', temperature: 0.9 } });
expect(body.model).toBe('pinned-model');
});
it('overwrites a client-supplied param with the captured authoritative value', () => {
const body = { temperature: 1.0 }; // crafted/stale client value
replayModelParameters(body, { model_parameters: { temperature: 0.2 } });
expect(body.temperature).toBe(0.2);
});
it('is a no-op when nothing was captured', () => {
const body = { conversationId: 'c1' };
replayModelParameters(body, {});
expect(body).toEqual({ conversationId: 'c1' });
});
});
describe('J2 — pause unfinished-save is skipped once a fast resume took over', () => {
// Mirrors request.js: only mark the paused row unfinished while the job is STILL paused
// on THIS generation's action. A claim transitions it out of requires_action and a
// replacement bumps createdAt — either means a /resume now owns the row, so marking it
// unfinished would clobber the resumed turn's completed content. Fail open on read error.
const shouldMarkUnfinished = async ({ jobCreatedAt, streamId }) => {
let stillPaused = true;
try {
const liveJob = await mockGenerationJobManager.getJob(streamId);
stillPaused =
!!liveJob &&
liveJob.status === 'requires_action' &&
(jobCreatedAt == null || liveJob.createdAt === jobCreatedAt);
} catch {
stillPaused = true;
}
return stillPaused;
};
it('marks unfinished while still paused on this generation', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({
status: 'requires_action',
createdAt: 1000,
});
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
});
it('skips the unfinished-save once a fast resume claimed it (no longer requires_action)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({ status: 'running', createdAt: 1000 });
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
});
it('skips the unfinished-save when a newer request replaced the job', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({
status: 'requires_action',
createdAt: 2000,
});
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
});
it('fails open (marks unfinished) when the liveness read throws', async () => {
mockGenerationJobManager.getJob.mockRejectedValue(new Error('store down'));
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
});
});
});
@@ -0,0 +1,195 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), debug: jest.fn() },
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
emitEvent: jest.fn(),
createToolExecuteHandler: jest.fn(),
markSummarizationUsage: (usage) => usage,
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
runPreviewFinalize: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
const { ModelEndHandler } = require('../callbacks');
const buildGraph = () => ({
getAgentContext: () => ({
provider: 'vertexai',
clientOptions: { model: 'gemini-3.1-flash-lite-preview' },
}),
});
describe('ModelEndHandler — Vertex thoughtSignature capture (issue #13006 follow-up)', () => {
it('maps non-empty signatures onto tool_call_ids in order', async () => {
const collectedUsage = [];
const collectedThoughtSignatures = {};
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
await handler.handle(
'on_chat_model_end',
{
output: {
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
tool_calls: [
{ id: 'tc_a', name: 'a', args: {} },
{ id: 'tc_b', name: 'b', args: {} },
],
additional_kwargs: { signatures: ['SIG_A', '', 'SIG_B'] },
},
},
{ ls_model_name: 'gemini-3.1-flash-lite-preview', user_id: 'u1' },
buildGraph(),
);
expect(collectedThoughtSignatures).toEqual({ tc_a: 'SIG_A', tc_b: 'SIG_B' });
expect(collectedUsage).toHaveLength(1);
});
it('accumulates per-id across multiple model_end events (multi-step tool turn)', async () => {
const collectedUsage = [];
const collectedThoughtSignatures = {};
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
await handler.handle(
'on_chat_model_end',
{
output: {
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
tool_calls: [{ id: 'tc_step1', name: 'a', args: {} }],
additional_kwargs: { signatures: ['SIG_step1'] },
},
},
{ ls_model_name: 'g', user_id: 'u' },
buildGraph(),
);
await handler.handle(
'on_chat_model_end',
{
output: {
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
tool_calls: [{ id: 'tc_step2', name: 'b', args: {} }],
additional_kwargs: { signatures: ['SIG_step2'] },
},
},
{ ls_model_name: 'g', user_id: 'u' },
buildGraph(),
);
expect(collectedThoughtSignatures).toEqual({
tc_step1: 'SIG_step1',
tc_step2: 'SIG_step2',
});
});
it('is a no-op for signatures when collectedThoughtSignatures is null', async () => {
const collectedUsage = [];
const handler = new ModelEndHandler(collectedUsage, null);
await handler.handle(
'on_chat_model_end',
{
output: {
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
tool_calls: [{ id: 'tc1', name: 'a', args: {} }],
additional_kwargs: { signatures: ['SIG'] },
},
},
{ ls_model_name: 'g', user_id: 'u' },
buildGraph(),
);
expect(collectedUsage).toHaveLength(1);
});
it('does not store anything when signatures field is missing (non-Vertex providers)', async () => {
const collectedUsage = [];
const collectedThoughtSignatures = {};
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
await handler.handle(
'on_chat_model_end',
{
output: {
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
tool_calls: [{ id: 'tc1', name: 'a', args: {} }],
additional_kwargs: {},
},
},
{ ls_model_name: 'gpt-4', user_id: 'u' },
buildGraph(),
);
expect(collectedThoughtSignatures).toEqual({});
});
it('does not store anything when tool_calls is missing', async () => {
const collectedUsage = [];
const collectedThoughtSignatures = {};
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
await handler.handle(
'on_chat_model_end',
{
output: {
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
additional_kwargs: { signatures: ['SIG_orphan'] },
},
},
{ ls_model_name: 'g', user_id: 'u' },
buildGraph(),
);
expect(collectedThoughtSignatures).toEqual({});
});
it('tags the producing agent on collected + emitted usage for per-endpoint pricing', async () => {
const collectedUsage = [];
const emitUsage = jest.fn();
const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
const graph = {
getAgentContext: () => ({
provider: 'openai',
agentId: 'agent_sub',
clientOptions: { model: 'gpt-4' },
}),
};
await handler.handle(
'on_chat_model_end',
{ output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
{ ls_model_name: 'gpt-4', run_id: 'r1', user_id: 'u1' },
graph,
);
expect(collectedUsage[0].agentId).toBe('agent_sub');
expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'agent_sub' }));
});
it('leaves usage untagged when the graph context has no agentId (single-endpoint)', async () => {
const collectedUsage = [];
const emitUsage = jest.fn();
const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
await handler.handle(
'on_chat_model_end',
{ output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
{ ls_model_name: 'gemini-3.1-flash-lite-preview', run_id: 'r1', user_id: 'u1' },
buildGraph(),
);
expect(collectedUsage[0].agentId).toBeUndefined();
expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: undefined }));
});
it('throws when collectedUsage is not an array (existing contract)', () => {
expect(() => new ModelEndHandler(null)).toThrow('collectedUsage must be an array');
});
});
@@ -0,0 +1,511 @@
/**
* Unit tests for OpenAI-compatible API controller
* Tests that recordCollectedUsage is called correctly for token spending
*/
const mockProcessStream = jest.fn().mockResolvedValue(undefined);
const mockSpendTokens = jest.fn().mockResolvedValue({});
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
const primed = {};
for (const skill of alwaysApplySkillPrimes ?? []) {
primed[skill.name] = skill._id.toString();
}
for (const skill of manualSkillPrimes ?? []) {
primed[skill.name] = skill._id.toString();
}
return Object.keys(primed).length > 0 ? primed : undefined;
});
const mockEnrichWithSkillConfigurable = jest.fn((result) => result);
const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({
agent,
toolRegistry: config.toolRegistry,
userMCPAuthMap: config.userMCPAuthMap,
tool_resources: config.tool_resources,
actionsEnabled: config.actionsEnabled,
accessibleSkillIds: config.accessibleSkillIds,
activeSkillNames: config.activeSkillNames,
codeEnvAvailable: config.codeEnvAvailable,
skillAuthoringAvailable: config.skillAuthoringAvailable,
fileAuthoringToolNames: config.fileAuthoringToolNames,
skillPrimedIdsByName:
mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {},
}));
const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) =>
mockEnrichWithSkillConfigurable({
result,
context: {
req,
accessibleSkillIds: ctx.accessibleSkillIds,
codeEnvAvailable: ctx.codeEnvAvailable === true,
skillPrimedIdsByName: ctx.skillPrimedIdsByName,
activeSkillNames: ctx.activeSkillNames,
skillAuthoringAvailable: ctx.skillAuthoringAvailable === true,
fileAuthoringToolNames: ctx.fileAuthoringToolNames,
},
}),
);
const mockCanAuthorSkillFiles = jest.fn(
({ scopedEditableSkillIds = [], skillCreateAllowed }) =>
scopedEditableSkillIds.length > 0 || skillCreateAllowed === true,
);
const mockGetSkillToolDeps = jest.fn(() => ({}));
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid-123'),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
ToolEndHandler: jest.fn(),
formatAgentMessages: jest.fn().mockReturnValue({
messages: [],
indexTokenCountMap: {},
}),
}));
jest.mock('@librechat/api', () => ({
writeSSE: jest.fn(),
createRun: jest.fn().mockResolvedValue({
processStream: mockProcessStream,
}),
createChunk: jest.fn().mockReturnValue({}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
resolveAgentScopedSkillIds: jest
.fn()
.mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds),
loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
sendFinalChunk: jest.fn(),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
validateRequest: jest
.fn()
.mockReturnValue({ request: { model: 'agent-123', messages: [], stream: false } }),
initializeAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
model: 'gpt-4',
model_parameters: {},
toolRegistry: {},
edges: [],
}),
getBalanceConfig: mockGetBalanceConfig,
createErrorResponse: jest.fn(),
getTransactionsConfig: mockGetTransactionsConfig,
recordCollectedUsage: mockRecordCollectedUsage,
createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()),
extractManualSkills: jest.fn().mockReturnValue(undefined),
injectSkillPrimes: jest.fn().mockReturnValue({
initialMessages: [],
indexTokenCountMap: {},
inserted: 0,
insertIdx: -1,
alwaysApplyDropped: 0,
alwaysApplyDedupedFromManual: 0,
}),
buildNonStreamingResponse: jest.fn().mockReturnValue({ id: 'resp-123' }),
createOpenAIStreamTracker: jest.fn().mockReturnValue({
addText: jest.fn(),
addReasoning: jest.fn(),
toolCalls: new Map(),
usage: { promptTokens: 0, completionTokens: 0, reasoningTokens: 0 },
}),
createOpenAIContentAggregator: jest.fn().mockReturnValue({
addText: jest.fn(),
addReasoning: jest.fn(),
getText: jest.fn().mockReturnValue(''),
getReasoning: jest.fn().mockReturnValue(''),
toolCalls: new Map(),
usage: { promptTokens: 100, completionTokens: 50, reasoningTokens: 0 },
}),
resolveRecursionLimit: jest.fn().mockReturnValue(50),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
discoverConnectedAgents: jest.fn().mockResolvedValue({
agentConfigs: new Map(),
edges: [],
skippedAgentIds: new Set(),
userMCPAuthMap: undefined,
}),
}));
jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn(),
}));
jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
getSkillToolDeps: mockGetSkillToolDeps,
getSkillDbMethods: jest.fn(() => ({})),
canAuthorSkillFiles: mockCanAuthorSkillFiles,
withDeploymentSkillIds: jest.fn((ids = []) => ids),
enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
buildAgentToolContext: mockBuildAgentToolContext,
enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext,
}));
jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));
jest.mock('~/server/services/ToolService', () => ({
loadAgentTools: jest.fn().mockResolvedValue([]),
loadToolsForExecution: jest.fn().mockResolvedValue([]),
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
buildSummarizationHandlers: jest.fn().mockReturnValue({}),
markSummarizationUsage: jest.fn().mockImplementation((usage) => usage),
agentLogHandlerObj: { handle: jest.fn() },
}));
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
checkPermission: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn().mockReturnValue({}),
}));
jest.mock('~/server/services/Files/Code/crud', () => ({
batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
getSessionInfo: jest.fn().mockResolvedValue(null),
checkIfActive: jest.fn().mockReturnValue(false),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }),
getFiles: jest.fn(),
getUserKey: jest.fn(),
getMessages: jest.fn(),
updateFilesUsage: jest.fn(),
getUserKeyValues: jest.fn(),
getUserCodeFiles: jest.fn(),
getToolFilesByIds: jest.fn(),
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
getConvoFiles: jest.fn().mockResolvedValue([]),
getConvo: jest.fn().mockResolvedValue(null),
}));
describe('OpenAIChatCompletionController', () => {
let OpenAIChatCompletionController;
let req, res;
beforeEach(() => {
jest.clearAllMocks();
const controller = require('../openai');
OpenAIChatCompletionController = controller.OpenAIChatCompletionController;
req = {
body: {
model: 'agent-123',
messages: [{ role: 'user', content: 'Hello' }],
stream: false,
},
user: { id: 'user-123' },
config: {
endpoints: {
agents: { allowedProviders: ['openAI'] },
},
},
on: jest.fn(),
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
flushHeaders: jest.fn(),
end: jest.fn(),
write: jest.fn(),
};
});
describe('conversation ownership validation', () => {
it('should skip ownership check when conversation_id is not provided', async () => {
const { getConvo } = require('~/models');
await OpenAIChatCompletionController(req, res);
expect(getConvo).not.toHaveBeenCalled();
});
it('should return 400 when conversation_id is not a string', async () => {
const { validateRequest } = require('@librechat/api');
validateRequest.mockReturnValueOnce({
request: { model: 'agent-123', messages: [], stream: false, conversation_id: { $gt: '' } },
});
await OpenAIChatCompletionController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('should return 404 when conversation is not owned by user', async () => {
const { validateRequest } = require('@librechat/api');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
messages: [],
stream: false,
conversation_id: 'convo-abc',
},
});
getConvo.mockResolvedValueOnce(null);
await OpenAIChatCompletionController(req, res);
expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
expect(res.status).toHaveBeenCalledWith(404);
});
it('should proceed when conversation is owned by user', async () => {
const { validateRequest } = require('@librechat/api');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
messages: [],
stream: false,
conversation_id: 'convo-abc',
},
});
getConvo.mockResolvedValueOnce({ conversationId: 'convo-abc', user: 'user-123' });
await OpenAIChatCompletionController(req, res);
expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
expect(res.status).not.toHaveBeenCalledWith(404);
});
it('should return 500 when getConvo throws a DB error', async () => {
const { validateRequest } = require('@librechat/api');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
messages: [],
stream: false,
conversation_id: 'convo-abc',
},
});
getConvo.mockRejectedValueOnce(new Error('DB connection failed'));
await OpenAIChatCompletionController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
});
});
describe('token usage recording', () => {
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
await OpenAIChatCompletionController(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
conversationId: expect.any(String),
collectedUsage: expect.any(Array),
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
}),
);
});
it('should pass balance and transactions config to recordCollectedUsage', async () => {
mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 1000 });
mockGetTransactionsConfig.mockReturnValue({ enabled: true, rateLimit: 100 });
await OpenAIChatCompletionController(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
balance: { enabled: true, startBalance: 1000 },
transactions: { enabled: true, rateLimit: 100 },
}),
);
});
it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
await OpenAIChatCompletionController(req, res);
const [deps] = mockRecordCollectedUsage.mock.calls[0];
expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
expect(deps).toHaveProperty('pricing');
expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
expect(deps).toHaveProperty('bulkWriteOps');
expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
});
it('should include model from primaryConfig in recordCollectedUsage params', async () => {
await OpenAIChatCompletionController(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
model: 'gpt-4',
}),
);
});
});
describe('recursionLimit resolution', () => {
it('should pass resolveRecursionLimit result to processStream config', async () => {
const { resolveRecursionLimit } = require('@librechat/api');
resolveRecursionLimit.mockReturnValueOnce(75);
await OpenAIChatCompletionController(req, res);
expect(mockProcessStream).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ recursionLimit: 75 }),
expect.anything(),
);
});
it('should call resolveRecursionLimit with agentsEConfig and agent', async () => {
const { resolveRecursionLimit } = require('@librechat/api');
const { getAgent } = require('~/models');
const mockAgent = { id: 'agent-123', name: 'Test', recursion_limit: 200 };
getAgent.mockResolvedValueOnce(mockAgent);
req.config = {
endpoints: {
agents: { recursionLimit: 100, maxRecursionLimit: 150, allowedProviders: [] },
},
};
await OpenAIChatCompletionController(req, res);
expect(resolveRecursionLimit).toHaveBeenCalledWith(req.config.endpoints.agents, mockAgent);
});
});
describe('sub-agent skill priming', () => {
it('passes the sub-agent primed skill IDs into tool execution', async () => {
const {
initializeAgent,
discoverConnectedAgents,
createToolExecuteHandler,
} = require('@librechat/api');
const { loadToolsForExecution } = require('~/server/services/ToolService');
const subAgent = { id: 'agent-sub', name: 'Sub Agent' };
const subConfig = {
id: 'agent-sub',
model: 'gpt-4',
model_parameters: {},
toolRegistry: new Map(),
userMCPAuthMap: { sub: { token: 'sub-token' } },
tool_resources: { code_interpreter: { file_ids: ['sub-file'] } },
actionsEnabled: true,
accessibleSkillIds: ['sub-skill-id'],
activeSkillNames: ['sub-hidden-skill'],
codeEnvAvailable: true,
skillAuthoringAvailable: true,
fileAuthoringToolNames: ['create_file', 'edit_file'],
manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }],
alwaysApplySkillPrimes: [
{ name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } },
],
};
initializeAgent.mockResolvedValueOnce({
id: 'agent-123',
model: 'gpt-4',
model_parameters: {},
toolRegistry: new Map(),
edges: [{ source: 'agent-123', target: 'agent-sub' }],
accessibleSkillIds: ['primary-skill-id'],
activeSkillNames: ['primary-skill'],
codeEnvAvailable: false,
skillAuthoringAvailable: false,
fileAuthoringToolNames: [],
manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }],
});
discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => {
deps.onAgentInitialized('agent-sub', subAgent, subConfig);
return {
agentConfigs: new Map([['agent-sub', subConfig]]),
edges: [],
skippedAgentIds: new Set(),
userMCPAuthMap: undefined,
};
});
await OpenAIChatCompletionController(req, res);
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
await toolExecuteOptions.loadTools(['read_file'], 'agent-sub');
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({
agent: subAgent,
toolRegistry: subConfig.toolRegistry,
userMCPAuthMap: subConfig.userMCPAuthMap,
tool_resources: subConfig.tool_resources,
actionsEnabled: true,
}),
);
expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({
result: expect.anything(),
context: {
req,
accessibleSkillIds: ['sub-skill-id'],
codeEnvAvailable: true,
skillPrimedIdsByName: {
'sub-always-skill': 'sub-always-id',
'sub-hidden-skill': 'sub-manual-id',
},
activeSkillNames: ['sub-hidden-skill'],
skillAuthoringAvailable: true,
fileAuthoringToolNames: ['create_file', 'edit_file'],
},
});
});
});
});
@@ -0,0 +1,621 @@
const { EventEmitter } = require('events');
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
const mockGenerationJobManager = {
createJob: jest.fn(),
emitError: jest.fn(),
completeJob: jest.fn(),
getResumeState: jest.fn(),
updateMetadata: jest.fn(),
};
const mockCheckAndIncrementPendingRequest = jest.fn();
const mockDecrementPendingRequest = jest.fn();
const mockFilterPersistableAbortContent = jest.fn((content) =>
content.filter((part) => part?.type !== 'tool_call'),
);
const mockGetConvo = jest.fn();
const mockGetMessages = jest.fn();
const mockSaveMessage = jest.fn();
let mockMCPContexts = new WeakMap();
const mockCreateMCPRequestContext = jest.fn(() => ({
connections: new Map(),
pending: new Map(),
cleanupStarted: false,
cleanupOnResponse: false,
responseCleanupAttached: false,
}));
const mockGetMCPRequestContext = jest.fn((req) => {
if (!req) {
return undefined;
}
let context = mockMCPContexts.get(req);
if (!context) {
context = mockCreateMCPRequestContext();
mockMCPContexts.set(req, context);
}
return context.cleanupStarted ? undefined : context;
});
const mockCleanupMCPRequestContext = jest.fn(async (context) => {
if (!context || context.cleanupStarted) {
return;
}
context.cleanupStarted = true;
const connections = new Set(context.connections.values());
const settled = await Promise.allSettled(context.pending.values());
for (const result of settled) {
if (result.status === 'fulfilled' && result.value) {
connections.add(result.value);
}
}
await Promise.allSettled(Array.from(connections).map((connection) => connection.disconnect?.()));
context.connections.clear();
context.pending.clear();
});
const mockCleanupMCPRequestContextForReq = jest.fn(async (req) => {
const context = mockMCPContexts.get(req);
if (!context) {
return;
}
try {
await mockCleanupMCPRequestContext(context);
} finally {
mockMCPContexts.delete(req);
}
});
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
getViolationInfo: jest.fn(),
buildMessageFiles: jest.fn(() => []),
resolveTitleTiming: jest.fn(() => 'immediate'),
GenerationJobManager: mockGenerationJobManager,
getReferencedQuotes: jest.fn((quotes) => {
if (!Array.isArray(quotes)) {
return null;
}
const normalized = quotes
.filter((quote) => typeof quote === 'string' && quote.trim().length > 0)
.map((quote) => quote.trim());
return normalized.length > 0 ? normalized : null;
}),
cleanupMCPRequestContext: (...args) => mockCleanupMCPRequestContext(...args),
createMCPRequestContext: (...args) => mockCreateMCPRequestContext(...args),
getMCPRequestContext: (...args) => mockGetMCPRequestContext(...args),
filterPersistableAbortContent: (...args) => mockFilterPersistableAbortContent(...args),
cleanupMCPRequestContextForReq: (...args) => mockCleanupMCPRequestContextForReq(...args),
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
sanitizeMessageForTransmit: jest.fn((message) => message),
checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
isUnpersistedPreliminaryParent: async ({
userId,
conversationId,
parentMessageId,
getMessages,
}) => {
if (typeof parentMessageId !== 'string' || !parentMessageId.endsWith('_')) {
return false;
}
const filter = { user: userId, messageId: parentMessageId };
if (conversationId && conversationId !== 'new') {
filter.conversationId = conversationId;
}
const messages = await getMessages(filter, '_id');
return messages.length === 0;
},
}));
jest.mock('~/server/cleanup', () => ({
disposeClient: jest.fn(),
clientRegistry: null,
requestDataMap: {
set: jest.fn(),
},
}));
jest.mock('~/server/middleware', () => ({
handleAbortError: jest.fn(() => Promise.resolve()),
}));
jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));
jest.mock('~/models', () => ({
saveMessage: (...args) => mockSaveMessage(...args),
getMessages: (...args) => mockGetMessages(...args),
getConvo: (...args) => mockGetConvo(...args),
}));
const AgentController = require('../request');
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
function createResumableResponse() {
const res = new EventEmitter();
res.headersSent = false;
res.writableEnded = false;
res.finished = false;
res.destroyed = false;
res.json = jest.fn(() => {
res.headersSent = true;
res.writableEnded = true;
res.finished = true;
res.emit('finish');
return res;
});
res.status = jest.fn(() => res);
return res;
}
function nextTick() {
return new Promise((resolve) => setImmediate(resolve));
}
describe('ResumableAgentController resume metadata', () => {
beforeEach(() => {
jest.clearAllMocks();
mockMCPContexts = new WeakMap();
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
mockDecrementPendingRequest.mockResolvedValue(undefined);
mockGetConvo.mockResolvedValue({ createdAt: '2026-06-07T00:00:00.000Z' });
mockGetMessages.mockResolvedValue([]);
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt: 1000,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: { on: jest.fn() },
});
mockGenerationJobManager.getResumeState.mockResolvedValue(null);
mockGenerationJobManager.updateMetadata.mockResolvedValue(undefined);
mockGenerationJobManager.emitError.mockResolvedValue(undefined);
mockSaveMessage.mockResolvedValue({});
});
it('rejects an underscore-suffixed parent that is not persisted', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn();
const req = {
user: { id: 'user-123' },
body: {
text: 'Follow up too early.',
messageId: 'follow-up-user',
parentMessageId: 'pending-response_',
conversationId,
endpointOption: {
endpoint: 'agents',
modelOptions: { model: 'gpt-3.5-turbo' },
},
},
config: {},
};
const res = {
json: jest.fn(),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGetMessages).toHaveBeenCalledWith(
{ user: 'user-123', messageId: 'pending-response_', conversationId },
'_id',
);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('selected parent response is still being saved'),
}),
);
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled();
expect(initializeClient).not.toHaveBeenCalled();
});
it('allows an underscore-suffixed parent when it is already persisted', async () => {
const conversationId = 'conversation-123';
mockGetMessages.mockResolvedValue([{ _id: 'persisted-parent' }]);
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Follow up to persisted underscore id.',
messageId: 'follow-up-user',
parentMessageId: 'persisted-response_',
conversationId,
endpointOption: {
endpoint: 'agents',
modelOptions: { model: 'gpt-3.5-turbo' },
},
},
config: {},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGetMessages).toHaveBeenCalledWith(
{ user: 'user-123', messageId: 'persisted-response_', conversationId },
'_id',
);
expect(res.status).not.toHaveBeenCalledWith(409);
expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledWith('user-123');
expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
conversationId,
'user-123',
conversationId,
);
});
it('stores the in-flight turn before MCP initialization can emit OAuth', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Check Google Workspace availability.',
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
endpointOption: {
endpoint: 'agents',
iconURL: 'https://example.com/spec-icon.png',
modelOptions: { model: 'gpt-3.5-turbo' },
},
},
config: {},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
conversationId,
expect.objectContaining({
conversationId,
endpoint: 'agents',
iconURL: 'https://example.com/spec-icon.png',
model: 'gpt-3.5-turbo',
responseMessageId: 'follow-up-user_',
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
text: 'Check Google Workspace availability.',
},
}),
);
expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
initializeClient.mock.invocationCallOrder[0],
);
});
it('keeps request-scoped MCP connections until resumable initialization finishes', async () => {
const conversationId = 'conversation-123';
const disconnect = jest.fn().mockResolvedValue(undefined);
const initializeClient = jest.fn(async ({ req, res }) => {
const context = getMCPRequestContext(req, res);
context.connections.set('mcp-server', { disconnect });
await nextTick();
expect(disconnect).not.toHaveBeenCalled();
throw new Error('stop after request-scoped MCP connection');
});
const req = {
user: { id: 'user-123' },
body: {
text: 'Use a BODY-scoped MCP server.',
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
endpointOption: {
endpoint: 'agents',
modelOptions: { model: 'gpt-4.1' },
},
},
config: {},
};
const res = createResumableResponse();
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(res.json).toHaveBeenCalledWith({
streamId: conversationId,
conversationId,
status: 'started',
});
expect(disconnect).toHaveBeenCalledTimes(1);
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
mockDecrementPendingRequest.mock.invocationCallOrder[0],
);
});
it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Use the resume spec.',
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
endpointOption: {
endpoint: 'agents',
spec: 'agent-spec',
agent_id: 'agent_resume_spec',
model_parameters: { model: 'gpt-4.1' },
},
},
config: {
modelSpecs: {
list: [
{
name: 'agent-spec',
preset: {
endpoint: 'openAI',
iconURL: 'https://example.com/preset-icon.png',
},
},
],
},
},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
conversationId,
expect.objectContaining({
iconURL: 'https://example.com/preset-icon.png',
model: 'agent_resume_spec',
}),
);
});
it('falls back to the model spec preset endpoint when no icon URL is configured', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Use the endpoint icon.',
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
endpointOption: {
endpoint: 'agents',
spec: 'endpoint-icon-spec',
model_parameters: { model: 'gpt-4.1' },
},
},
config: {
modelSpecs: {
list: [
{
name: 'endpoint-icon-spec',
preset: {
endpoint: 'anthropic',
},
},
],
},
},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
conversationId,
expect.objectContaining({
iconURL: 'anthropic',
model: 'gpt-4.1',
}),
);
});
it('filters OAuth prompts before saving partial responses on disconnect', async () => {
const conversationId = 'conversation-123';
let allSubscribersLeftHandler;
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt: 1000,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: {
on: jest.fn((event, handler) => {
if (event === 'allSubscribersLeft') {
allSubscribersLeftHandler = handler;
}
}),
},
});
mockGenerationJobManager.getResumeState.mockResolvedValue({
conversationId,
responseMessageId: 'response-message',
iconURL: 'https://example.com/spec-icon.png',
model: 'gpt-4.1',
userMessage: {
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
text: 'Use Google Workspace',
},
});
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Use Google Workspace',
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
endpointOption: {
endpoint: 'agents',
iconURL: 'https://example.com/fallback-icon.png',
modelOptions: { model: 'gpt-3.5-turbo' },
},
},
config: {},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
const oauthPart = {
type: 'tool_call',
tool_call: {
name: 'oauth_mcp_Google-Workspace',
auth: 'https://auth.example.com/oauth',
},
};
const textPart = { type: 'text', text: 'Partial response...' };
await allSubscribersLeftHandler([oauthPart, textPart]);
expect(mockFilterPersistableAbortContent).toHaveBeenCalledWith([oauthPart, textPart]);
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-123' }),
expect.objectContaining({
content: [textPart],
iconURL: 'https://example.com/spec-icon.png',
model: 'gpt-4.1',
messageId: 'response-message',
parentMessageId: 'user-message',
}),
expect.any(Object),
);
});
it('uses model spec and agent fallbacks when saving partial responses on disconnect', async () => {
const conversationId = 'conversation-123';
let allSubscribersLeftHandler;
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt: 1000,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: {
on: jest.fn((event, handler) => {
if (event === 'allSubscribersLeft') {
allSubscribersLeftHandler = handler;
}
}),
},
});
mockGenerationJobManager.getResumeState.mockResolvedValue({
conversationId,
responseMessageId: 'response-message',
userMessage: {
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
text: 'Use fallback metadata',
},
});
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Use fallback metadata',
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
endpointOption: {
endpoint: 'agents',
spec: 'agent-spec',
agent_id: 'agent_resume_spec',
model_parameters: { model: 'gpt-4.1' },
},
},
config: {
modelSpecs: {
list: [
{
name: 'agent-spec',
preset: {
endpoint: 'openAI',
iconURL: 'https://example.com/preset-icon.png',
},
},
],
},
},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
const textPart = { type: 'text', text: 'Partial response...' };
await allSubscribersLeftHandler([textPart]);
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-123' }),
expect.objectContaining({
content: [textPart],
iconURL: 'https://example.com/preset-icon.png',
model: 'agent_resume_spec',
messageId: 'response-message',
parentMessageId: 'user-message',
}),
expect.any(Object),
);
});
});
@@ -0,0 +1,711 @@
/**
* Unit tests for Open Responses API controller
* Tests that recordCollectedUsage is called correctly for token spending
*/
const mockSpendTokens = jest.fn().mockResolvedValue({});
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
const primed = {};
for (const skill of alwaysApplySkillPrimes ?? []) {
primed[skill.name] = skill._id.toString();
}
for (const skill of manualSkillPrimes ?? []) {
primed[skill.name] = skill._id.toString();
}
return Object.keys(primed).length > 0 ? primed : undefined;
});
const mockEnrichWithSkillConfigurable = jest.fn((result) => result);
const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({
agent,
toolRegistry: config.toolRegistry,
userMCPAuthMap: config.userMCPAuthMap,
tool_resources: config.tool_resources,
actionsEnabled: config.actionsEnabled,
accessibleSkillIds: config.accessibleSkillIds,
activeSkillNames: config.activeSkillNames,
codeEnvAvailable: config.codeEnvAvailable,
skillAuthoringAvailable: config.skillAuthoringAvailable,
fileAuthoringToolNames: config.fileAuthoringToolNames,
skillPrimedIdsByName:
mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {},
}));
const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) =>
mockEnrichWithSkillConfigurable({
result,
context: {
req,
accessibleSkillIds: ctx.accessibleSkillIds,
codeEnvAvailable: ctx.codeEnvAvailable === true,
skillPrimedIdsByName: ctx.skillPrimedIdsByName,
activeSkillNames: ctx.activeSkillNames,
skillAuthoringAvailable: ctx.skillAuthoringAvailable === true,
fileAuthoringToolNames: ctx.fileAuthoringToolNames,
},
}),
);
const mockCanAuthorSkillFiles = jest.fn(
({ scopedEditableSkillIds = [], skillCreateAllowed }) =>
scopedEditableSkillIds.length > 0 || skillCreateAllowed === true,
);
const mockGetSkillToolDeps = jest.fn(() => ({}));
const mockBuildAgentScopedContext = jest.fn().mockResolvedValue(new Map());
const mockBuildAgentContextAttachmentsByAgentId = jest.fn().mockReturnValue(new Map());
const mockApplyContextToAgent = jest.fn().mockResolvedValue(undefined);
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid-123'),
}));
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mock-uuid-456'),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
ToolEndHandler: jest.fn(),
formatAgentMessages: jest.fn().mockReturnValue({
messages: [],
indexTokenCountMap: {},
}),
}));
jest.mock('@librechat/api', () => ({
createRun: jest.fn().mockResolvedValue({
processStream: jest.fn().mockResolvedValue(undefined),
}),
applyContextToAgent: (...args) => mockApplyContextToAgent(...args),
buildToolSet: jest.fn().mockReturnValue(new Set()),
buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
buildAgentContextAttachmentsByAgentId: (...args) =>
mockBuildAgentContextAttachmentsByAgentId(...args),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
resolveAgentScopedSkillIds: jest
.fn()
.mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds),
loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
initializeAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
model: 'claude-3',
model_parameters: {},
toolRegistry: {},
edges: [],
agentContextAttachments: [],
}),
discoverConnectedAgents: jest.fn().mockImplementation(async (computedParams, deps) => {
// Call onAgentInitialized for each agent config if provided by the mock setup
if (deps?.onAgentInitialized && mockGlobalDiscoveredAgentConfigs) {
for (const [agentId, config] of mockGlobalDiscoveredAgentConfigs) {
deps.onAgentInitialized(agentId, config, config);
}
}
return {
agentConfigs: mockGlobalDiscoveredAgentConfigs ?? new Map(),
edges: [],
skippedAgentIds: new Set(),
userMCPAuthMap: undefined,
};
}),
getBalanceConfig: mockGetBalanceConfig,
getTransactionsConfig: mockGetTransactionsConfig,
recordCollectedUsage: mockRecordCollectedUsage,
createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()),
extractManualSkills: jest.fn().mockReturnValue(undefined),
injectSkillPrimes: jest.fn().mockReturnValue({
initialMessages: [],
indexTokenCountMap: {},
inserted: 0,
insertIdx: -1,
alwaysApplyDropped: 0,
alwaysApplyDedupedFromManual: 0,
}),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
// Responses API
writeDone: jest.fn(),
buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }),
generateResponseId: jest.fn().mockReturnValue('resp_mock-123'),
isValidationFailure: jest.fn().mockReturnValue(false),
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
emitResponseCreated: jest.fn(),
createResponseContext: jest.fn().mockReturnValue({ responseId: 'resp_123' }),
createResponseTracker: jest.fn().mockReturnValue({
usage: { promptTokens: 100, completionTokens: 50 },
}),
setupStreamingResponse: jest.fn(),
emitResponseInProgress: jest.fn(),
convertInputToMessages: jest.fn().mockReturnValue([]),
validateResponseRequest: jest.fn().mockReturnValue({
request: { model: 'agent-123', input: 'Hello', stream: false },
}),
buildAggregatedResponse: jest.fn().mockReturnValue({
id: 'resp_123',
status: 'completed',
output: [],
usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 },
}),
createResponseAggregator: jest.fn().mockReturnValue({
usage: { promptTokens: 100, completionTokens: 50 },
}),
sendResponsesErrorResponse: jest.fn(),
createResponsesEventHandlers: jest.fn().mockReturnValue({
handlers: {
on_message_delta: { handle: jest.fn() },
on_reasoning_delta: { handle: jest.fn() },
on_run_step: { handle: jest.fn() },
on_run_step_delta: { handle: jest.fn() },
on_chat_model_end: { handle: jest.fn() },
},
finalizeStream: jest.fn(),
}),
createAggregatorEventHandlers: jest.fn().mockReturnValue({
on_message_delta: { handle: jest.fn() },
on_reasoning_delta: { handle: jest.fn() },
on_run_step: { handle: jest.fn() },
on_run_step_delta: { handle: jest.fn() },
on_chat_model_end: { handle: jest.fn() },
}),
}));
jest.mock('~/server/services/ToolService', () => ({
loadAgentTools: jest.fn().mockResolvedValue([]),
loadToolsForExecution: jest.fn().mockResolvedValue([]),
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/server/controllers/agents/callbacks', () => {
const noop = { handle: jest.fn() };
return {
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
createResponsesToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
markSummarizationUsage: jest.fn().mockImplementation((usage) => usage),
agentLogHandlerObj: noop,
buildSummarizationHandlers: jest.fn().mockReturnValue({
on_summarize_start: noop,
on_summarize_delta: noop,
on_summarize_complete: noop,
}),
};
});
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
checkPermission: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/server/services/MCP', () => ({
resolveConfigServers: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn().mockReturnValue({}),
}));
jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn(),
}));
jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
getSkillToolDeps: mockGetSkillToolDeps,
getSkillDbMethods: jest.fn(() => ({})),
canAuthorSkillFiles: mockCanAuthorSkillFiles,
withDeploymentSkillIds: jest.fn((ids = []) => ids),
enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
buildAgentToolContext: mockBuildAgentToolContext,
enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext,
}));
jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn().mockReturnValue({}),
}));
jest.mock('~/server/services/Files/Code/crud', () => ({
batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
getSessionInfo: jest.fn().mockResolvedValue(null),
checkIfActive: jest.fn().mockReturnValue(false),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }),
getFiles: jest.fn(),
getUserKey: jest.fn(),
getMessages: jest.fn().mockResolvedValue([]),
saveMessage: jest.fn().mockResolvedValue({}),
updateFilesUsage: jest.fn(),
getUserKeyValues: jest.fn(),
getUserCodeFiles: jest.fn(),
getToolFilesByIds: jest.fn(),
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
getConvoFiles: jest.fn().mockResolvedValue([]),
saveConvo: jest.fn().mockResolvedValue({}),
getConvo: jest.fn().mockResolvedValue(null),
}));
let mockGlobalDiscoveredAgentConfigs = null;
describe('createResponse controller', () => {
let createResponse;
let req, res;
beforeEach(() => {
jest.clearAllMocks();
mockGlobalDiscoveredAgentConfigs = null;
const controller = require('../responses');
createResponse = controller.createResponse;
req = {
body: {
model: 'agent-123',
input: 'Hello',
stream: false,
},
user: { id: 'user-123' },
config: {
endpoints: {
agents: { allowedProviders: ['anthropic'] },
},
},
on: jest.fn(),
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
flushHeaders: jest.fn(),
end: jest.fn(),
write: jest.fn(),
};
});
describe('conversation ownership validation', () => {
it('should skip ownership check when previous_response_id is not provided', async () => {
const { getConvo } = require('~/models');
await createResponse(req, res);
expect(getConvo).not.toHaveBeenCalled();
});
it('should return 400 when previous_response_id is not a string', async () => {
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
validateResponseRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
input: 'Hello',
stream: false,
previous_response_id: { $gt: '' },
},
});
await createResponse(req, res);
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
res,
400,
'previous_response_id must be a string',
'invalid_request',
);
});
it('should return 404 when conversation is not owned by user', async () => {
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
const { getConvo } = require('~/models');
validateResponseRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
input: 'Hello',
stream: false,
previous_response_id: 'resp_abc',
},
});
getConvo.mockResolvedValueOnce(null);
await createResponse(req, res);
expect(getConvo).toHaveBeenCalledWith('user-123', 'resp_abc');
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
res,
404,
'Conversation not found',
'not_found',
);
});
it('should proceed when conversation is owned by user', async () => {
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
const { getConvo } = require('~/models');
validateResponseRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
input: 'Hello',
stream: false,
previous_response_id: 'resp_abc',
},
});
getConvo.mockResolvedValueOnce({ conversationId: 'resp_abc', user: 'user-123' });
await createResponse(req, res);
expect(getConvo).toHaveBeenCalledWith('user-123', 'resp_abc');
expect(sendResponsesErrorResponse).not.toHaveBeenCalledWith(
res,
404,
expect.any(String),
expect.any(String),
);
});
it('should return 500 when getConvo throws a DB error', async () => {
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
const { getConvo } = require('~/models');
validateResponseRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
input: 'Hello',
stream: false,
previous_response_id: 'resp_abc',
},
});
getConvo.mockRejectedValueOnce(new Error('DB connection failed'));
await createResponse(req, res);
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
res,
500,
expect.any(String),
expect.any(String),
);
});
});
describe('token usage recording - non-streaming', () => {
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
conversationId: expect.any(String),
collectedUsage: expect.any(Array),
context: 'message',
}),
);
});
it('should pass balance and transactions config to recordCollectedUsage', async () => {
mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 2000 });
mockGetTransactionsConfig.mockReturnValue({ enabled: true });
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
balance: { enabled: true, startBalance: 2000 },
transactions: { enabled: true },
}),
);
});
it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
await createResponse(req, res);
const [deps] = mockRecordCollectedUsage.mock.calls[0];
expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
expect(deps).toHaveProperty('pricing');
expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
expect(deps).toHaveProperty('bulkWriteOps');
expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
});
it('should include model from primaryConfig in recordCollectedUsage params', async () => {
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
model: 'claude-3',
}),
);
});
});
describe('agent context parity with UI path', () => {
it('applies agent-scoped attachment context before createRun', async () => {
const api = require('@librechat/api');
api.initializeAgent.mockResolvedValueOnce({
id: 'agent-123',
model: 'claude-3',
model_parameters: {},
toolRegistry: {},
edges: [],
agentContextAttachments: [{ file_id: 'file-1', filename: 'ocr_file.pdf' }],
});
mockBuildAgentContextAttachmentsByAgentId.mockReturnValueOnce(
new Map([['agent-123', [{ file_id: 'file-1', filename: 'ocr_file.pdf' }]]]),
);
mockBuildAgentScopedContext.mockResolvedValueOnce(
new Map([['agent-123', 'PDF context: ocr_file.pdf']]),
);
await createResponse(req, res);
expect(mockBuildAgentContextAttachmentsByAgentId).toHaveBeenCalledWith([
expect.objectContaining({ id: 'agent-123' }),
]);
expect(mockBuildAgentScopedContext).toHaveBeenCalledWith(
expect.objectContaining({
agentIds: ['agent-123'],
attachmentsByAgentId: expect.any(Map),
req,
}),
);
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: expect.objectContaining({ id: 'agent-123' }),
agentId: 'agent-123',
sharedRunContext: 'PDF context: ocr_file.pdf',
}),
);
});
it('applies context to primary and discovered handoff agents', async () => {
const api = require('@librechat/api');
const handoffConfig = {
id: 'agent-handoff',
model: 'claude-3',
model_parameters: {},
toolRegistry: {},
edges: [],
agentContextAttachments: [{ file_id: 'file-2', filename: 'handoff_context.pdf' }],
};
// Set primary agent to have edges pointing to handoff agent
api.initializeAgent.mockResolvedValueOnce({
id: 'agent-123',
model: 'claude-3',
model_parameters: {},
toolRegistry: {},
edges: [{ source: 'agent-123', target: 'agent-handoff' }],
agentContextAttachments: [{ file_id: 'file-1', filename: 'primary_context.pdf' }],
});
// Set global config so discoverConnectedAgents mock can invoke onAgentInitialized
mockGlobalDiscoveredAgentConfigs = new Map([['agent-handoff', handoffConfig]]);
mockBuildAgentScopedContext.mockResolvedValueOnce(
new Map([
['agent-123', 'Primary context'],
['agent-handoff', 'Handoff context'],
]),
);
await createResponse(req, res);
const appliedAgentIds = mockApplyContextToAgent.mock.calls.map((call) => call[0].agentId);
expect(appliedAgentIds).toEqual(expect.arrayContaining(['agent-123', 'agent-handoff']));
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-handoff',
sharedRunContext: 'Handoff context',
}),
);
});
});
describe('token usage recording - streaming', () => {
beforeEach(() => {
req.body.stream = true;
const api = require('@librechat/api');
api.validateResponseRequest.mockReturnValue({
request: { model: 'agent-123', input: 'Hello', stream: true },
});
});
it('should call recordCollectedUsage after successful streaming completion', async () => {
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
context: 'message',
}),
);
});
});
describe('collectedUsage population', () => {
it('should collect usage from on_chat_model_end events', async () => {
const api = require('@librechat/api');
api.createRun.mockImplementation(async ({ customHandlers }) => {
return {
processStream: jest.fn().mockImplementation(async () => {
customHandlers.on_chat_model_end.handle('on_chat_model_end', {
output: {
usage_metadata: {
input_tokens: 150,
output_tokens: 75,
model: 'claude-3',
},
},
});
}),
};
});
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
collectedUsage: expect.arrayContaining([
expect.objectContaining({
input_tokens: 150,
output_tokens: 75,
}),
]),
}),
);
});
});
describe('sub-agent skill priming', () => {
it('passes the sub-agent primed skill IDs into non-streaming tool execution', async () => {
const {
initializeAgent,
discoverConnectedAgents,
createToolExecuteHandler,
} = require('@librechat/api');
const { loadToolsForExecution } = require('~/server/services/ToolService');
const subAgent = { id: 'agent-sub', name: 'Sub Agent' };
const subConfig = {
id: 'agent-sub',
model: 'claude-3',
model_parameters: {},
toolRegistry: new Map(),
userMCPAuthMap: { sub: { token: 'sub-token' } },
tool_resources: { code_interpreter: { file_ids: ['sub-file'] } },
actionsEnabled: true,
accessibleSkillIds: ['sub-skill-id'],
activeSkillNames: ['sub-hidden-skill'],
codeEnvAvailable: true,
skillAuthoringAvailable: true,
fileAuthoringToolNames: ['create_file', 'edit_file'],
manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }],
alwaysApplySkillPrimes: [
{ name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } },
],
};
initializeAgent.mockResolvedValueOnce({
id: 'agent-123',
model: 'claude-3',
model_parameters: {},
toolRegistry: new Map(),
edges: [{ source: 'agent-123', target: 'agent-sub' }],
accessibleSkillIds: ['primary-skill-id'],
activeSkillNames: ['primary-skill'],
codeEnvAvailable: false,
skillAuthoringAvailable: false,
fileAuthoringToolNames: [],
manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }],
});
discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => {
deps.onAgentInitialized('agent-sub', subAgent, subConfig);
return {
agentConfigs: new Map([['agent-sub', subConfig]]),
edges: [],
skippedAgentIds: new Set(),
userMCPAuthMap: undefined,
};
});
await createResponse(req, res);
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
await toolExecuteOptions.loadTools(['read_file'], 'agent-sub');
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({
agent: subAgent,
toolRegistry: subConfig.toolRegistry,
userMCPAuthMap: subConfig.userMCPAuthMap,
tool_resources: subConfig.tool_resources,
actionsEnabled: true,
}),
);
expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({
result: expect.anything(),
context: {
req,
accessibleSkillIds: ['sub-skill-id'],
codeEnvAvailable: true,
skillPrimedIdsByName: {
'sub-always-skill': 'sub-always-id',
'sub-hidden-skill': 'sub-manual-id',
},
activeSkillNames: ['sub-hidden-skill'],
skillAuthoringAvailable: true,
fileAuthoringToolNames: ['create_file', 'edit_file'],
},
});
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,477 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { ChatGenerationChunk } = require('@langchain/core/outputs');
const { HumanMessage, AIMessage, AIMessageChunk } = require('@langchain/core/messages');
const {
Run,
Providers,
GraphEvents,
FakeChatModel,
createContentAggregator,
} = require('@librechat/agents');
const {
GenerationJobManager,
aggregateEmittedUsage,
resolveAgentTokenConfig,
buildPersistedContextUsage,
} = require('@librechat/api');
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid'),
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
runPreviewFinalize: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
/** Real pipeline guard: published lib versions without the event skip its assertions */
const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null;
/**
* FakeChatModel that attaches provider-style usage_metadata on a final
* empty chunk (the OpenAI streaming pattern), so CHAT_MODEL_END carries
* aggregated usage through the real @librechat/agents pipeline.
*/
class UsageFakeModel extends FakeChatModel {
constructor(options, usagePerCall) {
super(options);
this.usagePerCall = usagePerCall;
this.usageCallIndex = 0;
}
async *_streamResponseChunks(messages, options, runManager) {
yield* super._streamResponseChunks(messages, options, runManager);
const index = Math.min(this.usageCallIndex, this.usagePerCall.length - 1);
this.usageCallIndex += 1;
yield new ChatGenerationChunk({
text: '',
message: new AIMessageChunk({ content: '', usage_metadata: this.usagePerCall[index] }),
});
}
}
const addTool = tool(async ({ a, b }) => String(a + b), {
name: 'add',
description: 'Add two numbers',
schema: z.object({ a: z.number(), b: z.number() }),
});
const charCounter = (msg) => {
const content = msg.content;
if (typeof content === 'string') {
return content.length + 3;
}
if (Array.isArray(content)) {
let length = 3;
for (const part of content) {
if (typeof part === 'string') {
length += part.length;
} else if (typeof part?.text === 'string') {
length += part.text.length;
}
}
return length;
}
return 3;
};
function createMockRes() {
const events = [];
return {
events,
headersSent: true,
writableEnded: false,
write(payload) {
for (const line of String(payload).split('\n')) {
if (line.startsWith('data: ')) {
events.push(JSON.parse(line.slice(6)));
}
}
return true;
},
};
}
const FIRST_CALL_USAGE = {
input_tokens: 100,
output_tokens: 20,
total_tokens: 120,
};
const SECOND_CALL_USAGE = {
input_tokens: 150,
output_tokens: 10,
total_tokens: 160,
input_token_details: { cache_creation: 30, cache_read: 50 },
};
const MAX_CONTEXT_TOKENS = 8000;
async function runToolLoop({
res,
streamId = null,
collectedUsage,
contextUsageSink = null,
usageEmitSink = null,
usageCost = null,
}) {
const { contentParts, aggregateContent } = createContentAggregator();
const handlers = getDefaultHandlers({
res,
aggregateContent,
toolEndCallback: () => {},
collectedUsage,
streamId,
contextUsageSink,
usageEmitSink,
usageCost,
});
const run = await Run.create({
runId: 'usage-e2e-response',
graphConfig: {
type: 'standard',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-4o-mini',
streaming: true,
streamUsage: false,
},
instructions: 'You are a helpful assistant.',
maxContextTokens: MAX_CONTEXT_TOKENS,
tools: [addTool],
},
returnContent: true,
customHandlers: handlers,
tokenCounter: charCounter,
indexTokenCountMap: {},
});
run.Graph.overrideModel = new UsageFakeModel(
{
responses: ['Let me calculate that.', 'The answer is 4.'],
toolCalls: [{ name: 'add', args: { a: 2, b: 2 }, id: 'tc_1', type: 'tool_call' }],
},
[FIRST_CALL_USAGE, SECOND_CALL_USAGE],
);
await run.processStream(
{ messages: [new HumanMessage('What is 2+2?')] },
{
configurable: { thread_id: 'usage-e2e-thread', user_id: 'user-1' },
streamMode: 'values',
version: 'v2',
},
);
return { run, contentParts };
}
describe('usage events through the real agents pipeline', () => {
jest.setTimeout(30000);
afterAll(async () => {
await GenerationJobManager.destroy();
});
test('emits on_token_usage per model call with collectedUsage parity', async () => {
const res = createMockRes();
const collectedUsage = [];
const { contentParts } = await runToolLoop({ res, collectedUsage });
const usageEvents = res.events.filter((e) => e.event === 'on_token_usage');
expect(usageEvents).toHaveLength(2);
expect(usageEvents[0].data).toMatchObject(FIRST_CALL_USAGE);
expect(usageEvents[1].data).toMatchObject(SECOND_CALL_USAGE);
expect(usageEvents[0].data.provider).toBe(Providers.OPENAI);
expect(usageEvents[0].data.model).toBeTruthy();
expect(usageEvents[0].data.usage_type).toBeUndefined();
expect(collectedUsage).toHaveLength(2);
expect(collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE);
expect(collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE);
const text = contentParts
.filter((part) => part?.type === 'text')
.map((part) => part.text)
.join('');
expect(text).toContain('The answer is 4.');
});
test('emits a context snapshot before each model call', async () => {
if (!hasContextUsageEvent) {
console.warn('Skipping: installed @librechat/agents predates ON_CONTEXT_USAGE');
return;
}
const res = createMockRes();
const { run } = await runToolLoop({ res, collectedUsage: [] });
expect(run).toBeDefined();
const contextEvents = res.events.filter((e) => e.event === 'on_context_usage');
expect(contextEvents).toHaveLength(2);
for (const event of contextEvents) {
const { breakdown, contextBudget, remainingContextTokens, effectiveInstructionTokens } =
event.data;
expect(breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
expect(contextBudget).toBeGreaterThan(0);
expect(contextBudget).toBeLessThanOrEqual(MAX_CONTEXT_TOKENS);
expect(effectiveInstructionTokens).toBeGreaterThan(0);
expect(remainingContextTokens).toBeGreaterThan(0);
expect(remainingContextTokens).toBeLessThan(contextBudget);
expect(breakdown.toolTokenCounts.add).toBeGreaterThan(0);
}
/** Tool loop grows the context between calls */
expect(contextEvents[1].data.prePruneContextTokens).toBeGreaterThan(
contextEvents[0].data.prePruneContextTokens,
);
/** Snapshot precedes the call's usage event */
const firstContextIndex = res.events.findIndex((e) => e.event === 'on_context_usage');
const firstUsageIndex = res.events.findIndex((e) => e.event === 'on_token_usage');
expect(firstContextIndex).toBeGreaterThanOrEqual(0);
expect(firstContextIndex).toBeLessThan(firstUsageIndex);
});
test('captures the usage rollup + latest context snapshot for message persistence', () => {
const res = createMockRes();
const contextUsageSink = { latest: null };
const usageEmitSink = [];
return runToolLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink }).then(() => {
/** Both model calls' emitted payloads are captured for the rollup */
expect(usageEmitSink).toHaveLength(2);
const usage = aggregateEmittedUsage(usageEmitSink);
/** Display units: openAI is cache-subset, so input excludes cache
* (1503050=70); output is repaired completion */
expect(usage).toEqual({
input:
FIRST_CALL_USAGE.input_tokens +
(SECOND_CALL_USAGE.input_tokens -
SECOND_CALL_USAGE.input_token_details.cache_creation -
SECOND_CALL_USAGE.input_token_details.cache_read),
output: FIRST_CALL_USAGE.output_tokens + SECOND_CALL_USAGE.output_tokens,
cacheWrite: SECOND_CALL_USAGE.input_token_details.cache_creation,
cacheRead: SECOND_CALL_USAGE.input_token_details.cache_read,
});
/** contextCost off → no cost folded into the rollup */
expect(usage.cost).toBeUndefined();
if (hasContextUsageEvent) {
expect(contextUsageSink.latest).not.toBeNull();
const persisted = buildPersistedContextUsage(contextUsageSink.latest);
expect(persisted.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
/** Zero-valued tool counts are trimmed from the persisted blob */
for (const count of Object.values(persisted.breakdown.toolTokenCounts ?? {})) {
expect(count).toBeGreaterThan(0);
}
}
});
});
test('folds authoritative per-event cost into the rollup when contextCost is on', async () => {
const res = createMockRes();
const usageEmitSink = [];
/** Stub pricing mirroring getMultiplier/getCacheMultiplier shape */
const usageCost = {
enabled: true,
pricing: {
getMultiplier: ({ tokenType }) => (tokenType === 'completion' ? 15 : 3),
getCacheMultiplier: ({ cacheType }) => (cacheType === 'write' ? 3.75 : 0.3),
},
};
await runToolLoop({ res, collectedUsage: [], usageEmitSink, usageCost });
for (const event of usageEmitSink) {
expect(typeof event.cost).toBe('number');
}
const usage = aggregateEmittedUsage(usageEmitSink);
expect(usage.cost).toBeGreaterThan(0);
expect(usage.cost).toBeCloseTo(usageEmitSink.reduce((sum, e) => sum + e.cost, 0));
});
test('emit path prices each call by its producing agent and strips the agentId tag', () => {
const res = createMockRes();
const usageEmitSink = [];
/** Two endpoints share a model id but bill at different rates. */
const primaryConfig = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } };
const subagentConfig = { 'gpt-4': { prompt: 0.05, completion: 0.15, context: 8192 } };
const byAgentId = new Map([
['primary', primaryConfig],
['sub', subagentConfig],
]);
const usageCost = {
enabled: true,
endpointTokenConfig: primaryConfig,
pricing: {
getMultiplier: ({ tokenType, model, endpointTokenConfig }) =>
endpointTokenConfig?.[model]?.[tokenType] ?? 0,
getCacheMultiplier: () => 0,
},
resolveEndpointTokenConfig: (usage) =>
resolveAgentTokenConfig({ agentId: usage?.agentId, byAgentId, fallback: primaryConfig }),
};
const { aggregateContent } = createContentAggregator();
const handlers = getDefaultHandlers({
res,
aggregateContent,
toolEndCallback: () => {},
collectedUsage: [],
usageEmitSink,
usageCost,
});
/** The CHAT_MODEL_END handler's emitUsage IS the real emitTokenUsage closure. */
const emitUsage = handlers[GraphEvents.CHAT_MODEL_END].emitUsage;
const call = { model: 'gpt-4', input_tokens: 100, output_tokens: 50, total_tokens: 150 };
emitUsage({ ...call, agentId: 'sub' });
emitUsage({ ...call, agentId: 'primary' });
const events = res.events.filter((e) => e.event === 'on_token_usage');
expect(events).toHaveLength(2);
/** agentId is an internal pricing tag — never streamed to the client nor
* folded into the persisted rollup. */
for (const e of events) {
expect(e.data.agentId).toBeUndefined();
}
for (const entry of usageEmitSink) {
expect(entry.agentId).toBeUndefined();
}
/** Same tokens + model id, but the subagent endpoint's higher rates price
* its call above the primary — proving per-agent emit pricing. The 5× ratio
* ((100·0.05+50·0.15)/(100·0.01+50·0.03)) is scale-independent of credit units. */
expect(events[1].data.cost).toBeGreaterThan(0);
expect(events[0].data.cost).toBeGreaterThan(events[1].data.cost);
expect(events[0].data.cost / events[1].data.cost).toBeCloseTo(5);
});
test('persists usage and context snapshot for resume via GenerationJobManager', async () => {
const streamId = `usage-e2e-stream-${Date.now()}`;
await GenerationJobManager.createJob(streamId, 'user-1', 'convo-1');
const res = createMockRes();
await runToolLoop({ res, streamId, collectedUsage: [] });
const resumeState = await GenerationJobManager.getResumeState(streamId);
expect(resumeState).not.toBeNull();
expect(resumeState.collectedUsage).toHaveLength(2);
expect(resumeState.collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE);
expect(resumeState.collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE);
if (hasContextUsageEvent) {
expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
/** Latest-wins: the persisted snapshot is the second call's */
expect(resumeState.contextUsage.prePruneContextTokens).toBeGreaterThan(0);
/** Reconciled to the final primary call's actual prompt: openAI folds cache
* into input_tokens (150), so the resume snapshot's used = 150 — the real
* context, not the calibrated estimate. */
const used =
resumeState.contextUsage.contextBudget - resumeState.contextUsage.remainingContextTokens;
expect(used).toBe(SECOND_CALL_USAGE.input_tokens);
}
});
/** Drives a real summarization (tight context + padded history); self-summarize
* reuses the overridden fake model so no API key is needed. */
async function runSummarizationLoop({ res, collectedUsage, contextUsageSink, usageEmitSink }) {
const { aggregateContent } = createContentAggregator();
const handlers = getDefaultHandlers({
res,
aggregateContent,
toolEndCallback: () => {},
collectedUsage,
contextUsageSink,
usageEmitSink,
summarizationOptions: { enabled: true },
});
const pad = 'context detail to overflow the tiny budget. '.repeat(40);
const history = [
new HumanMessage(`Turn 1 question. ${pad}`),
new AIMessage(`Turn 1 answer. ${pad}`),
new HumanMessage(`Turn 2 question. ${pad}`),
new AIMessage(`Turn 2 answer. ${pad}`),
new HumanMessage(`Final question after a lot of prior history. ${pad}`),
];
const indexTokenCountMap = {};
history.forEach((message, i) => {
indexTokenCountMap[i] = charCounter(message);
});
const run = await Run.create({
runId: `summ-e2e-${Date.now()}`,
graphConfig: {
type: 'standard',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-4o-mini',
streaming: true,
streamUsage: false,
},
instructions: 'You are a helpful assistant.',
maxContextTokens: 700,
summarizationEnabled: true,
summarizationConfig: { provider: Providers.OPENAI, model: 'gpt-4o-mini' },
},
returnContent: true,
customHandlers: handlers,
tokenCounter: charCounter,
indexTokenCountMap,
});
run.Graph.overrideModel = new UsageFakeModel(
{ responses: ['## Summary\nPrior turns compacted.', 'Here is the final answer.'] },
[{ input_tokens: 40, output_tokens: 8, total_tokens: 48 }],
);
await run.processStream(
{ messages: history },
{
configurable: { thread_id: 'summ-e2e-thread', user_id: 'user-1' },
streamMode: 'values',
version: 'v2',
},
);
return run;
}
/** A summarized turn compacts the context (summary tokens replace the older
* turns) and the reduced snapshot is persisted — the latest snapshot is
* followed by a primary usage, so the save guard keeps it and the client
* uses the snapshot (not the inflated whole-history estimate). */
test('persists the reduced (compacted) snapshot after summarization', async () => {
if (!hasContextUsageEvent) {
return;
}
const res = createMockRes();
const contextUsageSink = { latest: null, count: 0 };
const usageEmitSink = [];
await runSummarizationLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink });
const snapshot = contextUsageSink.latest;
/** Summarization fired: a summary exists and the kept message tokens are
* small (the compacted context, not the full history). */
expect(snapshot?.breakdown?.summaryTokens).toBeGreaterThan(0);
expect(snapshot?.breakdown?.messageTokens).toBeLessThan(snapshot?.breakdown?.summaryTokens);
/** The save guard keeps it: a primary usage follows the latest snapshot. */
const afterLatest = usageEmitSink.slice(contextUsageSink.latestUsageIndex ?? 0);
expect(afterLatest.some((e) => e.usage_type == null)).toBe(true);
expect(
buildPersistedContextUsage(snapshot, usageEmitSink).breakdown.summaryTokens,
).toBeGreaterThan(0);
});
});
@@ -0,0 +1,161 @@
/**
* Live host-layer verification: real Anthropic run through the actual
* getDefaultHandlers pipeline, asserting the SSE usage/context events the
* client consumes and their resume persistence.
*
* Run with:
* RUN_USAGE_LIVE_TESTS=1 ANTHROPIC_API_KEY=... npx jest usageEvents.live --runInBand
*/
const { HumanMessage } = require('@langchain/core/messages');
const { Run, Providers, GraphEvents } = require('@librechat/agents');
const { GenerationJobManager } = require('@librechat/api');
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid'),
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
runPreviewFinalize: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
const shouldRunLive =
process.env.RUN_USAGE_LIVE_TESTS === '1' &&
process.env.ANTHROPIC_API_KEY != null &&
process.env.ANTHROPIC_API_KEY !== '';
const describeIfLive = shouldRunLive ? describe : describe.skip;
const modelName = process.env.ANTHROPIC_USAGE_LIVE_MODEL ?? 'claude-haiku-4-5';
const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null;
const charCounter = (msg) => {
const content = msg.content;
if (typeof content === 'string') {
return Math.ceil(content.length / 4) + 3;
}
if (Array.isArray(content)) {
let length = 3;
for (const part of content) {
if (typeof part === 'string') {
length += Math.ceil(part.length / 4);
} else if (typeof part?.text === 'string') {
length += Math.ceil(part.text.length / 4);
}
}
return length;
}
return 3;
};
function createMockRes() {
const events = [];
return {
events,
headersSent: true,
writableEnded: false,
write(payload) {
for (const line of String(payload).split('\n')) {
if (line.startsWith('data: ')) {
events.push(JSON.parse(line.slice(6)));
}
}
return true;
},
};
}
describeIfLive('live usage events through the host pipeline', () => {
jest.setTimeout(120000);
afterAll(async () => {
await GenerationJobManager.destroy();
});
test('streams real provider usage and persists it for resume', async () => {
const streamId = `usage-live-${Date.now()}`;
await GenerationJobManager.createJob(streamId, 'user-live', 'convo-live');
/** streamId mode routes events through the job emitter — capture them
* as a subscribed resumable client would, not via res.write */
const res = createMockRes();
await GenerationJobManager.subscribe(streamId, (event) => {
res.events.push(event);
});
const collectedUsage = [];
const handlers = getDefaultHandlers({
res,
aggregateContent: () => {},
toolEndCallback: () => {},
collectedUsage,
streamId,
});
const run = await Run.create({
runId: 'usage-live-response',
graphConfig: {
type: 'standard',
llmConfig: {
provider: Providers.ANTHROPIC,
model: modelName,
apiKey: process.env.ANTHROPIC_API_KEY,
temperature: 0,
maxTokens: 64,
streaming: true,
streamUsage: true,
},
instructions: 'You are concise. Reply with one short sentence.',
maxContextTokens: 8000,
},
returnContent: true,
customHandlers: handlers,
tokenCounter: charCounter,
indexTokenCountMap: {},
});
await run.processStream(
{ messages: [new HumanMessage('Say hello in five words or fewer.')] },
{
configurable: { thread_id: 'usage-live-thread', user_id: 'user-live' },
streamMode: 'values',
version: 'v2',
},
);
const usageEvents = res.events.filter((e) => e.event === 'on_token_usage');
expect(usageEvents).toHaveLength(1);
const usage = usageEvents[0].data;
expect(usage.input_tokens).toBeGreaterThan(0);
expect(usage.output_tokens).toBeGreaterThan(0);
expect(usage.provider).toBe(Providers.ANTHROPIC);
expect(usage.model).toBe(modelName);
expect(collectedUsage).toHaveLength(1);
expect(usage.input_tokens).toBe(collectedUsage[0].input_tokens);
if (hasContextUsageEvent) {
const contextEvents = res.events.filter((e) => e.event === 'on_context_usage');
expect(contextEvents).toHaveLength(1);
const snapshot = contextEvents[0].data;
expect(snapshot.breakdown.maxContextTokens).toBe(8000);
const estimatedUsed = snapshot.contextBudget - snapshot.remainingContextTokens;
expect(estimatedUsed).toBeGreaterThan(0);
expect(estimatedUsed / usage.input_tokens).toBeGreaterThan(0.2);
expect(estimatedUsed / usage.input_tokens).toBeLessThan(5);
}
const resumeState = await GenerationJobManager.getResumeState(streamId);
expect(resumeState.collectedUsage).toHaveLength(1);
expect(resumeState.collectedUsage[0].input_tokens).toBe(usage.input_tokens);
if (hasContextUsageEvent) {
expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(8000);
}
});
});
@@ -0,0 +1,159 @@
jest.mock('~/server/services/PermissionService', () => ({
findPubliclyAccessibleResources: jest.fn(),
findAccessibleResources: jest.fn(),
hasPublicPermission: jest.fn(),
grantPermission: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
}));
const mongoose = require('mongoose');
const { actionDelimiter } = require('librechat-data-provider');
const { agentSchema, actionSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { duplicateAgent } = require('../v1');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
if (!mongoose.models.Agent) {
mongoose.model('Agent', agentSchema);
}
if (!mongoose.models.Action) {
mongoose.model('Action', actionSchema);
}
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.models.Agent.deleteMany({});
await mongoose.models.Action.deleteMany({});
});
describe('duplicateAgentHandler — action domain extraction', () => {
it('builds duplicated action entries using metadata.domain, not action_id', async () => {
const userId = new mongoose.Types.ObjectId();
const originalAgentId = `agent_original`;
const agent = await mongoose.models.Agent.create({
id: originalAgentId,
name: 'Test Agent',
author: userId.toString(),
provider: 'openai',
model: 'gpt-4',
tools: [],
actions: [`api.example.com${actionDelimiter}act_original`],
versions: [{ name: 'Test Agent', createdAt: new Date(), updatedAt: new Date() }],
});
await mongoose.models.Action.create({
user: userId,
action_id: 'act_original',
agent_id: originalAgentId,
metadata: { domain: 'api.example.com' },
});
const req = {
params: { id: agent.id },
user: { id: userId.toString() },
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(201);
const { agent: newAgent, actions: newActions } = res.json.mock.calls[0][0];
expect(newAgent.id).not.toBe(originalAgentId);
expect(String(newAgent.author)).toBe(userId.toString());
expect(newActions).toHaveLength(1);
expect(newActions[0].metadata.domain).toBe('api.example.com');
expect(newActions[0].agent_id).toBe(newAgent.id);
for (const actionEntry of newAgent.actions) {
const [domain, actionId] = actionEntry.split(actionDelimiter);
expect(domain).toBe('api.example.com');
expect(actionId).toBeTruthy();
expect(actionId).not.toBe('act_original');
}
const allActions = await mongoose.models.Action.find({}).lean();
expect(allActions).toHaveLength(2);
const originalAction = allActions.find((a) => a.action_id === 'act_original');
expect(originalAction.agent_id).toBe(originalAgentId);
const duplicatedAction = allActions.find((a) => a.action_id !== 'act_original');
expect(duplicatedAction.agent_id).toBe(newAgent.id);
expect(duplicatedAction.metadata.domain).toBe('api.example.com');
});
it('strips sensitive metadata fields from duplicated actions', async () => {
const userId = new mongoose.Types.ObjectId();
const originalAgentId = 'agent_sensitive';
await mongoose.models.Agent.create({
id: originalAgentId,
name: 'Sensitive Agent',
author: userId.toString(),
provider: 'openai',
model: 'gpt-4',
tools: [],
actions: [`secure.api.com${actionDelimiter}act_secret`],
versions: [{ name: 'Sensitive Agent', createdAt: new Date(), updatedAt: new Date() }],
});
await mongoose.models.Action.create({
user: userId,
action_id: 'act_secret',
agent_id: originalAgentId,
metadata: {
domain: 'secure.api.com',
api_key: 'sk-secret-key-12345',
oauth_client_id: 'client_id_xyz',
oauth_client_secret: 'client_secret_xyz',
},
});
const req = {
params: { id: originalAgentId },
user: { id: userId.toString() },
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(201);
const duplicatedAction = await mongoose.models.Action.findOne({
agent_id: { $ne: originalAgentId },
}).lean();
expect(duplicatedAction.metadata.domain).toBe('secure.api.com');
expect(duplicatedAction.metadata.api_key).toBeUndefined();
expect(duplicatedAction.metadata.oauth_client_id).toBeUndefined();
expect(duplicatedAction.metadata.oauth_client_secret).toBeUndefined();
const originalAction = await mongoose.models.Action.findOne({
action_id: 'act_secret',
}).lean();
expect(originalAction.metadata.api_key).toBe('sk-secret-key-12345');
});
});
@@ -0,0 +1,193 @@
const { duplicateAgent } = require('../v1');
const { getAgent, createAgent, getActions } = require('~/models');
const { nanoid } = require('nanoid');
jest.mock('~/models');
jest.mock('nanoid');
describe('duplicateAgent', () => {
let req, res;
beforeEach(() => {
req = {
params: { id: 'agent_123' },
user: { id: 'user_456' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
jest.clearAllMocks();
});
it('should duplicate an agent successfully', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_789',
versions: [{ name: 'Test Agent', version: 1 }],
__v: 0,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_456',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent_new_123',
author: 'user_456',
name: expect.stringContaining('Test Agent ('),
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
}),
);
expect(createAgent).toHaveBeenCalledWith(
expect.not.objectContaining({
versions: expect.anything(),
__v: expect.anything(),
}),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
agent: mockNewAgent,
actions: [],
});
});
it('should ensure duplicated agent has clean versions array without nested fields', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
versions: [
{
name: 'Test Agent',
versions: [{ name: 'Nested' }],
__v: 1,
},
],
__v: 2,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(mockNewAgent.versions).toHaveLength(1);
const firstVersion = mockNewAgent.versions[0];
expect(firstVersion).not.toHaveProperty('versions');
expect(firstVersion).not.toHaveProperty('__v');
expect(mockNewAgent).not.toHaveProperty('__v');
expect(res.status).toHaveBeenCalledWith(201);
});
it('should return 404 if agent not found', async () => {
getAgent.mockResolvedValue(null);
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Agent not found',
status: 'error',
});
});
it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
tool_resources: {
ocr: { enabled: true, config: 'test' },
other: { should: 'not be copied' },
},
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue({ id: 'agent_new_123' });
await duplicateAgent(req, res);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
tool_resources: {
context: { enabled: true, config: 'test' },
},
}),
);
});
it('should handle errors gracefully', async () => {
getAgent.mockRejectedValue(new Error('Database error'));
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
// errorHandler.js
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { sendResponse } = require('~/server/middleware/error');
const { recordUsage } = require('~/server/services/Threads');
const getLogStores = require('~/cache/getLogStores');
const { getConvo } = require('~/models');
/**
* @typedef {Object} ErrorHandlerContext
* @property {OpenAIClient} openai - The OpenAI client
* @property {string} run_id - The run ID
* @property {boolean} completedRun - Whether the run has completed
* @property {string} assistant_id - The assistant ID
* @property {string} conversationId - The conversation ID
* @property {string} parentMessageId - The parent message ID
* @property {string} responseMessageId - The response message ID
* @property {string} endpoint - The endpoint being used
* @property {string} cacheKey - The cache key for the current request
*/
/**
* @typedef {Object} ErrorHandlerDependencies
* @property {ServerRequest} req - The Express request object
* @property {Express.Response} res - The Express response object
* @property {() => ErrorHandlerContext} getContext - Function to get the current context
* @property {string} [originPath] - The origin path for the error handler
*/
/**
* Creates an error handler function with the given dependencies
* @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
* @returns {(error: Error) => Promise<void>} The error handler function
*/
const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
const cache = getLogStores(CacheKeys.ABORT_KEYS);
/**
* Handles errors that occur during the chat process
* @param {Error} error - The error that occurred
* @returns {Promise<void>}
*/
return async (error) => {
const {
openai,
run_id,
endpoint,
cacheKey,
completedRun,
assistant_id,
conversationId,
parentMessageId,
responseMessageId,
} = getContext();
const defaultErrorMessage =
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
const messageData = {
assistant_id,
conversationId,
parentMessageId,
sender: 'System',
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint,
};
if (error.message === 'Run cancelled') {
return res.end();
} else if (error.message === 'Request closed' && completedRun) {
return;
} else if (error.message === 'Request closed') {
logger.debug(`[${originPath}] Request aborted on close`);
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
req,
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(req, res, messageData, error.message);
} else {
logger.error(`[${originPath}]`, error);
}
if (!openai || !run_id) {
return sendResponse(req, res, messageData, defaultErrorMessage);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const status = await cache.get(cacheKey);
if (status === 'cancelled') {
logger.debug(`[${originPath}] Run already cancelled`);
return res.end();
}
await cache.delete(cacheKey);
} catch (error) {
logger.error(`[${originPath}] Error cancelling run`, error);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
let run;
try {
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error(`[${originPath}] Error fetching or processing run`, error);
}
let finalEvent;
try {
finalEvent = {
final: true,
conversation: await getConvo(req.user.id, conversationId),
};
} catch (error) {
logger.error(`[${originPath}] Error finalizing error process`, error);
return sendResponse(req, res, messageData, 'The Assistant run failed');
}
return sendResponse(req, res, finalEvent);
};
};
module.exports = { createErrorHandler };
@@ -0,0 +1,923 @@
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { Constants, actionDelimiter } = require('librechat-data-provider');
const { agentSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const d = Constants.mcp_delimiter;
const mockGetAllServerConfigs = jest.fn();
const mockUserCanUseMCPServers = jest.fn();
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn().mockResolvedValue({
web_search: true,
execute_code: true,
file_search: true,
}),
}));
jest.mock('~/config', () => ({
getMCPServersRegistry: jest.fn(() => ({
getAllServerConfigs: mockGetAllServerConfigs,
})),
}));
jest.mock('~/server/services/MCP', () => ({
resolveConfigServers: jest.fn().mockResolvedValue({}),
createMCPPermissionContext: jest.fn((req) => ({
canUseServers: (user) => mockUserCanUseMCPServers(user, req),
})),
userCanUseMCPServers: (...args) => mockUserCanUseMCPServers(...args),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/services/Files/images/avatar', () => ({
resizeAvatar: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
filterFile: jest.fn(),
}));
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
grantPermission: jest.fn(),
hasPublicPermission: jest.fn().mockResolvedValue(false),
checkPermission: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/models', () => {
const mongoose = require('mongoose');
const { createModels, createMethods } = require('@librechat/data-schemas');
createModels(mongoose);
const methods = createMethods(mongoose);
return {
...methods,
getCategoriesWithCounts: jest.fn(),
deleteFileByFilter: jest.fn(),
};
});
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
})),
}));
const {
filterAuthorizedTools,
createAgent: createAgentHandler,
updateAgent: updateAgentHandler,
duplicateAgent: duplicateAgentHandler,
revertAgentVersion: revertAgentVersionHandler,
} = require('./v1');
const { getMCPServersRegistry } = require('~/config');
let Agent;
describe('MCP Tool Authorization', () => {
let mongoServer;
let mockReq;
let mockRes;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
jest.clearAllMocks();
getMCPServersRegistry.mockImplementation(() => ({
getAllServerConfigs: mockGetAllServerConfigs,
}));
mockGetAllServerConfigs.mockResolvedValue({
authorizedServer: { type: 'sse', url: 'https://authorized.example.com' },
anotherServer: { type: 'sse', url: 'https://another.example.com' },
});
mockUserCanUseMCPServers.mockResolvedValue(true);
mockReq = {
user: {
id: new mongoose.Types.ObjectId().toString(),
role: 'USER',
},
body: {},
params: {},
query: {},
app: { locals: { fileStrategy: 'local' } },
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
});
describe('filterAuthorizedTools', () => {
const availableTools = { web_search: true, custom_tool: true };
const userId = 'test-user-123';
const testUser = { id: userId, role: 'USER' };
test('should keep authorized MCP tools and strip unauthorized ones', async () => {
const result = await filterAuthorizedTools({
tools: [`toolA${d}authorizedServer`, `toolB${d}forbiddenServer`, 'web_search'],
userId,
user: testUser,
availableTools,
});
expect(result).toContain(`toolA${d}authorizedServer`);
expect(result).toContain('web_search');
expect(result).not.toContain(`toolB${d}forbiddenServer`);
});
test('should strip MCP tools when user lacks MCP server use permission', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
const result = await filterAuthorizedTools({
tools: [
`toolA${d}authorizedServer`,
`${Constants.mcp_all}${d}authorizedServer`,
'web_search',
],
userId,
user: testUser,
availableTools,
});
expect(result).toEqual(['web_search']);
expect(mockUserCanUseMCPServers).toHaveBeenCalledWith({ id: userId, role: 'USER' });
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should strip MCP tools when user context is missing', async () => {
mockUserCanUseMCPServers.mockResolvedValueOnce(false);
const result = await filterAuthorizedTools({
tools: [`toolA${d}authorizedServer`, 'web_search'],
userId,
availableTools,
});
expect(result).toEqual(['web_search']);
expect(mockUserCanUseMCPServers).toHaveBeenCalledWith(undefined);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should keep system tools without querying MCP registry', async () => {
const result = await filterAuthorizedTools({
tools: ['execute_code', 'file_search', 'web_search', 'memory'],
userId,
availableTools: {},
});
expect(result).toEqual(['execute_code', 'file_search', 'web_search', 'memory']);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should not query MCP registry when no MCP tools are present', async () => {
const result = await filterAuthorizedTools({
tools: ['web_search', 'custom_tool'],
userId,
availableTools,
});
expect(result).toEqual(['web_search', 'custom_tool']);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should filter all MCP tools when registry is uninitialized', async () => {
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
const result = await filterAuthorizedTools({
tools: [`toolA${d}someServer`, 'web_search'],
userId,
user: testUser,
availableTools,
});
expect(result).toEqual(['web_search']);
expect(result).not.toContain(`toolA${d}someServer`);
});
test('should handle mixed authorized and unauthorized MCP tools', async () => {
const result = await filterAuthorizedTools({
tools: [
'web_search',
`search${d}authorizedServer`,
`attack${d}victimServer`,
'execute_code',
`list${d}anotherServer`,
`steal${d}nonexistent`,
],
userId,
user: testUser,
availableTools,
});
expect(result).toEqual([
'web_search',
`search${d}authorizedServer`,
'execute_code',
`list${d}anotherServer`,
]);
});
test('should handle empty tools array', async () => {
const result = await filterAuthorizedTools({
tools: [],
userId,
availableTools,
});
expect(result).toEqual([]);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should handle null/undefined tool entries gracefully', async () => {
const result = await filterAuthorizedTools({
tools: [null, undefined, '', 'web_search'],
userId,
availableTools,
});
expect(result).toEqual(['web_search']);
});
test('should call getAllServerConfigs with the correct userId', async () => {
await filterAuthorizedTools({
tools: [`tool${d}authorizedServer`],
userId: 'specific-user-id',
user: { id: 'specific-user-id', role: 'USER' },
availableTools,
});
expect(mockGetAllServerConfigs).toHaveBeenCalledWith('specific-user-id', undefined);
});
test('should pass configServers to getAllServerConfigs and allow config-override servers', async () => {
const configServers = {
'config-override-server': { type: 'sse', url: 'https://override.example.com' },
};
mockGetAllServerConfigs.mockResolvedValue({
'config-override-server': configServers['config-override-server'],
});
const result = await filterAuthorizedTools({
tools: [`tool${d}config-override-server`, `tool${d}unauthorizedServer`],
userId,
user: testUser,
availableTools,
configServers,
});
expect(mockGetAllServerConfigs).toHaveBeenCalledWith(userId, configServers);
expect(result).toContain(`tool${d}config-override-server`);
expect(result).not.toContain(`tool${d}unauthorizedServer`);
});
test('should only call getAllServerConfigs once even with multiple MCP tools', async () => {
await filterAuthorizedTools({
tools: [`tool1${d}authorizedServer`, `tool2${d}anotherServer`, `tool3${d}unknownServer`],
userId,
user: testUser,
availableTools,
});
expect(mockGetAllServerConfigs).toHaveBeenCalledTimes(1);
});
test('should preserve existing MCP tools when registry is unavailable', async () => {
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
const existingTools = [`toolA${d}serverA`, `toolB${d}serverB`];
const result = await filterAuthorizedTools({
tools: [...existingTools, `newTool${d}unknownServer`, 'web_search'],
userId,
user: testUser,
availableTools,
existingTools,
});
expect(result).toContain(`toolA${d}serverA`);
expect(result).toContain(`toolB${d}serverB`);
expect(result).toContain('web_search');
expect(result).not.toContain(`newTool${d}unknownServer`);
});
test('should still reject all MCP tools when registry is unavailable and no existingTools', async () => {
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
const result = await filterAuthorizedTools({
tools: [`toolA${d}serverA`, 'web_search'],
userId,
user: testUser,
availableTools,
});
expect(result).toEqual(['web_search']);
});
test('should not preserve malformed existing tools when registry is unavailable', async () => {
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
const malformedTool = `a${d}b${d}c`;
const result = await filterAuthorizedTools({
tools: [malformedTool, `legit${d}serverA`, 'web_search'],
userId,
user: testUser,
availableTools,
existingTools: [malformedTool, `legit${d}serverA`],
});
expect(result).toContain(`legit${d}serverA`);
expect(result).toContain('web_search');
expect(result).not.toContain(malformedTool);
});
test('should gate app-level MCP tools present in the global tool cache', async () => {
const appMcpTool = `appTool${d}authorizedServer`;
const forbiddenAppMcpTool = `appTool${d}forbiddenServer`;
const cacheWithMCPTools = {
...availableTools,
[appMcpTool]: true,
[forbiddenAppMcpTool]: true,
};
const result = await filterAuthorizedTools({
tools: [appMcpTool, forbiddenAppMcpTool, 'web_search'],
userId,
user: testUser,
availableTools: cacheWithMCPTools,
});
expect(result).toContain(appMcpTool);
expect(result).toContain('web_search');
expect(result).not.toContain(forbiddenAppMcpTool);
});
test('should strip app-level MCP tools from the cache when user lacks MCP server use permission', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
const appMcpTool = `appTool${d}authorizedServer`;
const cacheWithMCPTools = { ...availableTools, [appMcpTool]: true };
const result = await filterAuthorizedTools({
tools: [appMcpTool, 'web_search'],
userId,
user: testUser,
availableTools: cacheWithMCPTools,
});
expect(result).toEqual(['web_search']);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should reject malformed MCP tool keys with multiple delimiters', async () => {
const result = await filterAuthorizedTools({
tools: [
`attack${d}victimServer${d}authorizedServer`,
`legit${d}authorizedServer`,
`a${d}b${d}c${d}d`,
'web_search',
],
userId,
user: testUser,
availableTools,
});
expect(result).toEqual([`legit${d}authorizedServer`, 'web_search']);
expect(result).not.toContainEqual(expect.stringContaining('victimServer'));
expect(result).not.toContainEqual(expect.stringContaining(`a${d}b`));
});
});
describe('createAgentHandler - MCP tool authorization', () => {
test('should strip unauthorized MCP tools on create', async () => {
mockReq.body = {
provider: 'openai',
model: 'gpt-4',
name: 'MCP Test Agent',
tools: ['web_search', `validTool${d}authorizedServer`, `attack${d}forbiddenServer`],
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const agent = mockRes.json.mock.calls[0][0];
expect(agent.tools).toContain('web_search');
expect(agent.tools).toContain(`validTool${d}authorizedServer`);
expect(agent.tools).not.toContain(`attack${d}forbiddenServer`);
});
test('should strip all MCP tools on create when user lacks MCP server use permission', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
mockReq.body = {
provider: 'openai',
model: 'gpt-4',
name: 'MCP Denied Test Agent',
tools: [
'web_search',
`validTool${d}authorizedServer`,
`${Constants.mcp_all}${d}authorizedServer`,
],
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const agent = mockRes.json.mock.calls[0][0];
expect(agent.tools).toEqual(['web_search']);
expect(agent.mcpServerNames).toEqual([]);
});
test('should not 500 when MCP registry is uninitialized', async () => {
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
mockReq.body = {
provider: 'openai',
model: 'gpt-4',
name: 'MCP Uninitialized Test',
tools: [`tool${d}someServer`, 'web_search'],
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const agent = mockRes.json.mock.calls[0][0];
expect(agent.tools).toEqual(['web_search']);
});
test('should store mcpServerNames only for authorized servers', async () => {
mockReq.body = {
provider: 'openai',
model: 'gpt-4',
name: 'MCP Names Test',
tools: [`toolA${d}authorizedServer`, `toolB${d}forbiddenServer`],
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const agent = mockRes.json.mock.calls[0][0];
const agentInDb = await Agent.findOne({ id: agent.id });
expect(agentInDb.mcpServerNames).toContain('authorizedServer');
expect(agentInDb.mcpServerNames).not.toContain('forbiddenServer');
});
});
describe('updateAgentHandler - MCP tool authorization', () => {
let existingAgentId;
let existingAgentAuthorId;
beforeEach(async () => {
existingAgentAuthorId = new mongoose.Types.ObjectId();
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Original Agent',
provider: 'openai',
model: 'gpt-4',
author: existingAgentAuthorId,
tools: ['web_search', `existingTool${d}authorizedServer`],
mcpServerNames: ['authorizedServer'],
versions: [
{
name: 'Original Agent',
provider: 'openai',
model: 'gpt-4',
tools: ['web_search', `existingTool${d}authorizedServer`],
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
existingAgentId = agent.id;
});
test('should preserve existing MCP tools even if editor lacks access', async () => {
mockGetAllServerConfigs.mockResolvedValue({});
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
tools: ['web_search', `existingTool${d}authorizedServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
expect(updatedAgent.tools).toContain('web_search');
});
test('should reject newly added unauthorized MCP tools', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
tools: ['web_search', `existingTool${d}authorizedServer`, `attack${d}forbiddenServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tools).toContain('web_search');
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
expect(updatedAgent.tools).not.toContain(`attack${d}forbiddenServer`);
});
test('should strip all MCP tools, including retained ones, when user lacks MCP server use permission', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
// Permission revoked: update must not preserve stale MCP bindings, matching
// the create/duplicate/revert paths.
expect(updatedAgent.tools).toEqual(['web_search']);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should strip retained MCP tools on an unrelated owner edit after permission revocation', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Renamed After Revocation',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tools).toEqual(['web_search']);
expect(updatedAgent.name).toBe('Renamed After Revocation');
});
test('should not strip shared agent MCP tools on unrelated editor changes after revocation', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
mockReq.user.id = new mongoose.Types.ObjectId().toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Shared Rename After Revocation',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
expect(updatedAgent.name).toBe('Shared Rename After Revocation');
expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
});
test('should not strip shared agent MCP tools on frontend-style full tools save after revocation', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
mockReq.user.id = new mongoose.Types.ObjectId().toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Shared Full Save After Revocation',
tools: ['web_search', `existingTool${d}authorizedServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
expect(updatedAgent.name).toBe('Shared Full Save After Revocation');
expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should reject new shared-agent MCP tools after revocation while retaining existing MCP tools', async () => {
mockUserCanUseMCPServers.mockResolvedValue(false);
mockReq.user.id = new mongoose.Types.ObjectId().toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
expect(updatedAgent.tools).not.toContain(`newTool${d}anotherServer`);
expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
expect(agentInDb.tools).not.toContain(`newTool${d}anotherServer`);
expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should not strip action tools whose operationId contains the MCP delimiter on revocation', async () => {
// `sync_mcp_state_action_...` contains the `_mcp_` substring but is a
// genuine OpenAPI action tool (isActionTool === true). Losing
// MCP_SERVERS.USE must not drop it — action use is unrelated to MCP.
const actionTool = `sync_mcp_state${actionDelimiter}api---example---com`;
await Agent.updateOne(
{ id: existingAgentId },
{ $set: { tools: ['web_search', actionTool] } },
);
mockUserCanUseMCPServers.mockResolvedValue(false);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Edited Without MCP Permission',
tools: ['web_search', actionTool],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(updatedAgent.tools).toContain(actionTool);
expect(updatedAgent.tools).toContain('web_search');
expect(agentInDb.mcpServerNames).toEqual([]);
});
test('should allow adding authorized MCP tools', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tools).toContain(`newTool${d}anotherServer`);
});
test('should not query MCP registry when no new MCP tools added', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
tools: ['web_search', `existingTool${d}authorizedServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});
test('should preserve existing MCP tools when registry unavailable and user edits agent', async () => {
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Renamed After Restart',
tools: ['web_search', `existingTool${d}authorizedServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
expect(updatedAgent.tools).toContain('web_search');
expect(updatedAgent.name).toBe('Renamed After Restart');
});
test('should preserve existing MCP tools when server not in configs (disconnected)', async () => {
mockGetAllServerConfigs.mockResolvedValue({});
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Edited While Disconnected',
tools: ['web_search', `existingTool${d}authorizedServer`],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
expect(updatedAgent.name).toBe('Edited While Disconnected');
});
});
describe('duplicateAgentHandler - MCP tool authorization', () => {
let sourceAgentId;
let sourceAgentAuthorId;
beforeEach(async () => {
sourceAgentAuthorId = new mongoose.Types.ObjectId();
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Source Agent',
provider: 'openai',
model: 'gpt-4',
author: sourceAgentAuthorId,
tools: ['web_search', `tool${d}authorizedServer`, `tool${d}forbiddenServer`],
mcpServerNames: ['authorizedServer', 'forbiddenServer'],
versions: [
{
name: 'Source Agent',
provider: 'openai',
model: 'gpt-4',
tools: ['web_search', `tool${d}authorizedServer`, `tool${d}forbiddenServer`],
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
sourceAgentId = agent.id;
});
test('should strip unauthorized MCP tools from duplicated agent', async () => {
mockGetAllServerConfigs.mockResolvedValue({
authorizedServer: { type: 'sse' },
});
mockReq.user.id = sourceAgentAuthorId.toString();
mockReq.params.id = sourceAgentId;
await duplicateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const { agent: newAgent } = mockRes.json.mock.calls[0][0];
expect(newAgent.id).not.toBe(sourceAgentId);
expect(newAgent.tools).toContain('web_search');
expect(newAgent.tools).toContain(`tool${d}authorizedServer`);
expect(newAgent.tools).not.toContain(`tool${d}forbiddenServer`);
const agentInDb = await Agent.findOne({ id: newAgent.id });
expect(agentInDb.mcpServerNames).toContain('authorizedServer');
expect(agentInDb.mcpServerNames).not.toContain('forbiddenServer');
});
test('should preserve source agent MCP tools when registry is unavailable', async () => {
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
mockReq.user.id = sourceAgentAuthorId.toString();
mockReq.params.id = sourceAgentId;
await duplicateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const { agent: newAgent } = mockRes.json.mock.calls[0][0];
expect(newAgent.tools).toContain('web_search');
expect(newAgent.tools).toContain(`tool${d}authorizedServer`);
expect(newAgent.tools).toContain(`tool${d}forbiddenServer`);
});
});
describe('revertAgentVersionHandler - MCP tool authorization', () => {
let existingAgentId;
let existingAgentAuthorId;
beforeEach(async () => {
existingAgentAuthorId = new mongoose.Types.ObjectId();
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Reverted Agent V2',
provider: 'openai',
model: 'gpt-4',
author: existingAgentAuthorId,
tools: ['web_search'],
versions: [
{
name: 'Reverted Agent V1',
provider: 'openai',
model: 'gpt-4',
tools: ['web_search', `oldTool${d}revokedServer`],
createdAt: new Date(Date.now() - 10000),
updatedAt: new Date(Date.now() - 10000),
},
{
name: 'Reverted Agent V2',
provider: 'openai',
model: 'gpt-4',
tools: ['web_search'],
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
existingAgentId = agent.id;
});
test('should strip unauthorized MCP tools after reverting to a previous version', async () => {
mockGetAllServerConfigs.mockResolvedValue({
authorizedServer: { type: 'sse' },
});
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = { version_index: 0 };
await revertAgentVersionHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const result = mockRes.json.mock.calls[0][0];
expect(result.tools).toContain('web_search');
expect(result.tools).not.toContain(`oldTool${d}revokedServer`);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.tools).toContain('web_search');
expect(agentInDb.tools).not.toContain(`oldTool${d}revokedServer`);
});
test('should keep authorized MCP tools after revert', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{ $set: { 'versions.0.tools': ['web_search', `tool${d}authorizedServer`] } },
);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = { version_index: 0 };
await revertAgentVersionHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const result = mockRes.json.mock.calls[0][0];
expect(result.tools).toContain('web_search');
expect(result.tools).toContain(`tool${d}authorizedServer`);
});
test('should preserve version MCP tools when registry is unavailable on revert', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
$set: {
'versions.0.tools': [
'web_search',
`validTool${d}authorizedServer`,
`otherTool${d}anotherServer`,
],
},
},
);
getMCPServersRegistry.mockImplementation(() => {
throw new Error('MCPServersRegistry has not been initialized.');
});
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = { version_index: 0 };
await revertAgentVersionHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const result = mockRes.json.mock.calls[0][0];
expect(result.tools).toContain('web_search');
expect(result.tools).toContain(`validTool${d}authorizedServer`);
expect(result.tools).toContain(`otherTool${d}anotherServer`);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.tools).toContain(`validTool${d}authorizedServer`);
expect(agentInDb.tools).toContain(`otherTool${d}anotherServer`);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,358 @@
/**
* Tests for AgentClient.recordCollectedUsage
*
* This is a critical function that handles token spending for agent LLM calls.
* The client now delegates to the TS recordCollectedUsage from @librechat/api,
* passing pricing and bulkWriteOps deps.
*/
const { EModelEndpoint } = require('librechat-data-provider');
const mockSpendTokens = jest.fn().mockResolvedValue();
const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
jest.mock('~/models', () => ({
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));
jest.mock('~/config', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
getMCPManager: jest.fn(() => ({
formatInstructionsForContext: jest.fn(),
})),
}));
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
createMetadataAggregator: () => ({
handleLLMEnd: jest.fn(),
collected: [],
}),
}));
jest.mock('@librechat/api', () => {
const actual = jest.requireActual('@librechat/api');
return {
...actual,
recordCollectedUsage: (...args) => mockRecordCollectedUsage(...args),
};
});
const AgentClient = require('./client');
describe('AgentClient - recordCollectedUsage', () => {
let client;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-4',
},
};
mockOptions = {
req: {
user: { id: 'user-123' },
body: { model: 'gpt-4', endpoint: EModelEndpoint.openAI },
},
res: {},
agent: mockAgent,
endpointTokenConfig: {},
};
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.user = 'user-123';
});
describe('basic functionality', () => {
it('should delegate to recordCollectedUsage with full deps', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
const [deps, params] = mockRecordCollectedUsage.mock.calls[0];
expect(deps).toHaveProperty('spendTokens');
expect(deps).toHaveProperty('spendStructuredTokens');
expect(deps).toHaveProperty('pricing');
expect(deps.pricing).toHaveProperty('getMultiplier');
expect(deps.pricing).toHaveProperty('getCacheMultiplier');
expect(deps).toHaveProperty('bulkWriteOps');
expect(deps.bulkWriteOps).toHaveProperty('insertMany');
expect(deps.bulkWriteOps).toHaveProperty('updateBalance');
expect(params).toEqual(
expect.objectContaining({
user: 'user-123',
conversationId: 'convo-123',
collectedUsage,
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
}),
);
});
it('should not set this.usage if collectedUsage is empty (returns undefined)', async () => {
mockRecordCollectedUsage.mockResolvedValue(undefined);
await client.recordCollectedUsage({
collectedUsage: [],
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage).toBeUndefined();
});
it('should not set this.usage if collectedUsage is null (returns undefined)', async () => {
mockRecordCollectedUsage.mockResolvedValue(undefined);
await client.recordCollectedUsage({
collectedUsage: null,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage).toBeUndefined();
});
it('should set this.usage from recordCollectedUsage result', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 75 });
const collectedUsage = [{ input_tokens: 200, output_tokens: 75, model: 'gpt-4' }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage).toEqual({ input_tokens: 200, output_tokens: 75 });
});
});
describe('sequential execution (single agent with tool calls)', () => {
it('should pass all usage entries to recordCollectedUsage', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 150, output_tokens: 30, model: 'gpt-4' },
{ input_tokens: 180, output_tokens: 20, model: 'gpt-4' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 100 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.collectedUsage).toHaveLength(3);
expect(client.usage.output_tokens).toBe(100);
expect(client.usage.input_tokens).toBe(100);
});
});
describe('parallel execution (multiple agents)', () => {
it('should pass parallel agent usage to recordCollectedUsage', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'gpt-4' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 90 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(client.usage.output_tokens).toBe(90);
expect(client.usage.output_tokens).toBeGreaterThan(0);
});
/** Bug regression: parallel agents where second agent has LOWER input tokens produced negative output via incremental calculation. */
it('should NOT produce negative output_tokens', async () => {
const collectedUsage = [
{ input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
{ input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage.output_tokens).toBeGreaterThan(0);
expect(client.usage.output_tokens).toBe(130);
});
});
describe('real-world scenarios', () => {
it('should correctly handle sequential tool calls with growing context', async () => {
const collectedUsage = [
{ input_tokens: 31596, output_tokens: 151, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 35368, output_tokens: 150, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 58362, output_tokens: 295, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 112604, output_tokens: 193, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 257440, output_tokens: 2217, model: 'claude-opus-4-5-20251101' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 3006 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage.input_tokens).toBe(31596);
expect(client.usage.output_tokens).toBe(3006);
});
it('should correctly handle cache tokens', async () => {
const collectedUsage = [
{
input_tokens: 788,
output_tokens: 163,
input_token_details: { cache_read: 0, cache_creation: 30808 },
model: 'claude-opus-4-5-20251101',
},
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 163 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage.input_tokens).toBe(31596);
expect(client.usage.output_tokens).toBe(163);
});
});
describe('model fallback', () => {
it('should use param model when available', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
await client.recordCollectedUsage({
model: 'param-model',
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.model).toBe('param-model');
});
it('should fallback to client.model when param model is missing', async () => {
client.model = 'client-model';
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.model).toBe('client-model');
});
it('should fallback to agent model_parameters.model as last resort', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.model).toBe('gpt-4');
});
});
describe('getStreamUsage integration', () => {
it('should return the usage object set by recordCollectedUsage', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const usage = client.getStreamUsage();
expect(usage).toEqual({ input_tokens: 100, output_tokens: 50 });
});
it('should return undefined before recordCollectedUsage is called', () => {
const usage = client.getStreamUsage();
expect(usage).toBeUndefined();
});
/** Verifies usage passes the check in BaseClient.sendMessage: if (usage != null && Number(usage[this.outputTokensKey]) > 0) */
it('should have output_tokens > 0 for BaseClient.sendMessage check', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
const collectedUsage = [
{ input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
{ input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const usage = client.getStreamUsage();
expect(usage).not.toBeNull();
expect(Number(usage.output_tokens)).toBeGreaterThan(0);
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+725
View File
@@ -0,0 +1,725 @@
const { logger } = require('@librechat/data-schemas');
const { Constants, EModelEndpoint } = require('librechat-data-provider');
const {
GenerationJobManager,
isPendingActionStale,
mapToolApprovalResolutions,
mapAskUserAnswer,
attachAskUserQuestionAnswer,
findUndecidedToolCalls,
findDisallowedDecisions,
findIncompleteDecisions,
computeAgentRequestFingerprint,
deleteAgentCheckpoint,
buildAbortedResponseMetadata,
sanitizeMessageForTransmit,
filterMalformedContentParts,
decrementPendingRequest,
checkAndIncrementPendingRequest,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
getMCPRequestContext,
cleanupMCPRequestContextForReq,
} = require('~/server/services/MCPRequestContext');
const { saveMessage, getConvo, getMessages } = require('~/models');
/**
* Upper bound on an `ask_user_question` answer (characters). Generous for any real
* reply typed into the question card while still bounding what a crafted POST can
* inject into the resumed run's ToolMessage.
*/
const MAX_ASK_ANSWER_LENGTH = 16_000;
/** De-duplicate a merged attachment list by a stable artifact identity. */
function mergeAttachments(existing, incoming) {
const seen = new Set();
const out = [];
for (const attachment of [...(existing ?? []), ...(incoming ?? [])]) {
if (!attachment) {
continue;
}
const key =
attachment.file_id ??
attachment.filepath ??
attachment.filename ??
JSON.stringify(attachment);
if (seen.has(key)) {
continue;
}
seen.add(key);
out.push(attachment);
}
return out;
}
/**
* Resolve the current segment's tool artifacts and merge them with any already
* persisted on the response row. A resumed turn can span multiple pause segments;
* each rebuilt client has its own `artifactPromises`, and the final finalize would
* otherwise OVERWRITE the row's attachments with only the last segment's. Reading
* the persisted row and merging keeps every segment's artifacts on the saved message.
*/
async function resolveAccumulatedAttachments({ client, conversationId, responseMessageId }) {
const promises = Array.isArray(client?.artifactPromises) ? client.artifactPromises : [];
const resolved = promises.length > 0 ? (await Promise.all(promises)).filter(Boolean) : [];
let existing = [];
if (responseMessageId) {
try {
const [row] = await getMessages(
{ conversationId, messageId: responseMessageId },
'attachments',
);
existing = Array.isArray(row?.attachments) ? row.attachments : [];
} catch (err) {
logger.warn(
'[ResumeAgentController] Failed to read prior attachments for merge',
err?.message ?? err,
);
}
}
return mergeAttachments(existing, resolved);
}
/** Resolve the segment's content for an unfinished save (mirrors finalize's source). */
async function resolveSegmentContent(client, streamId) {
const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
const rawContent =
liveContent.length > 0
? liveContent
: ((await GenerationJobManager.getResumeState(streamId))?.aggregatedContent ?? []);
return filterMalformedContentParts(rawContent);
}
/**
* A resumed segment that streamed content / produced artifacts and then paused AGAIN
* must persist that progress before returning. The next resume rebuilds a fresh client
* (empty `contentParts`/`artifactPromises`), so without this an approval that later
* expires or is reaped would leave only the EARLIER pause's content on the saved row —
* the user loses everything streamed during this segment. Saved as a partial (`$set`,
* still `unfinished`) so a subsequent successful resume overwrites it on finalize.
*/
async function persistRePauseProgress({ req, client, job, streamId, conversationId }) {
const userId = req.user.id;
const meta = job.metadata ?? {};
const responseMessageId = meta.responseMessageId ?? client.responseMessageId;
if (!responseMessageId) {
return;
}
const content = await resolveSegmentContent(client, streamId);
const attachments = await resolveAccumulatedAttachments({
client,
conversationId,
responseMessageId,
});
if (content.length === 0 && attachments.length === 0) {
return;
}
try {
await saveMessage(
{
userId,
isTemporary: meta.isTemporary ?? req.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{
messageId: responseMessageId,
conversationId,
...(content.length > 0 && { content }),
...(attachments.length > 0 && { attachments }),
unfinished: true,
user: userId,
},
{ context: 'api/server/controllers/agents/resume.js - re-pause progress persist' },
);
} catch (err) {
logger.error('[ResumeAgentController] Failed to persist re-pause progress', err);
}
}
/** Untenanted jobs (pre-multi-tenancy) remain accessible if the userId check passes. */
function hasTenantMismatch(job, user) {
return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId;
}
/**
* Build the SDK resume value from the wire decision payload, validating against the
* pending action. Returns `{ resumeValue }` on success or `{ error }` with an HTTP
* status for the route to surface.
*/
function resolveResumeValue(pendingAction, body) {
const payload = pendingAction.payload;
if (payload?.type === 'tool_approval') {
const resolutions = Array.isArray(body.decisions) ? body.decisions : [];
const undecided = findUndecidedToolCalls(payload, resolutions);
if (undecided.length > 0) {
return { status: 400, error: 'Every paused tool call must be decided', undecided };
}
// Enforce the policy's per-tool allowed_decisions — a crafted POST must not
// approve a tool the policy restricted to (e.g.) reject/respond.
const disallowed = findDisallowedDecisions(payload, resolutions);
if (disallowed.length > 0) {
return { status: 403, error: 'Decision not permitted for one or more tools', disallowed };
}
// `edit`/`respond` must carry their payload — otherwise toSdkDecision's defensive
// defaults ({} / '') would resume with an empty input/result the user didn't approve.
const incomplete = findIncompleteDecisions(resolutions);
if (incomplete.length > 0) {
return {
status: 400,
error: 'edit requires editedArguments and respond requires responseText',
incomplete,
};
}
return { resumeValue: mapToolApprovalResolutions(resolutions) };
}
if (payload?.type === 'ask_user_question') {
if (typeof body.answer !== 'string' || body.answer.length === 0) {
return { status: 400, error: 'An answer is required' };
}
// The answer becomes a ToolMessage the model must ingest — bound it like any
// other user-controlled wire field rather than trusting the client.
if (body.answer.length > MAX_ASK_ANSWER_LENGTH) {
return { status: 400, error: 'Answer exceeds the maximum length' };
}
return { resumeValue: mapAskUserAnswer({ answer: body.answer }) };
}
return { status: 400, error: 'Unsupported pending action type' };
}
/**
* Finalize a resumed turn that ran to completion: persist the (now complete)
* response message, emit the terminal event over the existing SSE, complete the
* job, and prune the checkpoint. Mirrors the abort route's save shape but for a
* successful finish. Best-effort title generation for a first-turn pause.
*/
async function finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle }) {
const userId = req.user.id;
const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
const meta = job.metadata ?? {};
const userMessage = meta.userMessage;
// The response hangs off the user message; the *user* message's own parent decides
// whether this is the first turn of the conversation (title eligibility).
const parentMessageId = userMessage?.messageId ?? Constants.NO_PARENT;
const isFirstTurn = (userMessage?.parentMessageId ?? Constants.NO_PARENT) === Constants.NO_PARENT;
const responseMessageId = meta.responseMessageId ?? `${userMessage?.messageId ?? 'resumed'}_`;
// Sourced from the paused job (persisted at creation), not the resume body — a
// temporary chat must stay temporary on resume so its messages aren't persisted.
const isTemporary = meta.isTemporary ?? req.body?.isTemporary;
// Read the raw job data BEFORE completeJob deletes it — its tracked token/context
// usage backs the response message's cost rollup (parity with normal completion).
const jobData = await GenerationJobManager.getJobStore().getJob(streamId);
// Job-replacement guard (mirrors the normal request path): jobs are keyed by streamId
// (== conversationId), so a new/concurrent request reusing this conversation overwrites
// the record with a fresh createdAt. If that happened while we were resuming, finalizing
// now would emit `done` to / complete / delete the NEWER turn's job. Skip all terminal
// side effects when the job we paused is no longer the live one; the caller's `finally`
// still disposes the client + releases the slot.
if (!jobData || jobData.createdAt !== job.createdAt) {
logger.warn(
`[ResumeAgentController] Skipping resumed finalization — job ${streamId} was replaced`,
);
return;
}
// Prefer the resumed run's live content: it's complete (seeded with the pre-pause
// content) and avoids a Redis re-read that can race appendChunk writes still in
// flight. Fall back to the aggregated store content only when the live array is empty.
const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
const rawContent =
liveContent.length > 0
? liveContent
: ((await GenerationJobManager.getResumeState(streamId))?.aggregatedContent ?? []);
// Parity with the normal agents path (AgentClient strips these before saving):
// drop empty/malformed tool_call parts so a resumed turn can't persist an invalid
// part that breaks reload/rendering.
const content = filterMalformedContentParts(rawContent);
const responseMessage = {
messageId: responseMessageId,
parentMessageId,
conversationId,
content,
sender: meta.sender ?? client?.sender ?? 'AI',
endpoint: meta.endpoint,
iconURL: meta.iconURL,
model: meta.model,
unfinished: false,
error: false,
isCreatedByUser: false,
user: userId,
};
if (meta.agent_id ?? req.body?.agent_id) {
responseMessage.agent_id = meta.agent_id ?? req.body.agent_id;
}
// Persist tool artifacts (code files, images, UI resources) the resumed continuation
// produced — BaseClient.sendMessage awaits these before saving, but the lean resume
// path bypasses it, so do it here or they vanish on reload / for late subscribers.
// MERGE with any already on the row (earlier pause segments) rather than overwrite —
// the final segment's client only holds its own segment's artifacts.
const attachments = await resolveAccumulatedAttachments({
client,
conversationId,
responseMessageId,
});
if (attachments.length > 0) {
responseMessage.attachments = attachments;
}
// Response metadata: the resume client only sees POST-resume usage, while the job's
// tracked tokenUsage is cumulative across the pause. Take the cumulative usage (+
// summary marker) from the job, and contextUsage / thoughtSignatures from the client
// (which the abort-only helper drops). Cumulative usage wins so cost isn't underreported.
const clientMeta = client?.buildResponseMetadata?.() ?? null;
const cumulativeMeta = jobData ? buildAbortedResponseMetadata(jobData) : null;
const responseMetadata = {
...(clientMeta ?? {}),
...(cumulativeMeta?.usage ? { usage: cumulativeMeta.usage } : {}),
...(cumulativeMeta?.summaryUsedTokens != null
? { summaryUsedTokens: cumulativeMeta.summaryUsedTokens }
: {}),
};
if (Object.keys(responseMetadata).length > 0) {
responseMessage.metadata = responseMetadata;
}
// Carry the resumed run's context-window calibration (BaseClient.sendMessage persists
// this on the response). Without it, the NEXT turn can't seed its pruner from this
// run and falls back to uncalibrated token accounting.
if (client?.contextMeta != null) {
responseMessage.contextMeta = client.contextMeta;
}
await saveMessage(
{ userId, isTemporary, interfaceConfig: req?.config?.interfaceConfig },
responseMessage,
{ context: 'api/server/controllers/agents/resume.js - resumed response end' },
);
const convo = await getConvo(userId, conversationId);
const conversation = { ...(convo ?? {}), conversationId };
// First-turn pause: the title was deferred when the turn paused. Generate it BEFORE
// completing the stream so the `title` event still reaches the live client (emitChunk
// no-ops once completeJob tears down the runtime) and the final event carries the real
// title instead of "New Chat". Best-effort — a failure must not fail the resumed turn.
if (
addTitle &&
isFirstTurn &&
!isTemporary &&
userMessage?.text &&
(!convo || !convo.title || convo.title === 'New Chat')
) {
try {
await addTitle(req, {
text: userMessage.text,
conversationId,
client,
onTitleGenerated: ({ conversationId: titleConvoId, title }) => {
conversation.title = title;
return GenerationJobManager.emitChunk(streamId, {
event: 'title',
data: { conversationId: titleConvoId, title },
});
},
});
} catch (err) {
logger.error('[ResumeAgentController] Title generation failed after resume', err);
}
}
conversation.title = conversation.title || 'New Chat';
// Re-check ownership immediately before the terminal writes. The start-of-function
// guard can go stale across the awaits above: saveMessage and (first-turn) title
// generation can take long enough for a new request to replace this job on the same
// conversationId (streamId == conversationId). Without this second read, emitDone /
// completeJob / prune below would emit `done` to and tear down the REPLACEMENT job —
// the same hazard the catch-path guard prevents on the failure path.
const liveJobBeforeFinalize = await GenerationJobManager.getJobStore().getJob(streamId);
if (!liveJobBeforeFinalize || liveJobBeforeFinalize.createdAt !== job.createdAt) {
logger.warn(
`[ResumeAgentController] Skipping resumed terminal writes — job ${streamId} was replaced mid-finalize`,
);
return;
}
const finalEvent = {
final: true,
conversation,
title: conversation.title,
requestMessage: userMessage
? sanitizeMessageForTransmit({
...userMessage,
conversationId,
isCreatedByUser: true,
// job.metadata.userMessage is persisted without files; carry the restored
// uploads (seeded onto req.body.files before reconstruction) so the final SSE
// doesn't blank the user bubble's attachments — matching the normal path.
...(Array.isArray(req.body?.files) && req.body.files.length > 0
? { files: req.body.files }
: {}),
})
: null,
responseMessage: { ...responseMessage },
};
await GenerationJobManager.emitDone(streamId, finalEvent);
// Awaited (not fire-and-forget) so the job's terminal write lands before the
// checkpoint prune, and so a failure here doesn't race the controller's error path.
try {
await GenerationJobManager.completeJob(streamId);
} catch (completeErr) {
logger.error('[ResumeAgentController] Failed to complete resumed turn', completeErr);
}
await deleteAgentCheckpoint(conversationId, checkpointerCfg);
}
/**
* Resume a generation that paused for human-in-the-loop review.
*
* The original run lives in a detached background task that exits when the run
* pauses, so this REBUILDS the run from the durable checkpoint (same `thread_id`)
* and continues it with the user's decision. The continuation streams over the
* client's existing SSE (events flow through the same `streamId`).
*
* Flow: authorize → map decisions → atomically claim the resume (single-winner) →
* ACK → reconstruct the client → `resumeCompletion` → finalize (or re-pause).
*
* Shares chat.js's middleware (auth, agent access, `buildEndpointOption`) so the
* agent/endpoint are reconstructed from the request exactly like a normal turn.
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
* @param {Function} initializeClient
* @param {Function} addTitle
*/
const ResumeAgentController = async (req, res, next, initializeClient, addTitle) => {
const userId = req.user.id;
const { conversationId, actionId } = req.body;
const streamId = conversationId;
if (!streamId || streamId === 'new') {
return res.status(400).json({ error: 'conversationId is required to resume' });
}
const job = await GenerationJobManager.getJob(streamId);
if (!job) {
return res.status(404).json({ error: 'No paused generation for this conversation' });
}
if (job.metadata?.userId && job.metadata.userId !== userId) {
return res.status(403).json({ error: 'Unauthorized' });
}
if (hasTenantMismatch(job, req.user)) {
return res.status(403).json({ error: 'Unauthorized' });
}
// The resume must rebuild the SAME agent/endpoint that paused. Require an EXACT
// agent_id match when the paused job had one — a request that omits agent_id (or
// claims an ephemeral / non-agents endpoint) must not rebuild the claimed checkpoint
// on a different graph. The conversation's agent is stable, so a correct client always
// sends the right one.
const originalAgentId = job.metadata?.agent_id;
if (originalAgentId && req.body.agent_id !== originalAgentId) {
return res.status(403).json({ error: 'Cannot resume with a different agent' });
}
// Require an EXACT endpoint match (like agent_id): a request that OMITS endpoint must
// not fall through — the shared chat middleware treats a missing/non-agents endpoint
// as the ephemeral agent, so omitting it could rebuild the claimed checkpoint on a
// different graph. A correct client always echoes the paused endpoint.
const originalEndpoint = job.metadata?.endpoint;
if (originalEndpoint && req.body.endpoint !== originalEndpoint) {
return res.status(403).json({ error: 'Cannot resume on a different endpoint' });
}
const pendingAction = job.metadata?.pendingAction;
if (job.status !== 'requires_action') {
return res.status(409).json({ error: 'No live pending action to resume' });
}
if (isPendingActionStale({ pendingAction })) {
// The action expired between the pending-action SSE and this submit. Drive the expiry
// NOW (expire CAS + terminal SSE) instead of waiting for the periodic sweeper —
// otherwise the job sits `requires_action` with a dead action and any attached SSE
// client never gets a terminal event, so the stream appears to hang even though the
// UI already reported the action as expired.
try {
await GenerationJobManager.expireApproval(streamId, pendingAction?.actionId);
} catch (err) {
logger.warn(
'[ResumeAgentController] Failed to expire stale action on submit',
err?.message ?? err,
);
}
return res.status(409).json({ error: 'No live pending action to resume' });
}
// Require the actionId the UI sends: without it, a stale/malformed client could
// resolve whatever action is currently pending (e.g. answer a different question).
if (!actionId) {
return res.status(400).json({ error: 'actionId is required to resume' });
}
if (pendingAction.actionId !== actionId) {
return res.status(409).json({ error: 'This decision targets a stale action' });
}
// Pin the graph identity: the resume must rebuild the SAME agent/graph + tool set the
// run paused on. The agent_id + endpoint guards above cover saved agents; the
// fingerprint additionally catches an ephemeral-agent config swap (its agent_id is
// undefined, so the id guard can't tell two ephemeral configs apart). Enforced only
// when the paused action carries a fingerprint (in-flight pauses from before this
// change won't), and recomputed from the resume body's graph-determining fields.
const pinnedFingerprint = pendingAction.requestFingerprint;
if (pinnedFingerprint && pinnedFingerprint !== computeAgentRequestFingerprint(req.body ?? {})) {
return res.status(403).json({ error: 'Cannot resume with a different agent configuration' });
}
const mapped = resolveResumeValue(pendingAction, req.body);
if (mapped.error) {
return res.status(mapped.status).json({
error: mapped.error,
...(mapped.undecided && { undecided: mapped.undecided }),
...(mapped.disallowed && { disallowed: mapped.disallowed }),
...(mapped.incomplete && { incomplete: mapped.incomplete }),
});
}
// Count the resume against the concurrency limit. The original turn released its slot
// when it paused, so resuming must re-acquire one — otherwise pausing several turns
// and resuming them at once would bypass LIMIT_CONCURRENT_MESSAGES.
const { allowed } = await checkAndIncrementPendingRequest(userId);
if (!allowed) {
return res.status(429).json({ error: 'Too many concurrent requests' });
}
// Atomically claim the resume. The single winner drives the run; a racing second
// submit (double-click, two tabs) gets false and must not re-drive — that would
// re-execute tools and double-bill.
//
// The claim runs AFTER the slot increment above but BEFORE the run's own try/finally
// that releases it, so a store/Redis error here (unlike the clean `!claimed` branch)
// would leak the concurrency slot until the counter TTL expires — spuriously 429'ing
// the user when they retry the still-paused approval. Release the slot on that path too.
let claimed;
try {
claimed = await GenerationJobManager.approvals.resolve(streamId, pendingAction.actionId);
} catch (err) {
await decrementPendingRequest(userId);
logger.error('[ResumeAgentController] Failed to claim resume', err);
return res.status(500).json({ error: 'Failed to resume' });
}
if (!claimed) {
await decrementPendingRequest(userId);
return res.status(409).json({ error: 'This action was already resolved or has expired' });
}
// Seed the run-scoped MCP request-context store BEFORE the ACK: once `res.json`
// finishes the response, a later `getMCPRequestContext(req, res)` (from tool loading)
// sees `res` as ended and returns undefined, leaving the resumed run without its MCP
// connection store — approved MCP / OAuth-overlay tools would then run without their
// request-scoped connections. Pre-seeding with a null `res` + `cleanupOnResponse:false`
// mirrors the normal stream path (request.js); torn down in the `finally` below.
req._resumableStreamId = streamId;
getMCPRequestContext(req, undefined, { cleanupOnResponse: false });
// ACK immediately; the continuation streams over the client's existing SSE.
res.json({ streamId, conversationId, status: 'resuming' });
// Seed the original thread parent BEFORE initializeClient: initializeAgent scopes
// thread files / code artifacts off `req.body.parentMessageId`, and the resume body
// doesn't carry it. This is the user message's parent (the thread position);
// `client.parentMessageId` below is a different value — the response's parent, i.e.
// the user message id.
req.body.parentMessageId = job.metadata.userMessage?.parentMessageId ?? Constants.NO_PARENT;
// Restore the paused user message's OWN uploaded files. initializeAgent rebuilds
// code/file sessions by walking the conversation from `parentMessageId`, but
// execute-code files are excluded from that lookup, so files uploaded on the paused
// turn would be dropped — an approved code/read-file tool would resume without them.
//
// SECURITY: ALWAYS source files from the paused job, never from the `/resume` body.
// `files` is not pinned by the resume fingerprint or replayed via resumeContext, so
// honoring a client-supplied `files` array would let a crafted/buggy client resume an
// approved code/read-file tool against a DIFFERENT file set than the one the user
// approved. A resume reconstructs the SAME paused turn, so there is no legitimate
// reason for the client to supply its own files. Prefer the files persisted on the JOB
// at onStart (race-free), fall back to the DB row for older jobs, and CLEAR otherwise
// so a client-supplied set can never leak through.
const metaFiles = job.metadata.userMessage?.files;
if (Array.isArray(metaFiles) && metaFiles.length > 0) {
req.body.files = metaFiles;
} else {
let restoredFiles = false;
const pausedUserMessageId = job.metadata.userMessage?.messageId;
if (pausedUserMessageId) {
try {
const [row] = await getMessages(
{ conversationId, messageId: pausedUserMessageId },
'files',
);
if (Array.isArray(row?.files) && row.files.length > 0) {
req.body.files = row.files;
restoredFiles = true;
}
} catch (err) {
logger.warn(
'[ResumeAgentController] Failed to restore paused user message files',
err?.message ?? err,
);
}
}
if (!restoredFiles) {
// No paused files (or the lookup failed): drop any client-supplied files so a
// crafted resume body can't inject a file set the paused turn never had.
req.body.files = [];
}
}
// Restore the conversation's createdAt so temporal prompt vars ({{current_datetime}},
// {{iso_datetime}}, ...) resolve against the SAME anchor the paused graph used rather
// than the resume wall-clock. initializeAgent reads `req.conversationCreatedAt`; the
// normal path sets it from the convo timestamp (resolveConversationCreatedAt), so mirror
// that here. (The original `timezone` is replayed onto req.body via RESUME_CONTEXT_KEYS.)
try {
const resumedConvo = await getConvo(userId, conversationId);
const createdAt = resumedConvo?.createdAt ? new Date(resumedConvo.createdAt) : null;
if (createdAt && !Number.isNaN(createdAt.getTime())) {
req.conversationCreatedAt = createdAt.toISOString();
}
} catch (err) {
logger.warn(
'[ResumeAgentController] Failed to restore conversation timestamp anchor',
err?.message ?? err,
);
}
let client = null;
try {
const result = await initializeClient({
req,
res,
endpointOption: req.body.endpointOption,
signal: job.abortController.signal,
});
client = result.client;
// Bind the rebuilt client to the in-flight turn's identity (no new user message).
client.conversationId = streamId;
// The resume operates on the SAME job (it moved it running again), so its identity is
// the paused job's createdAt — used by the re-pause CAS pre-check + checkpoint prune to
// avoid acting on a job a newer request has since replaced.
client.jobCreatedAt = job.createdAt;
client.responseMessageId = job.metadata.responseMessageId;
client.parentMessageId = job.metadata.userMessage?.messageId ?? Constants.NO_PARENT;
// Read the pre-pause content BEFORE swapping the store's content reference: the
// in-memory store's setContentParts REPLACES the stored array, so reading the
// resume state afterward would see the new (empty) client array and lose the seed.
const resumeState = await GenerationJobManager.getResumeState(streamId);
let seedContent = resumeState?.aggregatedContent ?? [];
// Stamp the answered question onto the paused ask_user_question tool-call part
// (args = the pendingAction's authoritative question, output = the user's answer):
// the streamed arg chunks carry no tool name so the aggregator dropped them, and
// no completion event ever fires for this tool — without this the saved part is
// an empty "cancelled-looking" tool call. See attachAskUserQuestionAnswer.
if (pendingAction.payload?.type === 'ask_user_question') {
seedContent = attachAskUserQuestionAnswer(
seedContent,
pendingAction.payload.question,
req.body.answer,
);
}
if (client.contentParts) {
GenerationJobManager.setContentParts(streamId, client.contentParts);
}
await client.resumeCompletion({
resumeValue: mapped.resumeValue,
seedContent,
abortController: job.abortController,
// Carry the user's MCP auth so approved MCP tools run with their credentials.
userMCPAuthMap: result.userMCPAuthMap,
// Replay deferred tools discovered before the pause (captured at pause). The rebuilt
// graph passes `messages: []`, so without these an approved deferred tool would be
// absent from the schema-only toolMap and resume would fail with "unknown tool".
discoveredToolNames: job.metadata?.discoveredTools,
});
// The model may pause AGAIN (another tool, or a follow-up question). The pending
// action is already persisted + emitted; leave the job `requires_action`.
if (client.pendingApproval) {
logger.debug(`[ResumeAgentController] Re-paused for approval: ${streamId}`);
// Persist this segment's content + artifacts before the fresh client (next
// resume) drops them, so an expiring re-pause doesn't lose them; finalize later
// overwrites content and merges attachments onto the saved message.
await persistRePauseProgress({ req, client, job, streamId, conversationId });
return;
}
// If the user aborted mid-resume, the abort route already emitted the terminal
// event and finalized the job — don't double-save / double-finalize here.
if (job.abortController.signal.aborted) {
logger.debug(
`[ResumeAgentController] Aborted during resume; abort route finalizes: ${streamId}`,
);
return;
}
await finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle });
} catch (err) {
logger.error('[ResumeAgentController] Resume failed', err);
// Job-replacement guard (mirrors finalizeResumedTurn's success-path guard): if a
// newer request reused this conversationId while the resume was failing, do NOT emit
// the error to / complete / prune the NEWER turn's job. The finally still releases
// the slot + disposes. Proceed with finalization if the replacement check itself fails.
let stillLive = true;
try {
const liveJob = await GenerationJobManager.getJobStore().getJob(streamId);
stillLive = !!liveJob && liveJob.createdAt === job.createdAt;
} catch (readErr) {
logger.warn('[ResumeAgentController] Replacement check failed; finalizing anyway', readErr);
}
if (!stillLive) {
logger.warn(
`[ResumeAgentController] Skipping failed-resume finalization — job ${streamId} was replaced`,
);
} else {
try {
await GenerationJobManager.emitError(streamId, err?.message ?? 'Resume failed');
} catch (emitErr) {
logger.error('[ResumeAgentController] Failed to emit resume error', emitErr);
}
try {
await GenerationJobManager.completeJob(streamId, err?.message ?? 'Resume failed');
} catch (completeErr) {
logger.error('[ResumeAgentController] Failed to finalize failed resume', completeErr);
// Last resort: force a terminal state so the job isn't orphaned in `running`.
await GenerationJobManager.getJobStore()
.updateJob(streamId, {
status: 'error',
completedAt: Date.now(),
error: 'Resume failed',
})
.catch((updErr) =>
logger.error('[ResumeAgentController] Fallback job finalize failed', updErr),
);
}
await deleteAgentCheckpoint(
conversationId,
req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer,
);
}
} finally {
// Tear down the MCP request-context store seeded before the ACK (parity with
// request.js's finishResumableRequest). No-op if it was never seeded.
await cleanupMCPRequestContextForReq(req);
// Release the concurrency slot taken above — UNLESS handleRunInterrupt already
// released it on a re-pause (so a fast /resume isn't 429'd). On a normal finish or
// error it didn't, so release here. A re-pause re-acquires its own slot next resume.
if (!client?.pendingRequestReleased) {
await decrementPendingRequest(userId);
}
if (client) {
disposeClient(client);
}
}
};
module.exports = ResumeAgentController;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+676
View File
@@ -0,0 +1,676 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const {
sendEvent,
countTokens,
checkBalance,
getBalanceConfig,
getModelMaxTokens,
} = require('@librechat/api');
const {
Time,
Constants,
RunStatus,
CacheKeys,
VisionModes,
ContentTypes,
EModelEndpoint,
ViolationTypes,
ImageVisionTool,
checkOpenAIStorage,
AssistantStreamEvents,
} = require('librechat-data-provider');
const {
initThread,
recordUsage,
saveUserMessage,
checkMessageGaps,
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody');
const { sendResponse } = require('~/server/middleware/error');
const {
createAutoRefillTransaction,
findBalanceByUser,
upsertBalanceFields,
getTransactions,
getMultiplier,
getConvo,
} = require('~/models');
const { logViolation, getLogStores } = require('~/cache');
const { getOpenAIClient } = require('./helpers');
/**
* @route POST /
* @desc Chat with an assistant
* @access Public
* @param {object} req - The request object, containing the request data.
* @param {object} req.body - The request payload.
* @param {Express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
const chatV1 = async (req, res) => {
const appConfig = req.config;
logger.debug('[/assistants/chat/] req.body', req.body);
const {
text,
model,
endpoint,
files = [],
promptPrefix,
assistant_id,
instructions,
endpointOption,
thread_id: _thread_id,
messageId: _messageId,
conversationId: convoId,
parentMessageId: _parentId = Constants.NO_PARENT,
clientTimestamp,
} = req.body;
/** @type {OpenAI} */
let openai;
/** @type {string|undefined} - the current thread id */
let thread_id = _thread_id;
/** @type {string|undefined} - the current run id */
let run_id;
/** @type {string|undefined} - the parent messageId */
let parentMessageId = _parentId;
/** @type {TMessage[]} */
let previousMessages = [];
/** @type {import('librechat-data-provider').TConversation | null} */
let conversation = null;
/** @type {string[]} */
let file_ids = [];
/** @type {Set<string>} */
let attachedFileIds = new Set();
/** @type {TMessage | null} */
let requestMessage = null;
/** @type {undefined | Promise<ChatCompletion>} */
let visionPromise;
const userMessageId = v4();
const responseMessageId = v4();
/** @type {string} - The conversation UUID - created if undefined */
const conversationId = convoId ?? v4();
const cache = getLogStores(CacheKeys.ABORT_KEYS);
const cacheKey = `${req.user.id}:${conversationId}`;
/** @type {Run | undefined} - The completed run, undefined if incomplete */
let completedRun;
const handleError = async (error) => {
const defaultErrorMessage =
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
const messageData = {
thread_id,
assistant_id,
conversationId,
parentMessageId,
sender: 'System',
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint,
};
if (error.message === 'Run cancelled') {
return res.end();
} else if (error.message === 'Request closed' && completedRun) {
return;
} else if (error.message === 'Request closed') {
logger.debug('[/assistants/chat/] Request aborted on close');
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === EModelEndpoint.azureAssistants
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
req,
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(req, res, messageData, error.message);
} else {
logger.error('[/assistants/chat/]', error);
}
if (!openai || !thread_id || !run_id) {
return sendResponse(req, res, messageData, defaultErrorMessage);
}
await sleep(2000);
try {
const status = await cache.get(cacheKey);
if (status === 'cancelled') {
logger.debug('[/assistants/chat/] Run already cancelled');
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[/assistants/chat/] Error cancelling run', error);
}
await sleep(2000);
let run;
try {
run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error('[/assistants/chat/] Error fetching or processing run', error);
}
let finalEvent;
try {
const runMessages = await checkMessageGaps({
openai,
run_id,
endpoint,
thread_id,
conversationId,
latestMessageId: responseMessageId,
});
const errorContentPart = {
text: {
value:
error?.message ?? 'There was an error processing your request. Please try again later.',
},
type: ContentTypes.ERROR,
};
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
runMessages[runMessages.length - 1].content = [errorContentPart];
} else {
const contentParts = runMessages[runMessages.length - 1].content;
for (let i = 0; i < contentParts.length; i++) {
const currentPart = contentParts[i];
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
if (
toolCall &&
toolCall?.function &&
!(toolCall?.function?.output || toolCall?.function?.output?.length)
) {
contentParts[i] = {
...currentPart,
[ContentTypes.TOOL_CALL]: {
...toolCall,
function: {
...toolCall.function,
output: 'error processing tool',
},
},
};
}
}
runMessages[runMessages.length - 1].content.push(errorContentPart);
}
finalEvent = {
final: true,
conversation: await getConvo(req.user.id, conversationId),
runMessages,
};
} catch (error) {
logger.error('[/assistants/chat/] Error finalizing error process', error);
return sendResponse(req, res, messageData, 'The Assistant run failed');
}
return sendResponse(req, res, finalEvent);
};
try {
res.on('close', async () => {
if (!completedRun) {
await handleError(new Error('Request closed'));
}
});
if (convoId && !_thread_id) {
completedRun = true;
throw new Error('Missing thread_id for existing conversation');
}
if (!assistant_id) {
completedRun = true;
throw new Error('Missing assistant_id');
}
const checkBalanceBeforeRun = async () => {
const balanceConfig = getBalanceConfig(appConfig);
if (!balanceConfig?.enabled) {
return;
}
const transactions =
(await getTransactions({
user: req.user.id,
context: 'message',
conversationId,
})) ?? [];
const totalPreviousTokens = Math.abs(
transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
);
// TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
// 5 is added for labels
let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
promptTokens += totalPreviousTokens + promptBuffer;
// Count tokens up to the current context window
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
await checkBalance(
{
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
},
},
{
findBalanceByUser,
getMultiplier,
createAutoRefillTransaction,
logViolation,
balanceConfig,
upsertBalanceFields,
},
);
};
const { openai: _openai } = await getOpenAIClient({
req,
res,
endpointOption,
});
openai = _openai;
await validateAuthor({ req, openai });
if (previousMessages.length) {
parentMessageId = previousMessages[previousMessages.length - 1].messageId;
}
let userMessage = {
role: 'user',
content: text,
metadata: {
messageId: userMessageId,
},
};
/** @type {CreateRunBody | undefined} */
const body = createRunBody({
assistant_id,
model,
promptPrefix,
instructions,
endpointOption,
clientTimestamp,
});
const getRequestFileIds = async () => {
let thread_file_ids = [];
if (convoId) {
const convo = await getConvo(req.user.id, convoId);
if (convo && convo.file_ids) {
thread_file_ids = convo.file_ids;
}
}
file_ids = files.map(({ file_id }) => file_id);
if (file_ids.length || thread_file_ids.length) {
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
if (endpoint === EModelEndpoint.azureAssistants) {
userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({
file_id,
tools: [{ type: 'file_search' }],
}));
} else {
userMessage.file_ids = Array.from(attachedFileIds);
}
}
};
const addVisionPrompt = async () => {
if (!endpointOption.attachments) {
return;
}
/** @type {MongoFile[]} */
const attachments = await endpointOption.attachments;
if (attachments && attachments.every((attachment) => checkOpenAIStorage(attachment.source))) {
return;
}
const assistant = await openai.beta.assistants.retrieve(assistant_id);
const visionToolIndex = assistant.tools.findIndex(
(tool) => tool?.function && tool?.function?.name === ImageVisionTool.function.name,
);
if (visionToolIndex === -1) {
return;
}
let visionMessage = {
role: 'user',
content: '',
};
const { files, image_urls } = await encodeAndFormat(
req,
attachments,
{
endpoint: EModelEndpoint.assistants,
},
VisionModes.generative,
);
visionMessage.image_urls = image_urls.length ? image_urls : undefined;
if (!visionMessage.image_urls?.length) {
return;
}
const imageCount = visionMessage.image_urls.length;
const plural = imageCount > 1;
visionMessage.content = createVisionPrompt(plural);
visionMessage = formatMessage({ message: visionMessage, endpoint: EModelEndpoint.openAI });
visionPromise = openai.chat.completions
.create({
messages: [visionMessage],
max_tokens: 4000,
})
.catch((error) => {
logger.error('[/assistants/chat/] Error creating vision prompt', error);
});
const pluralized = plural ? 's' : '';
body.additional_instructions = `${
body.additional_instructions ? `${body.additional_instructions}\n` : ''
}The user has uploaded ${imageCount} image${pluralized}.
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
plural ? '' : 'a '
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
return files;
};
/** @type {Promise<Run>|undefined} */
let userMessagePromise;
const initializeThread = async () => {
/** @type {[ undefined | MongoFile[]]}*/
const [processedFiles] = await Promise.all([addVisionPrompt(), getRequestFileIds()]);
// TODO: may allow multiple messages to be created beforehand in a future update
const initThreadBody = {
messages: [userMessage],
metadata: {
user: req.user.id,
conversationId,
},
};
if (processedFiles) {
for (const file of processedFiles) {
if (!checkOpenAIStorage(file.source)) {
attachedFileIds.delete(file.file_id);
const index = file_ids.indexOf(file.file_id);
if (index > -1) {
file_ids.splice(index, 1);
}
}
}
userMessage.file_ids = file_ids;
}
const result = await initThread({ openai, body: initThreadBody, thread_id });
thread_id = result.thread_id;
createOnTextProgress({
openai,
conversationId,
userMessageId,
messageId: responseMessageId,
thread_id,
});
requestMessage = {
user: req.user.id,
text,
messageId: userMessageId,
parentMessageId,
// TODO: make sure client sends correct format for `files`, use zod
files,
file_ids,
conversationId,
isCreatedByUser: true,
assistant_id,
thread_id,
model: assistant_id,
endpoint,
};
previousMessages.push(requestMessage);
/* asynchronous */
userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
conversation = {
conversationId,
endpoint,
promptPrefix: promptPrefix,
instructions: instructions,
assistant_id,
// model,
};
if (file_ids.length) {
conversation.file_ids = file_ids;
}
};
const promises = [initializeThread(), checkBalanceBeforeRun()];
await Promise.all(promises);
const sendInitialResponse = () => {
sendEvent(res, {
sync: true,
conversationId,
// messages: previousMessages,
requestMessage,
responseMessage: {
user: req.user.id,
messageId: openai.responseMessage.messageId,
parentMessageId: userMessageId,
conversationId,
assistant_id,
thread_id,
model: assistant_id,
},
});
};
/** @type {RunResponse | typeof StreamRunManager | undefined} */
let response;
const processRun = async (retry = false) => {
if (endpoint === EModelEndpoint.azureAssistants) {
body.model = openai._options.model;
openai.attachedFileIds = attachedFileIds;
openai.visionPromise = visionPromise;
if (retry) {
response = await runAssistant({
openai,
thread_id,
run_id,
in_progress: openai.in_progress,
});
return;
}
/* NOTE:
* By default, a Run will use the model and tools configuration specified in Assistant object,
* but you can override most of these when creating the Run for added flexibility:
*/
const run = await createRun({
openai,
thread_id,
body,
});
run_id = run.id;
await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
sendInitialResponse();
// todo: retry logic
response = await runAssistant({ openai, thread_id, run_id });
return;
}
/** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise<void>}} */
const handlers = {
[AssistantStreamEvents.ThreadRunCreated]: async (event) => {
await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
run_id = event.data.id;
sendInitialResponse();
},
};
const streamRunManager = new StreamRunManager({
req,
res,
openai,
handlers,
thread_id,
visionPromise,
attachedFileIds,
responseMessage: openai.responseMessage,
// streamOptions: {
// },
});
await streamRunManager.runAssistant({
thread_id,
body,
});
response = streamRunManager;
};
await processRun();
logger.debug('[/assistants/chat/] response', {
run: response.run,
steps: response.steps,
});
if (response.run.status === RunStatus.CANCELLED) {
logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
return res.end();
}
if (response.run.status === RunStatus.IN_PROGRESS) {
processRun(true);
}
completedRun = response.run;
/** @type {ResponseMessage} */
const responseMessage = {
...(response.responseMessage ?? response.finalMessage),
parentMessageId: userMessageId,
conversationId,
user: req.user.id,
assistant_id,
thread_id,
model: assistant_id,
endpoint,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
};
sendEvent(res, {
final: true,
conversation,
requestMessage: {
parentMessageId,
thread_id,
},
});
res.end();
if (userMessagePromise) {
await userMessagePromise;
}
await saveAssistantMessage(req, { ...responseMessage, model });
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
addTitle(req, {
text,
responseText: response.text,
conversationId,
});
}
await addThreadMetadata({
openai,
thread_id,
messageId: responseMessage.messageId,
messages: response.messages,
});
if (!response.run.usage) {
await sleep(3000);
completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
if (completedRun.usage) {
await recordUsage({
...completedRun.usage,
user: req.user.id,
model: completedRun.model ?? model,
conversationId,
});
}
} else {
await recordUsage({
...response.run.usage,
user: req.user.id,
model: response.run.model ?? model,
conversationId,
});
}
} catch (error) {
await handleError(error);
}
};
module.exports = chatV1;
+510
View File
@@ -0,0 +1,510 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const {
sendEvent,
countTokens,
checkBalance,
getBalanceConfig,
getModelMaxTokens,
} = require('@librechat/api');
const {
Time,
Constants,
RunStatus,
CacheKeys,
ContentTypes,
ToolCallTypes,
EModelEndpoint,
retrievalMimeTypes,
AssistantStreamEvents,
} = require('librechat-data-provider');
const {
initThread,
recordUsage,
saveUserMessage,
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const { createErrorHandler } = require('~/server/controllers/assistants/errors');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody');
const {
getConvo,
getMultiplier,
getTransactions,
findBalanceByUser,
upsertBalanceFields,
createAutoRefillTransaction,
} = require('~/models');
const { logViolation, getLogStores } = require('~/cache');
const { getOpenAIClient } = require('./helpers');
/**
* @route POST /
* @desc Chat with an assistant
* @access Public
* @param {ServerRequest} req - The request object, containing the request data.
* @param {Express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
const chatV2 = async (req, res) => {
logger.debug('[/assistants/chat/] req.body', req.body);
const appConfig = req.config;
/** @type {{files: MongoFile[]}} */
const {
text,
model,
endpoint,
files = [],
promptPrefix,
assistant_id,
instructions,
endpointOption,
thread_id: _thread_id,
messageId: _messageId,
conversationId: convoId,
parentMessageId: _parentId = Constants.NO_PARENT,
clientTimestamp,
} = req.body;
/** @type {OpenAI} */
let openai;
/** @type {string|undefined} - the current thread id */
let thread_id = _thread_id;
/** @type {string|undefined} - the current run id */
let run_id;
/** @type {string|undefined} - the parent messageId */
let parentMessageId = _parentId;
/** @type {TMessage[]} */
let previousMessages = [];
/** @type {import('librechat-data-provider').TConversation | null} */
let conversation = null;
/** @type {string[]} */
let file_ids = [];
/** @type {Set<string>} */
let attachedFileIds = new Set();
/** @type {TMessage | null} */
let requestMessage = null;
const userMessageId = v4();
const responseMessageId = v4();
/** @type {string} - The conversation UUID - created if undefined */
const conversationId = convoId ?? v4();
const cache = getLogStores(CacheKeys.ABORT_KEYS);
const cacheKey = `${req.user.id}:${conversationId}`;
/** @type {Run | undefined} - The completed run, undefined if incomplete */
let completedRun;
const getContext = () => ({
openai,
run_id,
endpoint,
cacheKey,
thread_id,
completedRun,
assistant_id,
conversationId,
parentMessageId,
responseMessageId,
});
const handleError = createErrorHandler({ req, res, getContext });
try {
res.on('close', async () => {
if (!completedRun) {
await handleError(new Error('Request closed'));
}
});
if (convoId && !_thread_id) {
completedRun = true;
throw new Error('Missing thread_id for existing conversation');
}
if (!assistant_id) {
completedRun = true;
throw new Error('Missing assistant_id');
}
const checkBalanceBeforeRun = async () => {
const balanceConfig = getBalanceConfig(appConfig);
if (!balanceConfig?.enabled) {
return;
}
const transactions =
(await getTransactions({
user: req.user.id,
context: 'message',
conversationId,
})) ?? [];
const totalPreviousTokens = Math.abs(
transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
);
// TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
// 5 is added for labels
let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
promptTokens += totalPreviousTokens + promptBuffer;
// Count tokens up to the current context window
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
await checkBalance(
{
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
},
},
{
findBalanceByUser,
getMultiplier,
createAutoRefillTransaction,
logViolation,
balanceConfig,
upsertBalanceFields,
},
);
};
const { openai: _openai } = await getOpenAIClient({
req,
res,
endpointOption,
});
openai = _openai;
await validateAuthor({ req, openai });
if (previousMessages.length) {
parentMessageId = previousMessages[previousMessages.length - 1].messageId;
}
let userMessage = {
role: 'user',
content: [
{
type: ContentTypes.TEXT,
text,
},
],
metadata: {
messageId: userMessageId,
},
};
/** @type {CreateRunBody | undefined} */
const body = createRunBody({
assistant_id,
model,
promptPrefix,
instructions,
endpointOption,
clientTimestamp,
});
const getRequestFileIds = async () => {
let thread_file_ids = [];
if (convoId) {
const convo = await getConvo(req.user.id, convoId);
if (convo && convo.file_ids) {
thread_file_ids = convo.file_ids;
}
}
if (files.length || thread_file_ids.length) {
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
let attachmentIndex = 0;
for (const file of files) {
file_ids.push(file.file_id);
if (file.type.startsWith('image')) {
userMessage.content.push({
type: ContentTypes.IMAGE_FILE,
[ContentTypes.IMAGE_FILE]: { file_id: file.file_id },
});
}
if (!userMessage.attachments) {
userMessage.attachments = [];
}
userMessage.attachments.push({
file_id: file.file_id,
tools: [{ type: ToolCallTypes.CODE_INTERPRETER }],
});
if (file.type.startsWith('image')) {
continue;
}
const mimeType = file.type;
const isSupportedByRetrieval = retrievalMimeTypes.some((regex) => regex.test(mimeType));
if (isSupportedByRetrieval) {
userMessage.attachments[attachmentIndex].tools.push({
type: ToolCallTypes.FILE_SEARCH,
});
}
attachmentIndex++;
}
}
};
/** @type {Promise<Run>|undefined} */
let userMessagePromise;
const initializeThread = async () => {
await getRequestFileIds();
// TODO: may allow multiple messages to be created beforehand in a future update
const initThreadBody = {
messages: [userMessage],
metadata: {
user: req.user.id,
conversationId,
},
};
const result = await initThread({ openai, body: initThreadBody, thread_id });
thread_id = result.thread_id;
createOnTextProgress({
openai,
conversationId,
userMessageId,
messageId: responseMessageId,
thread_id,
});
requestMessage = {
user: req.user.id,
text,
messageId: userMessageId,
parentMessageId,
// TODO: make sure client sends correct format for `files`, use zod
files,
file_ids,
conversationId,
isCreatedByUser: true,
assistant_id,
thread_id,
model: assistant_id,
endpoint,
};
previousMessages.push(requestMessage);
/* asynchronous */
userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
conversation = {
conversationId,
endpoint,
promptPrefix: promptPrefix,
instructions: instructions,
assistant_id,
// model,
};
if (file_ids.length) {
conversation.file_ids = file_ids;
}
};
const promises = [initializeThread(), checkBalanceBeforeRun()];
await Promise.all(promises);
const sendInitialResponse = () => {
sendEvent(res, {
sync: true,
conversationId,
// messages: previousMessages,
requestMessage,
responseMessage: {
user: req.user.id,
messageId: openai.responseMessage.messageId,
parentMessageId: userMessageId,
conversationId,
assistant_id,
thread_id,
model: assistant_id,
},
});
};
/** @type {RunResponse | typeof StreamRunManager | undefined} */
let response;
const processRun = async (retry = false) => {
if (endpoint === EModelEndpoint.azureAssistants) {
body.model = openai._options.model;
openai.attachedFileIds = attachedFileIds;
if (retry) {
response = await runAssistant({
openai,
thread_id,
run_id,
in_progress: openai.in_progress,
});
return;
}
/* NOTE:
* By default, a Run will use the model and tools configuration specified in Assistant object,
* but you can override most of these when creating the Run for added flexibility:
*/
const run = await createRun({
openai,
thread_id,
body,
});
run_id = run.id;
await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
sendInitialResponse();
// todo: retry logic
response = await runAssistant({ openai, thread_id, run_id });
return;
}
/** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise<void>}} */
const handlers = {
[AssistantStreamEvents.ThreadRunCreated]: async (event) => {
await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
run_id = event.data.id;
sendInitialResponse();
},
};
/** @type {undefined | TAssistantEndpoint} */
const config = appConfig.endpoints?.[endpoint] ?? {};
/** @type {undefined | TBaseEndpoint} */
const allConfig = appConfig.endpoints?.all;
const streamRunManager = new StreamRunManager({
req,
res,
openai,
handlers,
thread_id,
attachedFileIds,
parentMessageId: userMessageId,
responseMessage: openai.responseMessage,
streamRate: allConfig?.streamRate ?? config.streamRate,
// streamOptions: {
// },
});
await streamRunManager.runAssistant({
thread_id,
body,
});
response = streamRunManager;
response.text = streamRunManager.intermediateText;
};
await processRun();
logger.debug('[/assistants/chat/] response', {
run: response.run,
steps: response.steps,
});
if (response.run.status === RunStatus.CANCELLED) {
logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
return res.end();
}
if (response.run.status === RunStatus.IN_PROGRESS) {
processRun(true);
}
completedRun = response.run;
/** @type {ResponseMessage} */
const responseMessage = {
...(response.responseMessage ?? response.finalMessage),
text: response.text,
parentMessageId: userMessageId,
conversationId,
user: req.user.id,
assistant_id,
thread_id,
model: assistant_id,
endpoint,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
};
sendEvent(res, {
final: true,
conversation,
requestMessage: {
parentMessageId,
thread_id,
},
});
res.end();
if (userMessagePromise) {
await userMessagePromise;
}
await saveAssistantMessage(req, { ...responseMessage, model });
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
addTitle(req, {
text,
responseText: response.text,
conversationId,
});
}
await addThreadMetadata({
openai,
thread_id,
messageId: responseMessage.messageId,
messages: response.messages,
});
if (!response.run.usage) {
await sleep(3000);
completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
if (completedRun.usage) {
await recordUsage({
...completedRun.usage,
user: req.user.id,
model: completedRun.model ?? model,
conversationId,
});
}
} else {
await recordUsage({
...response.run.usage,
user: req.user.id,
model: response.run.model ?? model,
conversationId,
});
}
} catch (error) {
await handleError(error);
}
};
module.exports = chatV2;
+193
View File
@@ -0,0 +1,193 @@
// errorHandler.js
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
const { sendResponse } = require('~/server/middleware/error');
const getLogStores = require('~/cache/getLogStores');
const { getConvo } = require('~/models');
/**
* @typedef {Object} ErrorHandlerContext
* @property {OpenAIClient} openai - The OpenAI client
* @property {string} thread_id - The thread ID
* @property {string} run_id - The run ID
* @property {boolean} completedRun - Whether the run has completed
* @property {string} assistant_id - The assistant ID
* @property {string} conversationId - The conversation ID
* @property {string} parentMessageId - The parent message ID
* @property {string} responseMessageId - The response message ID
* @property {string} endpoint - The endpoint being used
* @property {string} cacheKey - The cache key for the current request
*/
/**
* @typedef {Object} ErrorHandlerDependencies
* @property {ServerRequest} req - The Express request object
* @property {Express.Response} res - The Express response object
* @property {() => ErrorHandlerContext} getContext - Function to get the current context
* @property {string} [originPath] - The origin path for the error handler
*/
/**
* Creates an error handler function with the given dependencies
* @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
* @returns {(error: Error) => Promise<void>} The error handler function
*/
const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
const cache = getLogStores(CacheKeys.ABORT_KEYS);
/**
* Handles errors that occur during the chat process
* @param {Error} error - The error that occurred
* @returns {Promise<void>}
*/
return async (error) => {
const {
openai,
run_id,
endpoint,
cacheKey,
thread_id,
completedRun,
assistant_id,
conversationId,
parentMessageId,
responseMessageId,
} = getContext();
const defaultErrorMessage =
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
const messageData = {
thread_id,
assistant_id,
conversationId,
parentMessageId,
sender: 'System',
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint,
};
if (error.message === 'Run cancelled') {
return res.end();
} else if (error.message === 'Request closed' && completedRun) {
return;
} else if (error.message === 'Request closed') {
logger.debug(`[${originPath}] Request aborted on close`);
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
req,
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(req, res, messageData, error.message);
} else {
logger.error(`[${originPath}]`, error);
}
if (!openai || !thread_id || !run_id) {
return sendResponse(req, res, messageData, defaultErrorMessage);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const status = await cache.get(cacheKey);
if (status === 'cancelled') {
logger.debug(`[${originPath}] Run already cancelled`);
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
logger.debug(`[${originPath}] Cancelled run:`, cancelledRun);
} catch (error) {
logger.error(`[${originPath}] Error cancelling run`, error);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
let run;
try {
run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error(`[${originPath}] Error fetching or processing run`, error);
}
let finalEvent;
try {
const runMessages = await checkMessageGaps({
openai,
run_id,
endpoint,
thread_id,
conversationId,
latestMessageId: responseMessageId,
});
const errorContentPart = {
text: {
value:
error?.message ?? 'There was an error processing your request. Please try again later.',
},
type: ContentTypes.ERROR,
};
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
runMessages[runMessages.length - 1].content = [errorContentPart];
} else {
const contentParts = runMessages[runMessages.length - 1].content;
for (let i = 0; i < contentParts.length; i++) {
const currentPart = contentParts[i];
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
if (
toolCall &&
toolCall?.function &&
!(toolCall?.function?.output || toolCall?.function?.output?.length)
) {
contentParts[i] = {
...currentPart,
[ContentTypes.TOOL_CALL]: {
...toolCall,
function: {
...toolCall.function,
output: 'error processing tool',
},
},
};
}
}
runMessages[runMessages.length - 1].content.push(errorContentPart);
}
finalEvent = {
final: true,
conversation: await getConvo(req.user.id, conversationId),
runMessages,
};
} catch (error) {
logger.error(`[${originPath}] Error finalizing error process`, error);
return sendResponse(req, res, messageData, 'The Assistant run failed');
}
return sendResponse(req, res, finalEvent);
};
};
module.exports = { createErrorHandler };
@@ -0,0 +1,289 @@
const {
EModelEndpoint,
defaultOrderQuery,
defaultAssistantsVersion,
} = require('librechat-data-provider');
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const {
initializeClient: initAzureClient,
} = require('~/server/services/Endpoints/azureAssistants');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getEndpointsConfig } = require('~/server/services/Config');
/**
* @param {ServerRequest} req
* @param {string} [endpoint]
* @returns {Promise<string>}
*/
const getCurrentVersion = async (req, endpoint) => {
const index = req.baseUrl.lastIndexOf('/v');
let version = index !== -1 ? req.baseUrl.substring(index + 1, index + 3) : null;
if (!version && req.body.version) {
version = `v${req.body.version}`;
}
if (!version && endpoint) {
const endpointsConfig = await getEndpointsConfig(req);
version = `v${endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint]}`;
}
if (!version?.startsWith('v') && version.length !== 2) {
throw new Error(`[${req.baseUrl}] Invalid version: ${version}`);
}
return version;
};
/**
* Asynchronously lists assistants based on provided query parameters.
*
* Initializes the client with the current request and response objects and lists assistants
* according to the query parameters. This function abstracts the logic for non-Azure paths.
*
* @deprecated
* @async
* @param {object} params - The parameters object.
* @param {object} params.req - The request object, used for initializing the client.
* @param {object} params.res - The response object, used for initializing the client.
* @param {string} params.version - The API version to use.
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
* @returns {Promise<object>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
*/
const _listAssistants = async ({ req, res, version, query }) => {
const { openai } = await getOpenAIClient({ req, res, version });
return openai.beta.assistants.list(query);
};
/**
* Fetches all assistants based on provided query params, until `has_more` is `false`.
*
* @async
* @param {object} params - The parameters object.
* @param {object} params.req - The request object, used for initializing the client.
* @param {object} params.res - The response object, used for initializing the client.
* @param {string} params.version - The API version to use.
* @param {Omit<AssistantListParams, 'endpoint'>} params.query - The query parameters to list assistants (e.g., limit, order).
* @returns {Promise<Array<Assistant>>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
*/
const listAllAssistants = async ({ req, res, version, query }) => {
/** @type {{ openai: OpenAI }} */
const { openai } = await getOpenAIClient({ req, res, version });
const allAssistants = [];
let first_id;
let last_id;
let afterToken = query.after;
let hasMore = true;
while (hasMore) {
const response = await openai.beta.assistants.list({
...query,
after: afterToken,
});
const { body } = response;
allAssistants.push(...body.data);
hasMore = body.has_more;
if (!first_id) {
first_id = body.first_id;
}
if (hasMore) {
afterToken = body.last_id;
} else {
last_id = body.last_id;
}
}
return {
data: allAssistants,
body: {
data: allAssistants,
has_more: false,
first_id,
last_id,
},
};
};
/**
* Asynchronously lists assistants for Azure configured groups.
*
* Iterates through Azure configured assistant groups, initializes the client with the current request and response objects,
* lists assistants based on the provided query parameters, and merges their data alongside the model information into a single array.
*
* @async
* @param {object} params - The parameters object.
* @param {object} params.req - The request object, used for initializing the client and manipulating the request body.
* @param {object} params.res - The response object, used for initializing the client.
* @param {string} params.version - The API version to use.
* @param {TAzureConfig} params.azureConfig - The Azure configuration object containing assistantGroups and groupMap.
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
* @returns {Promise<AssistantListResponse>} A promise that resolves to an array of assistant data merged with their respective model information.
*/
const listAssistantsForAzure = async ({ req, res, version, azureConfig = {}, query }) => {
/** @type {Array<[string, TAzureModelConfig]>} */
const groupModelTuples = [];
const promises = [];
/** @type {Array<TAzureGroup>} */
const groups = [];
const { groupMap, assistantGroups } = azureConfig;
for (const groupName of assistantGroups) {
const group = groupMap[groupName];
groups.push(group);
const currentModelTuples = Object.entries(group?.models);
groupModelTuples.push(currentModelTuples);
/* The specified model is only necessary to
fetch assistants for the shared instance */
req.body = req.body || {}; // Express 5: req.body is undefined instead of {} when no body parser runs
req.body.model = currentModelTuples[0][0];
promises.push(listAllAssistants({ req, res, version, query }));
}
const resolvedQueries = await Promise.all(promises);
const data = resolvedQueries.flatMap((res, i) =>
res.data.map((assistant) => {
const deploymentName = assistant.model;
const currentGroup = groups[i];
const currentModelTuples = groupModelTuples[i];
const firstModel = currentModelTuples[0][0];
if (currentGroup.deploymentName === deploymentName) {
return { ...assistant, model: firstModel };
}
for (const [model, modelConfig] of currentModelTuples) {
if (modelConfig.deploymentName === deploymentName) {
return { ...assistant, model };
}
}
return { ...assistant, model: firstModel };
}),
);
return {
first_id: data[0]?.id,
last_id: data[data.length - 1]?.id,
object: 'list',
has_more: false,
data,
};
};
/**
* Initializes the OpenAI client.
* @param {object} params - The parameters object.
* @param {ServerRequest} params.req - The request object.
* @param {ServerResponse} params.res - The response object.
* @param {TEndpointOption} params.endpointOption - The endpoint options.
* @param {boolean} params.initAppClient - Whether to initialize the app client.
* @param {string} params.overrideEndpoint - The endpoint to override.
* @returns {Promise<{ openai: OpenAI, openAIApiKey: string }>} - The initialized OpenAI SDK client.
*/
async function getOpenAIClient({ req, res, endpointOption, initAppClient, overrideEndpoint }) {
let endpoint = overrideEndpoint ?? req.body?.endpoint ?? req.query?.endpoint;
const version = await getCurrentVersion(req, endpoint);
if (!endpoint) {
throw new Error(`[${req.baseUrl}] Endpoint is required`);
}
let result;
if (endpoint === EModelEndpoint.assistants) {
result = await initializeClient({ req, res, version, endpointOption, initAppClient });
} else if (endpoint === EModelEndpoint.azureAssistants) {
result = await initAzureClient({ req, res, version, endpointOption, initAppClient });
}
return result;
}
/**
* Returns a list of assistants.
* @param {object} params
* @param {object} params.req - Express Request
* @param {AssistantListParams} [params.req.query] - The assistant list parameters for pagination and sorting.
* @param {object} params.res - Express Response
* @param {string} [params.overrideEndpoint] - The endpoint to override the request endpoint.
* @returns {Promise<AssistantListResponse>} 200 - success response - application/json
*/
const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
const appConfig = req.config;
const {
limit = 100,
order = 'desc',
after,
before,
endpoint,
} = req.query ?? {
endpoint: overrideEndpoint,
...defaultOrderQuery,
};
const version = await getCurrentVersion(req, endpoint);
const query = { limit, order, after, before };
/** @type {AssistantListResponse} */
let body;
if (endpoint === EModelEndpoint.assistants) {
({ body } = await listAllAssistants({ req, res, version, query }));
} else if (endpoint === EModelEndpoint.azureAssistants) {
const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
}
if (!appConfig.endpoints?.[endpoint]) {
return body;
}
let canManageAssistants = false;
try {
canManageAssistants = await hasCapability(req.user, SystemCapabilities.MANAGE_ASSISTANTS);
} catch (err) {
logger.warn(`[fetchAssistants] capability check failed, denying bypass: ${err.message}`);
}
if (canManageAssistants) {
logger.debug(`[fetchAssistants] MANAGE_ASSISTANTS bypass for user ${req.user.id}`);
return body;
}
body.data = filterAssistants({
userId: req.user.id,
assistants: body.data,
assistantsConfig: appConfig.endpoints?.[endpoint],
});
return body;
};
/**
* Filter assistants based on configuration.
*
* @param {object} params - The parameters object.
* @param {string} params.userId - The user ID to filter private assistants.
* @param {Assistant[]} params.assistants - The list of assistants to filter.
* @param {Partial<TAssistantEndpoint>} params.assistantsConfig - The assistant configuration.
* @returns {Assistant[]} - The filtered list of assistants.
*/
function filterAssistants({ assistants, userId, assistantsConfig }) {
const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
if (privateAssistants) {
return assistants.filter((assistant) => userId === assistant.metadata?.author);
} else if (supportedIds?.length) {
return assistants.filter((assistant) => supportedIds.includes(assistant.id));
} else if (excludedIds?.length) {
return assistants.filter((assistant) => !excludedIds.includes(assistant.id));
}
return assistants;
}
module.exports = {
getOpenAIClient,
fetchAssistants,
getCurrentVersion,
};
+405
View File
@@ -0,0 +1,405 @@
const fs = require('fs').promises;
const { logger } = require('@librechat/data-schemas');
const { FileContext } = require('librechat-data-provider');
const { deleteFileByFilter, updateAssistantDoc, getAssistants } = require('~/models');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService');
const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { getCachedTools } = require('~/server/services/Config');
const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
/**
* Create an assistant.
* @route POST /assistants
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
* @returns {Assistant} 201 - success response - application/json
*/
const createAssistant = async (req, res) => {
try {
const { openai } = await getOpenAIClient({ req, res });
const {
tools = [],
endpoint,
conversation_starters,
append_current_datetime,
...assistantData
} = req.body;
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = (await getCachedTools()) ?? {};
assistantData.tools = tools
.map((tool) => {
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
* the assistants runtime — drop them even when posted directly, since
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
if (isAgentsOnlyTool(tool)) {
logger.warn(
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
);
return undefined;
}
if (typeof tool !== 'string') {
return tool;
}
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
})
.filter((tool) => tool)
.flat();
let azureModelIdentifier = null;
if (openai.locals?.azureOptions) {
azureModelIdentifier = assistantData.model;
assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
assistantData.metadata = {
author: req.user.id,
endpoint,
};
const assistant = await openai.beta.assistants.create(assistantData);
const createData = { user: req.user.id };
if (conversation_starters) {
createData.conversation_starters = conversation_starters;
}
if (append_current_datetime !== undefined) {
createData.append_current_datetime = append_current_datetime;
}
const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
if (azureModelIdentifier) {
assistant.model = azureModelIdentifier;
}
if (document.conversation_starters) {
assistant.conversation_starters = document.conversation_starters;
}
if (append_current_datetime !== undefined) {
assistant.append_current_datetime = append_current_datetime;
}
logger.debug('/assistants/', assistant);
res.status(201).json(assistant);
} catch (error) {
logger.error('[/assistants] Error creating assistant', error);
res.status(500).json({ error: error.message });
}
};
/**
* Retrieves an assistant.
* @route GET /assistants/:id
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
const retrieveAssistant = async (req, res) => {
try {
/* NOTE: not actually being used right now */
const { openai } = await getOpenAIClient({ req, res });
const assistant_id = req.params.id;
const assistant = await openai.beta.assistants.retrieve(assistant_id);
res.json(assistant);
} catch (error) {
logger.error('[/assistants/:id] Error retrieving assistant', error);
res.status(500).json({ error: error.message });
}
};
/**
* Modifies an assistant.
* @route PATCH /assistants/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Assistant identifier.
* @param {AssistantUpdateParams} req.body - The assistant update parameters.
* @returns {Assistant} 200 - success response - application/json
*/
const patchAssistant = async (req, res) => {
try {
const { openai } = await getOpenAIClient({ req, res });
await validateAuthor({ req, openai });
const assistant_id = req.params.id;
const {
endpoint: _e,
conversation_starters,
append_current_datetime,
...updateData
} = req.body;
const toolDefinitions = (await getCachedTools()) ?? {};
updateData.tools = (updateData.tools ?? [])
.map((tool) => {
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
* the assistants runtime — drop them even when posted directly, since
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
if (isAgentsOnlyTool(tool)) {
logger.warn(
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
);
return undefined;
}
if (typeof tool !== 'string') {
return tool;
}
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
})
.filter((tool) => tool)
.flat();
if (openai.locals?.azureOptions && updateData.model) {
updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
const updatedAssistant = await openai.beta.assistants.update(assistant_id, updateData);
if (conversation_starters !== undefined) {
const conversationStartersUpdate = await updateAssistantDoc(
{ assistant_id },
{ conversation_starters },
);
updatedAssistant.conversation_starters = conversationStartersUpdate.conversation_starters;
}
if (append_current_datetime !== undefined) {
await updateAssistantDoc({ assistant_id }, { append_current_datetime });
updatedAssistant.append_current_datetime = append_current_datetime;
}
res.json(updatedAssistant);
} catch (error) {
logger.error('[/assistants/:id] Error updating assistant', error);
res.status(500).json({ error: error.message });
}
};
/**
* Deletes an assistant.
* @route DELETE /assistants/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
const deleteAssistant = async (req, res) => {
try {
const { openai } = await getOpenAIClient({ req, res });
await validateAuthor({ req, openai });
const assistant_id = req.params.id;
const deletionStatus = await openai.beta.assistants.delete(assistant_id);
if (deletionStatus?.deleted) {
await deleteAssistantActions({ req, assistant_id });
}
res.json(deletionStatus);
} catch (error) {
logger.error('[/assistants/:id] Error deleting assistant', error);
res.status(500).json({ error: 'Error deleting assistant' });
}
};
/**
* Returns a list of assistants.
* @route GET /assistants
* @param {object} req - Express Request
* @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
* @returns {AssistantListResponse} 200 - success response - application/json
*/
const listAssistants = async (req, res) => {
try {
const body = await fetchAssistants({ req, res });
res.json(body);
} catch (error) {
logger.error('[/assistants] Error listing assistants', error);
res.status(500).json({ message: 'Error listing assistants' });
}
};
/**
* Filter assistants based on configuration.
*
* @param {object} params - The parameters object.
* @param {string} params.userId - The user ID to filter private assistants.
* @param {AssistantDocument[]} params.assistants - The list of assistants to filter.
* @param {Partial<TAssistantEndpoint>} [params.assistantsConfig] - The assistant configuration.
* @returns {AssistantDocument[]} - The filtered list of assistants.
*/
function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) {
const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
const removeUserId = (doc) => {
const { user: _u, ...document } = doc;
return document;
};
if (privateAssistants) {
return documents.filter((doc) => userId === doc.user.toString()).map(removeUserId);
} else if (supportedIds?.length) {
return documents.filter((doc) => supportedIds.includes(doc.assistant_id)).map(removeUserId);
} else if (excludedIds?.length) {
return documents.filter((doc) => !excludedIds.includes(doc.assistant_id)).map(removeUserId);
}
return documents.map(removeUserId);
}
/**
* Returns a list of the user's assistant documents (metadata saved to database).
* @route GET /assistants/documents
* @returns {AssistantDocument[]} 200 - success response - application/json
*/
const getAssistantDocuments = async (req, res) => {
try {
const appConfig = req.config;
const endpoint = req.query?.endpoint;
const assistantsConfig = appConfig.endpoints?.[endpoint];
const documents = await getAssistants(
{},
{
user: 1,
assistant_id: 1,
conversation_starters: 1,
createdAt: 1,
updatedAt: 1,
append_current_datetime: 1,
},
);
const docs = filterAssistantDocs({
documents,
userId: req.user.id,
assistantsConfig,
});
res.json(docs);
} catch (error) {
logger.error('[/assistants/documents] Error listing assistant documents', error);
res.status(500).json({ error: error.message });
}
};
/**
* Uploads and updates an avatar for a specific assistant.
* @route POST /:assistant_id/avatar
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.assistant_id - The ID of the assistant.
* @param {Express.Multer.File} req.file - The avatar image file.
* @param {object} req.body - Request body
* @returns {Object} 200 - success response - application/json
*/
const uploadAssistantAvatar = async (req, res) => {
try {
const appConfig = req.config;
filterFile({ req, file: req.file, image: true, isAvatar: true });
const { assistant_id } = req.params;
if (!assistant_id) {
return res.status(400).json({ message: 'Assistant ID is required' });
}
const { openai } = await getOpenAIClient({ req, res });
await validateAuthor({ req, openai });
const buffer = await fs.readFile(req.file.path);
const image = await uploadImageBuffer({
req,
context: FileContext.avatar,
metadata: { buffer },
});
let _metadata;
try {
const assistant = await openai.beta.assistants.retrieve(assistant_id);
if (assistant) {
_metadata = assistant.metadata;
}
} catch (error) {
logger.error('[/:assistant_id/avatar] Error fetching assistant', error);
_metadata = {};
}
if (_metadata.avatar && _metadata.avatar_source) {
const { deleteFile } = getStrategyFunctions(_metadata.avatar_source);
try {
await deleteFile(req, {
filepath: _metadata.avatar,
user: req.user.id,
tenantId: req.user.tenantId,
});
await deleteFileByFilter({ user: req.user.id, filepath: _metadata.avatar });
} catch (error) {
logger.error('[/:assistant_id/avatar] Error deleting old avatar', error);
}
}
const metadata = {
..._metadata,
avatar: image.filepath,
avatar_source: appConfig.fileStrategy,
};
const promises = [];
promises.push(
updateAssistantDoc(
{ assistant_id },
{
avatar: {
filepath: image.filepath,
source: appConfig.fileStrategy,
},
user: req.user.id,
},
),
);
promises.push(openai.beta.assistants.update(assistant_id, { metadata }));
const resolved = await Promise.all(promises);
res.status(201).json(resolved[1]);
} catch (error) {
const message = 'An error occurred while updating the Assistant Avatar';
logger.error(message, error);
res.status(500).json({ message });
} finally {
try {
await fs.unlink(req.file.path);
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
} catch {
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
}
}
};
module.exports = {
createAssistant,
retrieveAssistant,
patchAssistant,
deleteAssistant,
listAssistants,
getAssistantDocuments,
uploadAssistantAvatar,
};
+315
View File
@@ -0,0 +1,315 @@
const { logger } = require('@librechat/data-schemas');
const { ToolCallTypes } = require('librechat-data-provider');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { validateAndUpdateTool } = require('~/server/services/ActionService');
const { getCachedTools } = require('~/server/services/Config');
const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
const { updateAssistantDoc } = require('~/models');
const { getOpenAIClient } = require('./helpers');
/**
* Create an assistant.
* @route POST /assistants
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
* @returns {Assistant} 201 - success response - application/json
*/
const createAssistant = async (req, res) => {
try {
/** @type {{ openai: OpenAIClient }} */
const { openai } = await getOpenAIClient({ req, res });
const {
tools = [],
endpoint,
conversation_starters,
append_current_datetime,
...assistantData
} = req.body;
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = (await getCachedTools()) ?? {};
assistantData.tools = tools
.map((tool) => {
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
* the assistants runtime — drop them even when posted directly, since
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
if (isAgentsOnlyTool(tool)) {
logger.warn(
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
);
return undefined;
}
if (typeof tool !== 'string') {
return tool;
}
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
})
.filter((tool) => tool)
.flat();
let azureModelIdentifier = null;
if (openai.locals?.azureOptions) {
azureModelIdentifier = assistantData.model;
assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
assistantData.metadata = {
author: req.user.id,
endpoint,
};
const assistant = await openai.beta.assistants.create(assistantData);
const createData = { user: req.user.id };
if (conversation_starters) {
createData.conversation_starters = conversation_starters;
}
if (append_current_datetime !== undefined) {
createData.append_current_datetime = append_current_datetime;
}
const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
if (azureModelIdentifier) {
assistant.model = azureModelIdentifier;
}
if (document.conversation_starters) {
assistant.conversation_starters = document.conversation_starters;
}
if (append_current_datetime !== undefined) {
assistant.append_current_datetime = append_current_datetime;
}
logger.debug('/assistants/', assistant);
res.status(201).json(assistant);
} catch (error) {
logger.error('[/assistants] Error creating assistant', error);
res.status(500).json({ error: error.message });
}
};
/**
* Modifies an assistant.
* @param {object} params
* @param {ServerRequest} params.req
* @param {OpenAIClient} params.openai
* @param {string} params.assistant_id
* @param {AssistantUpdateParams} params.updateData
* @returns {Promise<Assistant>} The updated assistant.
*/
const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
await validateAuthor({ req, openai });
const tools = [];
let conversation_starters = null;
if (updateData?.conversation_starters) {
const conversationStartersUpdate = await updateAssistantDoc(
{ assistant_id: assistant_id },
{ conversation_starters: updateData.conversation_starters },
);
conversation_starters = conversationStartersUpdate.conversation_starters;
delete updateData.conversation_starters;
}
if (updateData?.append_current_datetime !== undefined) {
await updateAssistantDoc(
{ assistant_id: assistant_id },
{ append_current_datetime: updateData.append_current_datetime },
);
delete updateData.append_current_datetime;
}
let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
* the assistants runtime — drop them even when posted directly, since
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
if (isAgentsOnlyTool(tool)) {
logger.warn(
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
);
continue;
}
const toolDefinitions = (await getCachedTools()) ?? {};
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
actualTool = Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
} else if (!actualTool) {
continue;
}
if (Array.isArray(actualTool)) {
for (const subTool of actualTool) {
if (!subTool.function) {
tools.push(subTool);
continue;
}
const updatedTool = await validateAndUpdateTool({ req, tool: subTool, assistant_id });
if (updatedTool) {
tools.push(updatedTool);
}
}
continue;
}
if (actualTool.type === ToolCallTypes.FILE_SEARCH) {
hasFileSearch = true;
}
if (!actualTool.function) {
tools.push(actualTool);
continue;
}
const updatedTool = await validateAndUpdateTool({ req, tool: actualTool, assistant_id });
if (updatedTool) {
tools.push(updatedTool);
}
}
if (hasFileSearch && !updateData.tool_resources) {
const assistant = await openai.beta.assistants.retrieve(assistant_id);
updateData.tool_resources = assistant.tool_resources ?? null;
}
if (hasFileSearch && !updateData.tool_resources?.file_search) {
updateData.tool_resources = {
...(updateData.tool_resources ?? {}),
file_search: {
vector_store_ids: [],
},
};
}
updateData.tools = tools;
if (openai.locals?.azureOptions && updateData.model) {
updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
const assistant = await openai.beta.assistants.update(assistant_id, updateData);
if (conversation_starters) {
assistant.conversation_starters = conversation_starters;
}
return assistant;
};
/**
* Modifies an assistant with the resource file id.
* @param {object} params
* @param {ServerRequest} params.req
* @param {OpenAIClient} params.openai
* @param {string} params.assistant_id
* @param {string} params.tool_resource
* @param {string} params.file_id
* @returns {Promise<Assistant>} The updated assistant.
*/
const addResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
const assistant = await openai.beta.assistants.retrieve(assistant_id);
const { tool_resources = {} } = assistant;
if (tool_resources[tool_resource]) {
tool_resources[tool_resource].file_ids.push(file_id);
} else {
tool_resources[tool_resource] = { file_ids: [file_id] };
}
delete assistant.id;
return await updateAssistant({
req,
openai,
assistant_id,
updateData: { tools: assistant.tools, tool_resources },
});
};
/**
* Deletes a file ID from an assistant's resource.
* @param {object} params
* @param {ServerRequest} params.req
* @param {OpenAIClient} params.openai
* @param {string} params.assistant_id
* @param {string} [params.tool_resource]
* @param {string} params.file_id
* @param {AssistantUpdateParams} params.updateData
* @returns {Promise<Assistant>} The updated assistant.
*/
const deleteResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
const assistant = await openai.beta.assistants.retrieve(assistant_id);
const { tool_resources = {} } = assistant;
if (tool_resource && tool_resources[tool_resource]) {
const resource = tool_resources[tool_resource];
const index = resource.file_ids.indexOf(file_id);
if (index !== -1) {
resource.file_ids.splice(index, 1);
}
} else {
for (const resourceKey in tool_resources) {
const resource = tool_resources[resourceKey];
const index = resource.file_ids.indexOf(file_id);
if (index !== -1) {
resource.file_ids.splice(index, 1);
break;
}
}
}
delete assistant.id;
return await updateAssistant({
req,
openai,
assistant_id,
updateData: { tools: assistant.tools, tool_resources },
});
};
/**
* Modifies an assistant.
* @route PATCH /assistants/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Assistant identifier.
* @param {AssistantUpdateParams} req.body - The assistant update parameters.
* @returns {Assistant} 200 - success response - application/json
*/
const patchAssistant = async (req, res) => {
try {
const { openai } = await getOpenAIClient({ req, res });
const assistant_id = req.params.id;
const { endpoint: _e, ...updateData } = req.body;
updateData.tools = updateData.tools ?? [];
const updatedAssistant = await updateAssistant({ req, openai, assistant_id, updateData });
res.json(updatedAssistant);
} catch (error) {
logger.error('[/assistants/:id] Error updating assistant', error);
res.status(500).json({ error: error.message });
}
};
module.exports = {
patchAssistant,
createAssistant,
updateAssistant,
addResourceFileId,
deleteResourceFileId,
};
@@ -0,0 +1,30 @@
const { logger } = require('@librechat/data-schemas');
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const loginController = async (req, res) => {
try {
if (!req.user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
if (req.user.twoFactorEnabled) {
const tempToken = generate2FATempToken(req.user._id);
return res.status(200).json({ twoFAPending: true, tempToken });
}
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
user.id = user._id.toString();
const token = await setAuthTokens(req.user._id, res, null, req);
return res.status(200).send({ token, user });
} catch (err) {
logger.error('[loginController]', err);
return res.status(500).json({ message: 'Something went wrong' });
}
};
module.exports = {
loginController,
};
@@ -0,0 +1,144 @@
const cookies = require('cookie');
const { isEnabled, clearCloudFrontCookies } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logoutUser } = require('~/server/services/AuthService');
const { getOpenIdConfig } = require('~/strategies');
/** Parses and validates OPENID_MAX_LOGOUT_URL_LENGTH, returning defaultValue on invalid input */
function parseMaxLogoutUrlLength(defaultValue = 2000) {
const raw = process.env.OPENID_MAX_LOGOUT_URL_LENGTH;
const trimmed = raw == null ? '' : raw.trim();
if (trimmed === '') {
return defaultValue;
}
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
logger.warn(
`[logoutController] Invalid OPENID_MAX_LOGOUT_URL_LENGTH value "${raw}", using default ${defaultValue}`,
);
return defaultValue;
}
return parsed;
}
const logoutController = async (req, res) => {
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid';
let refreshToken;
let idToken;
if (isOpenIdUser && req.session?.openidTokens) {
refreshToken = req.session.openidTokens.refreshToken;
idToken = req.session.openidTokens.idToken;
delete req.session.openidTokens;
}
refreshToken = refreshToken || parsedCookies.refreshToken;
idToken = idToken || parsedCookies.openid_id_token;
try {
const logout = await logoutUser(req, refreshToken);
const { status, message } = logout;
res.clearCookie('refreshToken');
res.clearCookie('openid_access_token');
res.clearCookie('openid_id_token');
res.clearCookie('openid_user_id');
res.clearCookie('token_provider');
clearCloudFrontCookies(res, {
userId: req.user?.id ?? req.user?._id?.toString?.(),
tenantId: req.user?.tenantId,
});
const response = { message };
if (
isOpenIdUser &&
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
process.env.OPENID_ISSUER
) {
let openIdConfig;
try {
openIdConfig = getOpenIdConfig();
} catch (err) {
logger.warn('[logoutController] OpenID config not available:', err.message);
}
if (openIdConfig) {
const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint;
if (endSessionEndpoint) {
const endSessionUrl = new URL(endSessionEndpoint);
const postLogoutRedirectUri =
process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`;
endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
/**
* OIDC RP-Initiated Logout cascading strategy:
* 1. id_token_hint (most secure, identifies exact session)
* 2. logout_hint + client_id (when URL would exceed safe length)
* 3. client_id only (when no token available)
*
* JWT tokens from spec-compliant OIDC providers use base64url
* encoding (RFC 7515), whose characters are all URL-safe, so
* token length equals URL-encoded length for projection.
* Non-compliant issuers using standard base64 (+/=) will cause
* underestimation; increase OPENID_MAX_LOGOUT_URL_LENGTH if the
* fallback does not trigger as expected.
*/
const maxLogoutUrlLength = parseMaxLogoutUrlLength();
let strategy = 'no_token';
if (idToken) {
const baseLength = endSessionUrl.toString().length;
const projectedLength = baseLength + '&id_token_hint='.length + idToken.length;
if (projectedLength > maxLogoutUrlLength) {
strategy = 'too_long';
logger.debug(
`[logoutController] Logout URL too long (${projectedLength} chars, max ${maxLogoutUrlLength}), ` +
'switching to logout_hint strategy',
);
} else {
strategy = 'use_token';
}
}
if (strategy === 'use_token') {
endSessionUrl.searchParams.set('id_token_hint', idToken);
} else {
if (strategy === 'too_long') {
const logoutHint = req.user?.email || req.user?.username || req.user?.openidId;
if (logoutHint) {
endSessionUrl.searchParams.set('logout_hint', logoutHint);
}
}
if (process.env.OPENID_CLIENT_ID) {
endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID);
} else if (strategy === 'too_long') {
logger.warn(
'[logoutController] Logout URL exceeds max length and OPENID_CLIENT_ID is not set. ' +
'The OIDC end-session request may be rejected. ' +
'Consider setting OPENID_CLIENT_ID or increasing OPENID_MAX_LOGOUT_URL_LENGTH.',
);
} else {
logger.warn(
'[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' +
'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' +
'The OIDC end-session request may be rejected by the identity provider.',
);
}
}
response.redirect = endSessionUrl.toString();
} else {
logger.warn(
'[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
);
}
}
}
return res.status(status).send(response);
} catch (err) {
logger.error('[logoutController]', err);
return res.status(500).json({ message: err.message });
}
};
module.exports = {
logoutController,
};
@@ -0,0 +1,583 @@
const cookies = require('cookie');
const mockLogoutUser = jest.fn();
const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
const mockIsEnabled = jest.fn();
const mockGetOpenIdConfig = jest.fn();
const mockClearCloudFrontCookies = jest.fn();
jest.mock('cookie');
jest.mock('@librechat/api', () => ({
isEnabled: (...args) => mockIsEnabled(...args),
clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args),
}));
jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger }));
jest.mock('~/server/services/AuthService', () => ({
logoutUser: (...args) => mockLogoutUser(...args),
}));
jest.mock('~/strategies', () => ({ getOpenIdConfig: () => mockGetOpenIdConfig() }));
const { logoutController } = require('./LogoutController');
function buildReq(overrides = {}) {
return {
user: { _id: 'user1', openidId: 'oid1', provider: 'openid' },
headers: { cookie: 'refreshToken=rt1' },
session: {
openidTokens: { refreshToken: 'srt', idToken: 'small-id-token' },
destroy: jest.fn(),
},
...overrides,
};
}
function buildRes() {
const res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
clearCookie: jest.fn(),
};
return res;
}
const ORIGINAL_ENV = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...ORIGINAL_ENV,
OPENID_USE_END_SESSION_ENDPOINT: 'true',
OPENID_ISSUER: 'https://idp.example.com',
OPENID_CLIENT_ID: 'my-client-id',
DOMAIN_CLIENT: 'https://app.example.com',
};
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
mockLogoutUser.mockResolvedValue({ status: 200, message: 'Logout successful' });
mockIsEnabled.mockReturnValue(true);
mockGetOpenIdConfig.mockReturnValue({
serverMetadata: () => ({
end_session_endpoint: 'https://idp.example.com/logout',
}),
});
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
describe('LogoutController', () => {
describe('id_token_hint from session', () => {
it('sets id_token_hint when session has idToken', async () => {
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
expect(body.redirect).not.toContain('client_id=');
});
});
describe('id_token_hint from cookie fallback', () => {
it('uses cookie id_token when session has no tokens', async () => {
cookies.parse.mockReturnValue({
refreshToken: 'cookie-rt',
openid_id_token: 'cookie-id-token',
});
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=cookie-id-token');
});
});
describe('client_id fallback', () => {
it('falls back to client_id when no idToken is available', async () => {
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('client_id=my-client-id');
expect(body.redirect).not.toContain('id_token_hint=');
});
it('does not produce client_id=undefined when OPENID_CLIENT_ID is unset', async () => {
delete process.env.OPENID_CLIENT_ID;
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('client_id=');
expect(body.redirect).not.toContain('undefined');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Neither id_token_hint nor OPENID_CLIENT_ID'),
);
});
});
describe('OPENID_USE_END_SESSION_ENDPOINT disabled', () => {
it('does not include redirect when disabled', async () => {
mockIsEnabled.mockReturnValue(false);
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('OPENID_ISSUER unset', () => {
it('does not include redirect when OPENID_ISSUER is missing', async () => {
delete process.env.OPENID_ISSUER;
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('non-OpenID user', () => {
it('does not include redirect for non-OpenID users', async () => {
const req = buildReq({
user: { _id: 'user1', provider: 'local' },
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('post_logout_redirect_uri', () => {
it('uses OPENID_POST_LOGOUT_REDIRECT_URI when set', async () => {
process.env.OPENID_POST_LOGOUT_REDIRECT_URI = 'https://custom.example.com/logged-out';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
const url = new URL(body.redirect);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://custom.example.com/logged-out',
);
});
it('defaults to DOMAIN_CLIENT/login when OPENID_POST_LOGOUT_REDIRECT_URI is unset', async () => {
delete process.env.OPENID_POST_LOGOUT_REDIRECT_URI;
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
const url = new URL(body.redirect);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://app.example.com/login',
);
});
});
describe('OpenID config not available', () => {
it('warns and returns no redirect when getOpenIdConfig throws', async () => {
mockGetOpenIdConfig.mockImplementation(() => {
throw new Error('OpenID configuration has not been initialized');
});
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('OpenID config not available'),
'OpenID configuration has not been initialized',
);
});
});
describe('end_session_endpoint not in metadata', () => {
it('warns and returns no redirect when end_session_endpoint is missing', async () => {
mockGetOpenIdConfig.mockReturnValue({
serverMetadata: () => ({}),
});
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('end_session_endpoint not found'),
);
});
});
describe('error handling', () => {
it('returns 500 on logoutUser error', async () => {
mockLogoutUser.mockRejectedValue(new Error('session error'));
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ message: 'session error' });
});
});
describe('cookie clearing', () => {
it('clears all auth cookies on successful logout', async () => {
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
expect(res.clearCookie).toHaveBeenCalledWith('openid_access_token');
expect(res.clearCookie).toHaveBeenCalledWith('openid_id_token');
expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id');
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
});
it('calls clearCloudFrontCookies on successful logout', async () => {
const req = buildReq({ user: { _id: 'user1', tenantId: 'tenantA' } });
const res = buildRes();
await logoutController(req, res);
expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(res, {
userId: 'user1',
tenantId: 'tenantA',
});
});
});
describe('URL length limit and logout_hint fallback', () => {
it('uses logout_hint when id_token makes URL exceed default limit (2000 chars)', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
expect(body.redirect).toContain('client_id=my-client-id');
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Logout URL too long'));
});
it('uses id_token_hint when URL is within default limit', async () => {
const shortIdToken = 'short-token';
const req = buildReq({
session: {
openidTokens: { refreshToken: 'srt', idToken: shortIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=short-token');
expect(body.redirect).not.toContain('logout_hint=');
expect(body.redirect).not.toContain('client_id=');
});
it('respects custom OPENID_MAX_LOGOUT_URL_LENGTH', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500';
const mediumIdToken = 'a'.repeat(600);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: mediumIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
});
it('uses username as logout_hint when email is not available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: 'oid1',
provider: 'openid',
username: 'testuser',
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('logout_hint=testuser');
});
it('uses openidId as logout_hint when email and username are not available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'unique-oid-123', provider: 'openid' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('logout_hint=unique-oid-123');
});
it('uses openidId as logout_hint when email and username are explicitly null', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: 'oid-without-email',
provider: 'openid',
email: null,
username: null,
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=oid-without-email');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('uses only client_id when absolutely no hint is available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: '',
provider: 'openid',
email: '',
username: '',
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).not.toContain('logout_hint=');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('warns about missing OPENID_CLIENT_ID when URL is too long', async () => {
delete process.env.OPENID_CLIENT_ID;
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=');
expect(body.redirect).not.toContain('client_id=');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('OPENID_CLIENT_ID is not set'),
);
});
it('falls back to logout_hint for cookie-sourced long token', async () => {
const longCookieToken = 'a'.repeat(3000);
cookies.parse.mockReturnValue({
refreshToken: 'cookie-rt',
openid_id_token: longCookieToken,
});
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: { destroy: jest.fn() },
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('keeps id_token_hint when projected URL length equals the max', async () => {
const baseUrl = new URL('https://idp.example.com/logout');
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
const baseLength = baseUrl.toString().length;
const tokenLength = 2000 - baseLength - '&id_token_hint='.length;
const exactToken = 'a'.repeat(tokenLength);
const req = buildReq({
session: {
openidTokens: { refreshToken: 'srt', idToken: exactToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=');
expect(body.redirect).not.toContain('logout_hint=');
});
it('falls back to logout_hint when projected URL is one char over the max', async () => {
const baseUrl = new URL('https://idp.example.com/logout');
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
const baseLength = baseUrl.toString().length;
const tokenLength = 2000 - baseLength - '&id_token_hint='.length + 1;
const overToken = 'a'.repeat(tokenLength);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: overToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=');
});
});
describe('invalid OPENID_MAX_LOGOUT_URL_LENGTH values', () => {
it('silently uses default when value is empty', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for partial numeric string', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500abc';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for zero value', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '0';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for negative value', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '-1';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for non-numeric string', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = 'abc';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
});
});
@@ -0,0 +1,61 @@
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const {
verifyTOTP,
getTOTPSecret,
verifyBackupCode,
} = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { getUserById } = require('~/models');
/**
* Verifies the 2FA code during login using a temporary token.
*/
const verify2FAWithTempToken = async (req, res) => {
try {
const { tempToken, token, backupCode } = req.body;
if (!tempToken) {
return res.status(400).json({ message: 'Missing temporary token' });
}
let payload;
try {
payload = jwt.verify(tempToken, process.env.JWT_SECRET);
} catch (err) {
logger.error('Failed to verify temporary token:', err);
return res.status(401).json({ message: 'Invalid or expired temporary token' });
}
const user = await getUserById(payload.userId, '+totpSecret +backupCodes');
if (!user || !user.twoFactorEnabled) {
return res.status(400).json({ message: '2FA is not enabled for this user' });
}
const secret = await getTOTPSecret(user.totpSecret);
let isVerified = false;
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
}
if (!isVerified) {
return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
}
const userData = user.toObject ? user.toObject() : { ...user };
delete userData.__v;
delete userData.password;
delete userData.totpSecret;
delete userData.backupCodes;
userData.id = user._id.toString();
const authToken = await setAuthTokens(user._id, res, null, req);
return res.status(200).json({ token: authToken, user: userData });
} catch (err) {
logger.error('[verify2FAWithTempToken]', err);
return res.status(500).json({ message: 'Something went wrong' });
}
};
module.exports = { verify2FAWithTempToken };
+92
View File
@@ -0,0 +1,92 @@
const { CacheKeys } = require('librechat-data-provider');
const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas');
const {
isEnabled,
getAdminPanelUrl,
isAdminPanelRedirect,
generateAdminExchangeCode,
} = require('@librechat/api');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const getLogStores = require('~/cache/getLogStores');
const { checkBan } = require('~/server/middleware');
const { generateToken } = require('~/models');
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
function createOAuthHandler(redirectUri = domains.client) {
/**
* A handler to process OAuth authentication results.
* @type {Function}
* @param {ServerRequest} req - Express request object.
* @param {ServerResponse} res - Express response object.
* @param {NextFunction} next - Express next middleware function.
*/
return async (req, res, next) => {
try {
if (res.headersSent) {
return;
}
await checkBan(req, res);
if (req.banned) {
return;
}
/** Check if this is an admin panel redirect (cross-origin or same-origin subpath) */
if (isAdminPanelRedirect(redirectUri, getAdminPanelUrl(), domains.client)) {
/** For admin panel, generate exchange code instead of setting cookies */
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
const token = await generateToken(req.user, sessionExpiry);
/** Get refresh token from tokenset for OpenID users */
const refreshToken =
req.user.provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true
? req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token
: undefined;
const expiresAt = Date.now() + sessionExpiry;
const callbackUrl = new URL(redirectUri);
const exchangeCode = await generateAdminExchangeCode(
cache,
req.user,
token,
refreshToken,
callbackUrl.origin,
req.pkceChallenge,
expiresAt,
);
callbackUrl.searchParams.set('code', exchangeCode);
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
return res.redirect(callbackUrl.toString());
}
/** Standard OAuth flow - set cookies and redirect */
if (
req.user &&
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, req, res, {
userId: req.user._id.toString(),
tenantId: req.user.tenantId,
});
} else {
await setAuthTokens(req.user._id, res, null, req);
}
res.redirect(redirectUri);
} catch (err) {
logger.error('Error in setting authentication tokens:', err);
next(err);
}
};
}
module.exports = {
createOAuthHandler,
};
+151
View File
@@ -0,0 +1,151 @@
const mockIsEnabled = jest.fn();
const mockGetAdminPanelUrl = jest.fn();
const mockIsAdminPanelRedirect = jest.fn();
const mockGenerateAdminExchangeCode = jest.fn();
const mockSyncUserEntraGroupMemberships = jest.fn();
const mockSetAuthTokens = jest.fn();
const mockSetOpenIDAuthTokens = jest.fn();
const mockGetLogStores = jest.fn();
const mockCheckBan = jest.fn();
const mockGenerateToken = jest.fn();
const mockLogger = { info: jest.fn(), error: jest.fn() };
jest.mock('librechat-data-provider', () => ({
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
}));
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
DEFAULT_SESSION_EXPIRY: 60000,
}));
jest.mock('@librechat/api', () => ({
isEnabled: (...args) => mockIsEnabled(...args),
getAdminPanelUrl: (...args) => mockGetAdminPanelUrl(...args),
isAdminPanelRedirect: (...args) => mockIsAdminPanelRedirect(...args),
generateAdminExchangeCode: (...args) => mockGenerateAdminExchangeCode(...args),
}));
jest.mock('~/server/services/PermissionService', () => ({
syncUserEntraGroupMemberships: (...args) => mockSyncUserEntraGroupMemberships(...args),
}));
jest.mock('~/server/services/AuthService', () => ({
setAuthTokens: (...args) => mockSetAuthTokens(...args),
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
}));
jest.mock(
'~/cache/getLogStores',
() =>
(...args) =>
mockGetLogStores(...args),
);
jest.mock('~/server/middleware', () => ({
checkBan: (...args) => mockCheckBan(...args),
}));
jest.mock('~/models', () => ({
generateToken: (...args) => mockGenerateToken(...args),
}));
const { createOAuthHandler } = require('./oauth');
const ORIGINAL_ENV = process.env;
function buildReq(overrides = {}) {
return {
user: {
_id: 'user-123',
email: 'admin@example.com',
provider: 'openid',
tokenset: { refresh_token: 'openid-refresh-token', access_token: 'openid-access-token' },
federatedTokens: { refresh_token: 'federated-refresh-token' },
},
pkceChallenge: 'pkce-challenge',
banned: false,
...overrides,
};
}
function buildRes() {
return {
headersSent: false,
redirect: jest.fn(),
};
}
describe('createOAuthHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...ORIGINAL_ENV,
DOMAIN_CLIENT: 'http://localhost:3080',
DOMAIN_SERVER: 'http://localhost:3080',
OPENID_REUSE_TOKENS: 'false',
};
mockIsEnabled.mockImplementation((value) => value === 'true' || value === true);
mockGetAdminPanelUrl.mockReturnValue('http://admin.example.com');
mockIsAdminPanelRedirect.mockReturnValue(true);
mockGetLogStores.mockReturnValue({});
mockCheckBan.mockResolvedValue(undefined);
mockGenerateToken.mockResolvedValue('jwt-token');
mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code');
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it('omits refresh token from admin exchange when OPENID_REUSE_TOKENS is disabled', async () => {
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
const req = buildReq();
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
{},
req.user,
'jwt-token',
undefined,
'http://admin.example.com',
'pkce-challenge',
expect.any(Number),
);
expect(res.redirect).toHaveBeenCalledWith(
'http://admin.example.com/auth/openid/callback?code=exchange-code',
);
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
expect(mockSetAuthTokens).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('includes refresh token in admin exchange when OPENID_REUSE_TOKENS is enabled', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
const req = buildReq();
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
{},
req.user,
'jwt-token',
'openid-refresh-token',
'http://admin.example.com',
'pkce-challenge',
expect.any(Number),
);
expect(res.redirect).toHaveBeenCalledWith(
'http://admin.example.com/auth/openid/callback?code=exchange-code',
);
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
expect(mockSetAuthTokens).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
});
+504
View File
@@ -0,0 +1,504 @@
/**
* MCP Tools Controller
* Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
*
* @import { MCPServerRegistry } from '@librechat/api'
* @import { MCPServerDocument } from 'librechat-data-provider'
*/
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const {
checkAccess,
isUserSourced,
MCPErrorCodes,
redactServerSecrets,
redactAllServerSecrets,
isMCPDomainNotAllowedError,
isMCPInspectionFailedError,
} = require('@librechat/api');
const {
Constants,
Permissions,
ResourceType,
PermissionBits,
PermissionTypes,
MCP_USER_INPUT_FIELDS,
MCPServerUserInputSchema,
} = require('librechat-data-provider');
const {
resolveConfigServers,
resolveMcpConfigNames,
resolveAllMcpConfigs,
} = require('~/server/services/MCP');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const { getResourcePermissionsMap } = require('~/server/services/PermissionService');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
const db = require('~/models');
/**
* Handles MCP-specific errors and sends appropriate HTTP responses.
* @param {Error} error - The error to handle
* @param {import('express').Response} res - Express response object
* @returns {import('express').Response | null} Response if handled, null if not an MCP error
*/
function handleMCPError(error, res) {
if (isMCPDomainNotAllowedError(error)) {
return res.status(error.statusCode).json({
error: error.code,
message: error.message,
});
}
if (isMCPInspectionFailedError(error)) {
return res.status(error.statusCode).json({
error: error.code,
message: error.message,
});
}
// Fallback for legacy string-based error handling (backwards compatibility)
if (error.message?.startsWith(MCPErrorCodes.DOMAIN_NOT_ALLOWED)) {
return res.status(403).json({
error: MCPErrorCodes.DOMAIN_NOT_ALLOWED,
message: error.message.replace(/^MCP_DOMAIN_NOT_ALLOWED\s*:\s*/i, ''),
});
}
if (error.message?.startsWith(MCPErrorCodes.INSPECTION_FAILED)) {
return res.status(400).json({
error: MCPErrorCodes.INSPECTION_FAILED,
message: error.message,
});
}
return null;
}
/**
* Get all MCP tools available to the user.
*/
const getMCPTools = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('[getMCPTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const mcpConfig = await resolveAllMcpConfigs(userId, req.user);
const configuredServers = Object.keys(mcpConfig);
if (!configuredServers.length) {
return res.status(200).json({ servers: {} });
}
const mcpManager = getMCPManager();
const mcpServers = {};
const serverToolsMap = new Map();
const cacheResults = await Promise.all(
configuredServers.map(async (serverName) => {
try {
return {
serverName,
tools: await getMCPServerTools(userId, serverName, mcpConfig[serverName]),
};
} catch (error) {
logger.error(`[getMCPTools] Error fetching cached tools for ${serverName}:`, error);
return { serverName, tools: null };
}
}),
);
for (const { serverName, tools } of cacheResults) {
if (tools) {
serverToolsMap.set(serverName, tools);
continue;
}
let serverTools;
try {
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
} catch (error) {
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
continue;
}
if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue;
}
serverToolsMap.set(serverName, serverTools);
if (Object.keys(serverTools).length > 0) {
// Cache asynchronously without blocking
cacheMCPServerTools({
userId,
serverName,
serverTools,
serverConfig: mcpConfig[serverName],
}).catch((err) =>
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
);
}
}
// Process each configured server
for (const serverName of configuredServers) {
try {
const serverTools = serverToolsMap.get(serverName);
const serverConfig = mcpConfig[serverName];
const server = {
name: serverName,
icon: serverConfig?.iconPath || '',
authenticated: true,
authConfig: [],
tools: [],
};
// Set authentication config once for the server
if (serverConfig?.customUserVars) {
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length > 0) {
server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
sensitive: value.sensitive,
}));
server.authenticated = false;
}
}
// Process tools efficiently - no need for convertMCPToolToPlugin
if (serverTools) {
for (const [toolKey, toolData] of Object.entries(serverTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const toolName = toolKey.split(Constants.mcp_delimiter)[0];
server.tools.push({
name: toolName,
pluginKey: toolKey,
description: toolData.function.description || '',
});
}
}
// Only add server if it has tools or is configured
if (server.tools.length > 0 || serverConfig) {
mcpServers[serverName] = server;
}
} catch (error) {
logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
}
}
res.status(200).json({ servers: mcpServers });
} catch (error) {
logger.error('[getMCPTools]', error);
res.status(500).json({ message: error.message });
}
};
/**
* Mirrors canAccessResource's capability bypass plus per-resource ACL EDIT check.
* `skipCapabilityWithoutDbIds` lets the list path skip the MANAGE_MCP_SERVERS probe
* when no DB-backed server is present; no list consumer reads the edit-gated fields
* the bypass would disclose. The detail route must not set it.
*/
async function computeCanEditByServer(req, serverConfigs, { skipCapabilityWithoutDbIds } = {}) {
const canEditByServer = new Map();
const dbIdsToCheck = [];
const dbIdToServerName = new Map();
for (const [name, config] of Object.entries(serverConfigs)) {
if (config.dbId) {
dbIdsToCheck.push(config.dbId);
dbIdToServerName.set(String(config.dbId), name);
continue;
}
canEditByServer.set(name, isUserSourced(config));
}
if (skipCapabilityWithoutDbIds === true && dbIdsToCheck.length === 0) {
return canEditByServer;
}
let bypass = false;
try {
bypass = await hasCapability(req.user, SystemCapabilities.MANAGE_MCP_SERVERS);
} catch (err) {
logger.warn(`[computeCanEditByServer] Capability bypass check failed: ${err.message}`);
}
if (bypass) {
for (const name of Object.keys(serverConfigs)) {
canEditByServer.set(name, true);
}
return canEditByServer;
}
if (dbIdsToCheck.length > 0) {
try {
const permsMap = await getResourcePermissionsMap({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.MCPSERVER,
resourceIds: dbIdsToCheck,
});
for (const [dbIdStr, name] of dbIdToServerName) {
const bits = permsMap.get(dbIdStr) ?? 0;
canEditByServer.set(name, (bits & PermissionBits.EDIT) !== 0);
}
} catch (err) {
logger.warn(
`[computeCanEditByServer] ACL lookup failed, defaulting to no edit: ${err.message}`,
);
for (const name of dbIdToServerName.values()) {
canEditByServer.set(name, false);
}
}
}
return canEditByServer;
}
/**
* Get all MCP servers with permissions
* @route GET /api/mcp/servers
*/
const getMCPServersList = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
const serverConfigs = await resolveAllMcpConfigs(userId, req.user);
const canEditByServer = await computeCanEditByServer(req, serverConfigs, {
skipCapabilityWithoutDbIds: true,
});
return res.json(redactAllServerSecrets(serverConfigs, { canEditByServer }));
} catch (error) {
logger.error('[getMCPServersList]', error);
res.status(500).json({ error: error.message });
}
};
/**
* Returns true when the request body's parsed config configures OBO. We block
* non-permission holders from creating or updating any DB-stored MCP server
* that mints per-user delegated tokens.
*/
function configHasObo(parsedConfig) {
return (
!!parsedConfig &&
typeof parsedConfig === 'object' &&
'obo' in parsedConfig &&
parsedConfig.obo != null
);
}
/**
* Fields a user without `CONFIGURE_OBO` may modify on an OBO server (allowlist).
* Any field not on this list is locked: changes to it (add, modify, or remove)
* require the permission. Allowlisting is fail-closed — when upstream introduces
* a new MCP server config field, it lands in the locked set by default until
* explicitly opted in here. Anything that could redirect the OBO token flow
* (`url`, `proxy`, `headers`), change scopes (`obo`), or reroute auth (`oauth`,
* `apiKey`, `customUserVars`) MUST stay locked.
*/
const OBO_USER_EDITABLE_FIELDS = new Set(['title', 'description', 'iconPath']);
/**
* Returns true when any non-allowlisted user-input field differs between the
* existing server config and the new payload. Treats add, remove, and modify
* as changes (stable JSON compare, with absence on either side counting as a
* change unless both sides are absent). The comparison surface is
* `MCP_USER_INPUT_FIELDS` (schema-derived from `MCPServerUserInputSchema`),
* so new fields on the schema are picked up automatically and stay locked
* by default until added to the allowlist above.
*/
function violatesOboLockdown(existingConfig, newConfig) {
for (const field of MCP_USER_INPUT_FIELDS) {
if (OBO_USER_EDITABLE_FIELDS.has(field)) continue;
const existing = existingConfig?.[field];
const next = newConfig?.[field];
if (existing === undefined && next === undefined) continue;
if (JSON.stringify(existing) !== JSON.stringify(next)) {
return true;
}
}
return false;
}
async function callerCanConfigureObo(req) {
return checkAccess({
req,
user: req.user,
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.CONFIGURE_OBO],
getRoleByName: db.getRoleByName,
});
}
/**
* Create MCP server
* @route POST /api/mcp/servers
*/
const createMCPServerController = async (req, res) => {
try {
const userId = req.user?.id;
const { config } = req.body;
const validation = MCPServerUserInputSchema.safeParse(config);
if (!validation.success) {
return res.status(400).json({
message: 'Invalid configuration',
errors: validation.error.errors,
});
}
if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
logger.warn(
`[createMCPServer] User ${userId} attempted to configure OBO without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
const reservedServerNames = await resolveMcpConfigNames(req);
const result = await getMCPServersRegistry().addServer(
'temp_server_name',
validation.data,
'DB',
userId,
reservedServerNames,
);
res.status(201).json({
serverName: result.serverName,
...redactServerSecrets(result.config, { canEdit: true }),
});
} catch (error) {
logger.error('[createMCPServer]', error);
const mcpErrorResponse = handleMCPError(error, res);
if (mcpErrorResponse) {
return mcpErrorResponse;
}
res.status(500).json({ message: error.message });
}
};
/**
* Get MCP server by ID
*/
const getMCPServerById = async (req, res) => {
try {
const userId = req.user?.id;
const { serverName } = req.params;
if (!serverName) {
return res.status(400).json({ message: 'Server name is required' });
}
const configServers = await resolveConfigServers(req);
const parsedConfig = await getMCPServersRegistry().getServerConfig(
serverName,
userId,
configServers,
);
if (!parsedConfig) {
return res.status(404).json({ message: 'MCP server not found' });
}
const canEditMap = await computeCanEditByServer(req, { [serverName]: parsedConfig });
const canEdit = canEditMap.get(serverName) ?? false;
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit }));
} catch (error) {
logger.error('[getMCPServerById]', error);
res.status(500).json({ message: error.message });
}
};
/**
* Update MCP server
* @route PATCH /api/mcp/servers/:serverName
*/
const updateMCPServerController = async (req, res) => {
try {
const userId = req.user?.id;
const { serverName } = req.params;
const { config } = req.body;
const validation = MCPServerUserInputSchema.safeParse(config);
if (!validation.success) {
return res.status(400).json({
message: 'Invalid configuration',
errors: validation.error.errors,
});
}
/**
* On an existing OBO server, lock down every user-input field except the
* cosmetic allowlist (title, description, iconPath) for callers without
* CONFIGURE_OBO. This closes the OBO redirect vector — without it, a user
* with UPDATE could change `url` (or `proxy`/`headers`/`customUserVars`)
* to point OBO-minted tokens at an attacker-controlled endpoint. Adds,
* modifies, and removes are all caught.
*/
const existingConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
if (configHasObo(existingConfig) && !(await callerCanConfigureObo(req))) {
if (violatesOboLockdown(existingConfig, validation.data)) {
logger.warn(
`[updateMCPServer] User ${userId} attempted to modify a locked field on OBO server '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
} else if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
// Adding OBO to a non-OBO server (or first-time configuration) still
// requires the permission, even if existing has no OBO.
logger.warn(
`[updateMCPServer] User ${userId} attempted to add OBO to '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
const parsedConfig = await getMCPServersRegistry().updateServer(
serverName,
validation.data,
'DB',
userId,
);
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit: true }));
} catch (error) {
logger.error('[updateMCPServer]', error);
const mcpErrorResponse = handleMCPError(error, res);
if (mcpErrorResponse) {
return mcpErrorResponse;
}
res.status(500).json({ message: error.message });
}
};
/**
* Delete MCP server
* @route DELETE /api/mcp/servers/:serverName
*/
const deleteMCPServerController = async (req, res) => {
try {
const userId = req.user?.id;
const { serverName } = req.params;
await getMCPServersRegistry().removeServer(serverName, 'DB', userId);
res.status(200).json({ message: 'MCP server deleted successfully' });
} catch (error) {
logger.error('[deleteMCPServer]', error);
res.status(500).json({ message: error.message });
}
};
module.exports = {
getMCPTools,
getMCPServersList,
createMCPServerController,
getMCPServerById,
updateMCPServerController,
deleteMCPServerController,
};
+259
View File
@@ -0,0 +1,259 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
const {
Tools,
AuthType,
Permissions,
ToolCallTypes,
PermissionTypes,
} = require('librechat-data-provider');
const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { getRetentionExpiry } = require('~/server/services/Files/retention');
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util');
/**
* Tools that are callable directly via `POST /tools/:toolId/call`.
* `execute_code` is the only entry today; the tool runs server-side via
* the agents library / sandbox service without any per-user credential.
*/
const directCallableTools = new Set([Tools.execute_code]);
const toolAccessPermType = {
[Tools.execute_code]: PermissionTypes.RUN_CODE,
};
/**
* Verifies web search authentication, ensuring each category has at least
* one fully authenticated service.
*
* @param {ServerRequest} req - The request object
* @param {ServerResponse} res - The response object
* @returns {Promise<void>} A promise that resolves when the function has completed
*/
const verifyWebSearchAuth = async (req, res) => {
try {
const appConfig = req.config;
const userId = req.user.id;
/** @type {TCustomConfig['webSearch']} */
const webSearchConfig = appConfig?.webSearch || {};
const result = await loadWebSearchAuth({
userId,
loadAuthValues,
webSearchConfig,
throwError: false,
});
return res.status(200).json({
authenticated: result.authenticated,
authTypes: result.authTypes,
});
} catch (error) {
console.error('Error in verifyWebSearchAuth:', error);
return res.status(500).json({ message: error.message });
}
};
/**
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
* @returns {Promise<void>} A promise that resolves when the function has completed.
*/
const verifyToolAuth = async (req, res) => {
try {
const { toolId } = req.params;
if (toolId === Tools.web_search) {
return await verifyWebSearchAuth(req, res);
}
if (!directCallableTools.has(toolId)) {
res.status(404).json({ message: 'Tool not found' });
return;
}
/**
* `execute_code` no longer requires a per-user credential — sandbox
* auth is handled server-side by the agents library. Always report
* system-authenticated so the client proceeds straight to the call
* without a key-entry dialog.
*
* Deployment contract: reachability of the sandbox service is the
* admin's responsibility. This endpoint does not probe the service
* (a per-auth-check network hop would be too expensive for what is
* a UI-gate query). If the sandbox is unreachable, the call path
* surfaces the error at execution time instead of here.
*/
res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
/**
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
* @param {NextFunction} next - The next middleware function to call.
* @returns {Promise<void>} A promise that resolves when the function has completed.
*/
const callTool = async (req, res) => {
try {
const appConfig = req.config;
const { toolId = '' } = req.params;
if (!directCallableTools.has(toolId)) {
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
res.status(404).json({ message: 'Tool not found' });
return;
}
const { partIndex, blockIndex, messageId, conversationId, ...args } = req.body;
if (!messageId) {
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call without message ID`);
res.status(400).json({ message: 'Message ID required' });
return;
}
const message = await getMessage({ user: req.user.id, messageId });
if (!message) {
logger.debug(`[${toolId}/call] User ${req.user.id} attempted call with invalid message ID`);
res.status(404).json({ message: 'Message not found' });
return;
}
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
let hasAccess = true;
if (toolAccessPermType[toolId]) {
hasAccess = await checkAccess({
user: req.user,
permissionType: toolAccessPermType[toolId],
permissions: [Permissions.USE],
getRoleByName,
});
}
if (!hasAccess) {
logger.warn(
`[${toolAccessPermType[toolId]}] Forbidden: Insufficient permissions for User ${req.user.id}: ${Permissions.USE}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
}
const { loadedTools } = await loadTools({
user: req.user.id,
tools: [toolId],
functions: true,
options: {
req,
returnMetadata: true,
processFileURL,
uploadImageBuffer,
},
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
});
const tool = loadedTools[0];
const toolCallId = `${req.user.id}_${nanoid()}`;
const result = await tool.invoke({
args,
name: toolId,
id: toolCallId,
type: ToolCallTypes.TOOL_CALL,
});
const { content, artifact } = result;
const toolCallData = {
toolId,
messageId,
partIndex,
blockIndex,
conversationId,
result: content,
user: req.user.id,
...(await getRetentionExpiry(req)),
};
if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
createToolCall(toolCallData).catch((error) => {
logger.error(`Error creating tool call: ${error.message}`);
});
return res.status(200).json({
result: content,
});
}
const artifactPromises = [];
for (const file of artifact.files) {
/* Files flagged `inherited` by codeapi are unchanged passthroughs of
* inputs the caller already owns (skill files, prior downloaded inputs,
* inherited .dirkeep markers). Re-downloading them is wasted work and
* 403s when the file is scoped to a different entity (e.g. skill
* entity_id) than the user's session key. They remain available for
* subsequent tool calls via primeInvokedSkills / session inheritance. */
if (file.inherited) {
continue;
}
const { id, name } = file;
artifactPromises.push(
(async () => {
const result = await processCodeOutput({
req,
id,
name,
messageId,
toolCallId,
conversationId,
session_id: artifact.session_id,
});
const fileMetadata = result?.file ?? null;
const finalize = result?.finalize;
if (!fileMetadata) {
return null;
}
/* This endpoint is non-streaming and its contract is "give
* me the artifacts" — return the persisted record immediately
* (with `status: 'pending'` for office buckets) and run the
* preview render in the background. The client polls
* `/api/files/:file_id/preview` for the resolved record.
* No `onResolved` — there's no live stream to write to here. */
runPreviewFinalize({
finalize,
fileId: fileMetadata.file_id,
previewRevision: result?.previewRevision,
});
return fileMetadata;
})().catch((error) => {
logger.error('Error processing code output:', error);
return null;
}),
);
}
const attachments = await Promise.all(artifactPromises);
toolCallData.attachments = attachments;
createToolCall(toolCallData).catch((error) => {
logger.error(`Error creating tool call: ${error.message}`);
});
res.status(200).json({
result: content,
attachments,
});
} catch (error) {
logger.error('Error calling tool', error);
res.status(500).json({ message: 'Error calling tool' });
}
};
const getToolCalls = async (req, res) => {
try {
const { conversationId } = req.query;
const toolCalls = await getToolCallsByConvo(conversationId, req.user.id);
res.status(200).json(toolCalls);
} catch (error) {
logger.error('Error getting tool calls', error);
res.status(500).json({ message: 'Error getting tool calls' });
}
};
module.exports = {
callTool,
getToolCalls,
verifyToolAuth,
};