Merge branch 'main' into feature/entra-id-azure-integration

This commit is contained in:
victorbjorkgren 2025-10-31 13:16:16 +01:00
commit 23ac2556da
193 changed files with 3845 additions and 692 deletions

View file

@ -116,11 +116,15 @@ const refreshController = async (req, res) => {
const token = await setAuthTokens(userId, res, session);
// trigger OAuth MCP server reconnection asynchronously (best effort)
void getOAuthReconnectionManager()
.reconnectServers(userId)
.catch((err) => {
logger.error('Error reconnecting OAuth MCP servers:', err);
});
try {
void getOAuthReconnectionManager()
.reconnectServers(userId)
.catch((err) => {
logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err);
});
} catch (err) {
logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err);
}
res.status(200).send({ token, user });
} else if (req?.query?.retry) {

View file

@ -8,6 +8,7 @@ const {
Tokenizer,
checkAccess,
logAxiosError,
sanitizeTitle,
resolveHeaders,
getBalanceConfig,
memoryInstructions,
@ -775,6 +776,7 @@ class AgentClient extends BaseClient {
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
config = {
runName: 'AgentRun',
configurable: {
thread_id: this.conversationId,
last_agent_index: this.agentConfigs?.size ?? 0,
@ -1233,6 +1235,10 @@ class AgentClient extends BaseClient {
handleLLMEnd,
},
],
configurable: {
thread_id: this.conversationId,
user_id: this.user ?? this.options.req.user?.id,
},
},
});
@ -1270,7 +1276,7 @@ class AgentClient extends BaseClient {
);
});
return titleResult.title;
return sanitizeTitle(titleResult.title);
} catch (err) {
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
return;

View file

@ -10,6 +10,10 @@ jest.mock('@librechat/agents', () => ({
}),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
}));
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
@ -252,6 +256,38 @@ describe('AgentClient - titleConvo', () => {
expect(result).toBe('Generated Title');
});
it('should sanitize the generated title by removing think blocks', async () => {
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
mockRun.generateTitle.mockResolvedValue({
title: titleWithThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should remove the <think> block and return only the clean title
expect(result).toBe('User Hi Greeting');
expect(result).not.toContain('<think>');
expect(result).not.toContain('</think>');
});
it('should return fallback title when sanitization results in empty string', async () => {
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
mockRun.generateTitle.mockResolvedValue({
title: titleOnlyThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return the fallback title since sanitization would result in empty string
expect(result).toBe('Untitled Conversation');
});
it('should handle errors gracefully and return undefined', async () => {
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));

View file

@ -57,7 +57,7 @@ async function loadConfigModels(req) {
for (let i = 0; i < customEndpoints.length; i++) {
const endpoint = customEndpoints[i];
const { models, name: configName, baseURL, apiKey } = endpoint;
const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint;
const name = normalizeEndpointName(configName);
endpointsMap[name] = endpoint;
@ -76,6 +76,8 @@ async function loadConfigModels(req) {
apiKey: API_KEY,
baseURL: BASE_URL,
user: req.user.id,
userObject: req.user,
headers: endpointHeaders,
direct: endpoint.directEndpoint,
userIdQuery: models.userIdQuery,
});

View file

@ -134,16 +134,16 @@ const initializeAgent = async ({
});
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
const maxTokens = optionalChainWithEmptyCheck(
modelOptions.maxOutputTokens,
modelOptions.maxTokens,
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
const maxOutputTokens = optionalChainWithEmptyCheck(
options.llmConfig?.maxOutputTokens,
options.llmConfig?.maxTokens,
0,
);
const agentMaxContextTokens = optionalChainWithEmptyCheck(
maxContextTokens,
getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig),
4096,
18000,
);
if (
@ -203,7 +203,7 @@ const initializeAgent = async ({
userMCPAuthMap,
toolContextMap,
useLegacyContent: !!options.useLegacyContent,
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
};
};

View file

@ -3,9 +3,10 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-
const { loadAgent } = require('~/models/Agent');
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
const agentPromise = loadAgent({
req,
spec,
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint,
model_parameters,
@ -20,7 +21,6 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
endpoint,
agent_id,
endpointType,
instructions,
model_parameters,
agent: agentPromise,
});

View file

@ -1,4 +1,3 @@
const { Providers } = require('@librechat/agents');
const {
resolveHeaders,
isUserProvided,
@ -143,39 +142,27 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
if (optionsOnly) {
const modelOptions = endpointOption?.model_parameters ?? {};
if (endpoint !== Providers.OLLAMA) {
clientOptions = Object.assign(
{
modelOptions,
},
clientOptions,
);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null) {
options.useLegacyContent = true;
options.endpointTokenConfig = endpointTokenConfig;
}
if (!clientOptions.streamRate) {
return options;
}
options.llmConfig.callbacks = [
{
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
},
];
clientOptions = Object.assign(
{
modelOptions,
},
clientOptions,
);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null) {
options.useLegacyContent = true;
options.endpointTokenConfig = endpointTokenConfig;
}
if (!clientOptions.streamRate) {
return options;
}
if (clientOptions.reverseProxyUrl) {
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0];
delete clientOptions.reverseProxyUrl;
}
return {
useLegacyContent: true,
llmConfig: modelOptions,
};
options.llmConfig.callbacks = [
{
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
},
];
return options;
}
const client = new OpenAIClient(apiKey, clientOptions);

View file

@ -159,7 +159,7 @@ const initializeClient = async ({
modelOptions.model = modelName;
clientOptions = Object.assign({ modelOptions }, clientOptions);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions);
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null && serverless === true) {
options.useLegacyContent = true;
}

View file

@ -42,18 +42,26 @@ async function getCustomConfigSpeech(req, res) {
settings.advancedMode = speechTab.advancedMode;
}
if (speechTab.speechToText) {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
if (speechTab.speechToText !== undefined) {
if (typeof speechTab.speechToText === 'boolean') {
settings.speechToText = speechTab.speechToText;
} else {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
}
}
}
}
if (speechTab.textToSpeech) {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
if (speechTab.textToSpeech !== undefined) {
if (typeof speechTab.textToSpeech === 'boolean') {
settings.textToSpeech = speechTab.textToSpeech;
} else {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
}
}
}
}

View file

@ -598,11 +598,22 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
throw new Error('OCR capability is not enabled for Agents');
} else if (shouldUseOCR) {
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
);
const { text, bytes, filepath: ocrFileURL } = await uploadOCR({ req, file, loadAuthValues });
return await createTextFile({ text, bytes, filepath: ocrFileURL });
try {
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
);
const {
text,
bytes,
filepath: ocrFileURL,
} = await uploadOCR({ req, file, loadAuthValues });
return await createTextFile({ text, bytes, filepath: ocrFileURL });
} catch (ocrError) {
logger.error(
`[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`,
ocrError,
);
}
}
const shouldUseSTT = fileConfig.checkType(

View file

@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
/**
* Get current user's Entra ID group memberships from Microsoft Graph
* Uses /me/memberOf endpoint to get groups the user is a member of
* Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
@ -167,10 +167,12 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
const getUserEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const response = await graphClient
.api('/me/getMemberGroups')
.post({ securityEnabledOnly: false });
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
return (groupsResponse.value || []).map((group) => group.id);
const groupIds = Array.isArray(response?.value) ? response.value : [];
return [...new Set(groupIds.map((groupId) => String(groupId)))];
} catch (error) {
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
return [];
@ -187,13 +189,22 @@ const getUserEntraGroups = async (accessToken, sub) => {
const getUserOwnedEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allGroupIds = [];
let nextLink = '/me/ownedObjects/microsoft.graph.group';
const groupsResponse = await graphClient
.api('/me/ownedObjects/microsoft.graph.group')
.select('id')
.get();
while (nextLink) {
const response = await graphClient.api(nextLink).select('id').top(999).get();
const groups = response?.value || [];
allGroupIds.push(...groups.map((group) => group.id));
return (groupsResponse.value || []).map((group) => group.id);
nextLink = response['@odata.nextLink']
? response['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null;
}
return allGroupIds;
} catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return [];
@ -211,21 +222,27 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => {
const getGroupMembers = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allMembers = [];
let nextLink = `/groups/${groupId}/members`;
const allMembers = new Set();
let nextLink = `/groups/${groupId}/transitiveMembers`;
while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || [];
allMembers.push(...members.map((member) => member.id));
const members = membersResponse?.value || [];
members.forEach((member) => {
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
allMembers.add(member.id);
}
});
nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
? membersResponse['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null;
}
return allMembers;
return Array.from(allMembers);
} catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error);
return [];

View file

@ -73,6 +73,7 @@ describe('GraphApiService', () => {
header: jest.fn().mockReturnThis(),
top: jest.fn().mockReturnThis(),
get: jest.fn(),
post: jest.fn(),
};
Client.init.mockReturnValue(mockGraphClient);
@ -514,31 +515,33 @@ describe('GraphApiService', () => {
});
describe('getUserEntraGroups', () => {
it('should fetch user groups from memberOf endpoint', async () => {
it('should fetch user groups using getMemberGroups endpoint', async () => {
const mockGroupsResponse = {
value: [
{
id: 'group-1',
},
{
id: 'group-2',
},
],
value: ['group-1', 'group-2'],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups');
expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false });
expect(result).toEqual(['group-1', 'group-2']);
});
it('should deduplicate returned group ids', async () => {
mockGraphClient.post.mockResolvedValue({
value: ['group-1', 'group-2', 'group-1'],
});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toHaveLength(2);
expect(result).toEqual(['group-1', 'group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
mockGraphClient.post.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@ -550,7 +553,7 @@ describe('GraphApiService', () => {
value: [],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@ -558,7 +561,7 @@ describe('GraphApiService', () => {
});
it('should handle missing value property', async () => {
mockGraphClient.get.mockResolvedValue({});
mockGraphClient.post.mockResolvedValue({});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@ -566,6 +569,89 @@ describe('GraphApiService', () => {
});
});
describe('getUserOwnedEntraGroups', () => {
it('should fetch owned groups with pagination support', async () => {
const firstPage = {
value: [
{
id: 'owned-group-1',
},
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
};
const secondPage = {
value: [
{
id: 'owned-group-2',
},
],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
1,
'/me/ownedObjects/microsoft.graph.group',
);
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(mockGraphClient.get).toHaveBeenCalledTimes(2);
expect(result).toEqual(['owned-group-1', 'owned-group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(result).toEqual([]);
});
});
describe('getGroupMembers', () => {
it('should fetch transitive members and include only users', async () => {
const firstPage = {
value: [
{ id: 'user-1', '@odata.type': '#microsoft.graph.user' },
{ id: 'child-group', '@odata.type': '#microsoft.graph.group' },
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc',
};
const secondPage = {
value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/groups/group-id/transitiveMembers?$skiptoken=abc',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(result).toEqual(['user-1', 'user-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(result).toEqual([]);
});
});
describe('testGraphApiAccess', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -39,6 +39,8 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
* @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
* @param {Record<string, string>} [params.headers] - Optional headers for the request.
* @param {Partial<IUser>} [params.userObject] - Optional user object for header resolution.
* @returns {Promise<string[]>} A promise that resolves to an array of model identifiers.
* @async
*/
@ -52,6 +54,8 @@ const fetchModels = async ({
userIdQuery = false,
createTokenConfig = true,
tokenKey,
headers,
userObject,
}) => {
let models = [];
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
@ -65,7 +69,13 @@ const fetchModels = async ({
}
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
return await OllamaClient.fetchModels(baseURL);
try {
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
} catch (ollamaError) {
const logMessage =
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
logAxiosError({ message: logMessage, error: ollamaError });
}
}
try {

View file

@ -1,5 +1,5 @@
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, resolveHeaders } = require('@librechat/api');
const { EModelEndpoint, defaultModels } = require('librechat-data-provider');
const {
@ -18,6 +18,8 @@ jest.mock('@librechat/api', () => {
processModelData: jest.fn((...args) => {
return originalUtils.processModelData(...args);
}),
logAxiosError: jest.fn(),
resolveHeaders: jest.fn((options) => options?.headers || {}),
};
});
@ -277,12 +279,51 @@ describe('fetchModels with Ollama specific logic', () => {
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: {},
timeout: 5000,
});
});
it('should handle errors gracefully when fetching Ollama models fails', async () => {
axios.get.mockRejectedValue(new Error('Network error'));
it('should pass headers and user object to Ollama fetchModels', async () => {
const customHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer custom-token',
};
const userObject = {
id: 'user789',
email: 'test@example.com',
};
resolveHeaders.mockReturnValueOnce(customHeaders);
const models = await fetchModels({
user: 'user789',
apiKey: 'testApiKey',
baseURL: 'https://api.ollama.test.com',
name: 'ollama',
headers: customHeaders,
userObject,
});
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(resolveHeaders).toHaveBeenCalledWith({
headers: customHeaders,
user: userObject,
});
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: customHeaders,
timeout: 5000,
});
});
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
axios.get.mockRejectedValueOnce(new Error('Ollama API error'));
axios.get.mockResolvedValueOnce({
data: {
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
},
});
const models = await fetchModels({
user: 'user789',
apiKey: 'testApiKey',
@ -290,8 +331,13 @@ describe('fetchModels with Ollama specific logic', () => {
name: 'OllamaAPI',
});
expect(models).toEqual([]);
expect(logger.error).toHaveBeenCalled();
expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
expect(logAxiosError).toHaveBeenCalledWith({
message:
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
error: expect.any(Error),
});
expect(axios.get).toHaveBeenCalledTimes(2);
});
it('should return an empty array if no baseURL is provided', async () => {