🛜 refactor: Streamline App Config Usage (#9234)

* WIP: app.locals refactoring

WIP: appConfig

fix: update memory configuration retrieval to use getAppConfig based on user role

fix: update comment for AppConfig interface to clarify purpose

🏷️ refactor: Update tests to use getAppConfig for endpoint configurations

ci: Update AppService tests to initialize app config instead of app.locals

ci: Integrate getAppConfig into remaining tests

refactor: Update multer storage destination to use promise-based getAppConfig and improve error handling in tests

refactor: Rename initializeAppConfig to setAppConfig and update related tests

ci: Mock getAppConfig in various tests to provide default configurations

refactor: Update convertMCPToolsToPlugins to use mcpManager for server configuration and adjust related tests

chore: rename `Config/getAppConfig` -> `Config/app`

fix: streamline OpenAI image tools configuration by removing direct appConfig dependency and using function parameters

chore: correct parameter documentation for imageOutputType in ToolService.js

refactor: remove `getCustomConfig` dependency in config route

refactor: update domain validation to use appConfig for allowed domains

refactor: use appConfig registration property

chore: remove app parameter from AppService invocation

refactor: update AppConfig interface to correct registration and turnstile configurations

refactor: remove getCustomConfig dependency and use getAppConfig in PluginController, multer, and MCP services

refactor: replace getCustomConfig with getAppConfig in STTService, TTSService, and related files

refactor: replace getCustomConfig with getAppConfig in Conversation and Message models, update tempChatRetention functions to use AppConfig type

refactor: update getAppConfig calls in Conversation and Message models to include user role for temporary chat expiration

ci: update related tests

refactor: update getAppConfig call in getCustomConfigSpeech to include user role

fix: update appConfig usage to access allowedDomains from actions instead of registration

refactor: enhance AppConfig to include fileStrategies and update related file strategy logic

refactor: update imports to use normalizeEndpointName from @librechat/api and remove redundant definitions

chore: remove deprecated unused RunManager

refactor: get balance config primarily from appConfig

refactor: remove customConfig dependency for appConfig and streamline loadConfigModels logic

refactor: remove getCustomConfig usage and use app config in file citations

refactor: consolidate endpoint loading logic into loadEndpoints function

refactor: update appConfig access to use endpoints structure across various services

refactor: implement custom endpoints configuration and streamline endpoint loading logic

refactor: update getAppConfig call to include user role parameter

refactor: streamline endpoint configuration and enhance appConfig usage across services

refactor: replace getMCPAuthMap with getUserMCPAuthMap and remove unused getCustomConfig file

refactor: add type annotation for loadedEndpoints in loadEndpoints function

refactor: move /services/Files/images/parse to TS API

chore: add missing FILE_CITATIONS permission to IRole interface

refactor: restructure toolkits to TS API

refactor: separate manifest logic into its own module

refactor: consolidate tool loading logic into a new tools module for startup logic

refactor: move interface config logic to TS API

refactor: migrate checkEmailConfig to TypeScript and update imports

refactor: add FunctionTool interface and availableTools to AppConfig

refactor: decouple caching and DB operations from AppService, make part of consolidated `getAppConfig`

WIP: fix tests

* fix: rebase conflicts

* refactor: remove app.locals references

* refactor: replace getBalanceConfig with getAppConfig in various strategies and middleware

* refactor: replace appConfig?.balance with getBalanceConfig in various controllers and clients

* test: add balance configuration to titleConvo method in AgentClient tests

* chore: remove unused `openai-chat-tokens` package

* chore: remove unused imports in initializeMCPs.js

* refactor: update balance configuration to use getAppConfig instead of getBalanceConfig

* refactor: integrate configMiddleware for centralized configuration handling

* refactor: optimize email domain validation by removing unnecessary async calls

* refactor: simplify multer storage configuration by removing async calls

* refactor: reorder imports for better readability in user.js

* refactor: replace getAppConfig calls with req.config for improved performance

* chore: replace getAppConfig calls with req.config in tests for centralized configuration handling

* chore: remove unused override config

* refactor: add configMiddleware to endpoint route and replace getAppConfig with req.config

* chore: remove customConfig parameter from TTSService constructor

* refactor: pass appConfig from request to processFileCitations for improved configuration handling

* refactor: remove configMiddleware from endpoint route and retrieve appConfig directly in getEndpointsConfig if not in `req.config`

* test: add mockAppConfig to processFileCitations tests for improved configuration handling

* fix: pass req.config to hasCustomUserVars and call without await after synchronous refactor

* fix: type safety in useExportConversation

* refactor: retrieve appConfig using getAppConfig in PluginController and remove configMiddleware from plugins route, to avoid always retrieving when plugins are cached

* chore: change `MongoUser` typedef to `IUser`

* fix: Add `user` and `config` fields to ServerRequest and update JSDoc type annotations from Express.Request to ServerRequest

* fix: remove unused setAppConfig mock from Server configuration tests
This commit is contained in:
Danny Avila 2025-08-26 12:10:18 -04:00 committed by GitHub
parent e1ad235f17
commit 9a210971f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
210 changed files with 4102 additions and 3465 deletions

View file

@ -1,9 +1,11 @@
import { primeResources } from './resources';
import { logger } from '@librechat/data-schemas';
import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint, TFile } from 'librechat-data-provider';
import type { Request as ServerRequest } from 'express';
import type { TFile } from 'librechat-data-provider';
import type { IUser } from '@librechat/data-schemas';
import type { TGetFiles } from './resources';
import type { AppConfig } from '~/types';
// Mock logger
jest.mock('@librechat/data-schemas', () => ({
@ -13,7 +15,8 @@ jest.mock('@librechat/data-schemas', () => ({
}));
describe('primeResources', () => {
let mockReq: ServerRequest;
let mockReq: ServerRequest & { user?: IUser };
let mockAppConfig: AppConfig;
let mockGetFiles: jest.MockedFunction<TGetFiles>;
let requestFileSet: Set<string>;
@ -22,15 +25,16 @@ describe('primeResources', () => {
jest.clearAllMocks();
// Setup mock request
mockReq = {
app: {
locals: {
[EModelEndpoint.agents]: {
capabilities: [AgentCapabilities.ocr],
},
},
mockReq = {} as unknown as ServerRequest & { user?: IUser };
// Setup mock appConfig
mockAppConfig = {
endpoints: {
[EModelEndpoint.agents]: {
capabilities: [AgentCapabilities.ocr],
} as TAgentsEndpoint,
},
} as unknown as ServerRequest;
} as AppConfig;
// Setup mock getFiles function
mockGetFiles = jest.fn();
@ -65,6 +69,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
@ -84,7 +89,7 @@ describe('primeResources', () => {
describe('when OCR is disabled', () => {
it('should not fetch OCR files even if tool_resources has OCR file_ids', async () => {
(mockReq.app as ServerRequest['app']).locals[EModelEndpoint.agents].capabilities = [];
(mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = [];
const tool_resources = {
[EToolResources.ocr]: {
@ -94,6 +99,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
@ -129,6 +135,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -158,6 +165,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -189,6 +197,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -220,6 +229,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -250,6 +260,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -291,6 +302,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -342,6 +354,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -399,6 +412,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -450,6 +464,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -492,6 +507,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -560,6 +576,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -618,6 +635,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -671,6 +689,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -724,6 +743,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -764,6 +784,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -838,6 +859,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -888,6 +910,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -906,6 +929,7 @@ describe('primeResources', () => {
// The function should now handle rejected attachment promises gracefully
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
@ -926,11 +950,13 @@ describe('primeResources', () => {
});
describe('edge cases', () => {
it('should handle missing app.locals gracefully', async () => {
const reqWithoutLocals = {} as ServerRequest;
it('should handle missing appConfig agents endpoint gracefully', async () => {
const reqWithoutLocals = {} as ServerRequest & { user?: IUser };
const emptyAppConfig = {} as AppConfig;
const result = await primeResources({
req: reqWithoutLocals,
appConfig: emptyAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
@ -942,14 +968,15 @@ describe('primeResources', () => {
});
expect(mockGetFiles).not.toHaveBeenCalled();
// When app.locals is missing and there's an error accessing properties,
// the function falls back to the catch block which returns an empty array
expect(result.attachments).toEqual([]);
// When appConfig agents endpoint is missing, OCR is disabled
// and no attachments are provided, the function returns undefined
expect(result.attachments).toBeUndefined();
});
it('should handle undefined tool_resources', async () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
@ -982,6 +1009,7 @@ describe('primeResources', () => {
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet: emptyRequestFileSet,
attachments,

View file

@ -4,6 +4,7 @@ import type { AgentToolResources, TFile, AgentBaseResource } from 'librechat-dat
import type { FilterQuery, QueryOptions, ProjectionType } from 'mongoose';
import type { IMongoFile, IUser } from '@librechat/data-schemas';
import type { Request as ServerRequest } from 'express';
import type { AppConfig } from '~/types/';
/**
* Function type for retrieving files from the database
@ -134,7 +135,8 @@ const categorizeFileForToolResources = ({
* 4. Prevents duplicate files across all sources
*
* @param params - Parameters object
* @param params.req - Express request object containing app configuration
* @param params.req - Express request object
* @param params.appConfig - Application configuration object
* @param params.getFiles - Function to retrieve files from database
* @param params.requestFileSet - Set of file IDs from the current request
* @param params.attachments - Promise resolving to array of attachment files
@ -143,6 +145,7 @@ const categorizeFileForToolResources = ({
*/
export const primeResources = async ({
req,
appConfig,
getFiles,
requestFileSet,
attachments: _attachments,
@ -150,6 +153,7 @@ export const primeResources = async ({
agentId,
}: {
req: ServerRequest & { user?: IUser };
appConfig: AppConfig;
requestFileSet: Set<string>;
attachments: Promise<Array<TFile | null>> | undefined;
tool_resources: AgentToolResources | undefined;
@ -198,9 +202,9 @@ export const primeResources = async ({
}
}
const isOCREnabled = (req.app.locals?.[EModelEndpoint.agents]?.capabilities ?? []).includes(
AgentCapabilities.ocr,
);
const isOCREnabled = (
appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities ?? []
).includes(AgentCapabilities.ocr);
if (tool_resources[EToolResources.ocr]?.file_ids && isOCREnabled) {
const context = await getFiles(

View file

@ -0,0 +1,43 @@
import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider';
import type { TCustomConfig, TEndpoint } from 'librechat-data-provider';
import type { AppConfig } from '~/types';
import { isEnabled, normalizeEndpointName } from '~/utils';
/**
* Retrieves the balance configuration object
* */
export function getBalanceConfig(appConfig?: AppConfig): Partial<TCustomConfig['balance']> | null {
const isLegacyEnabled = isEnabled(process.env.CHECK_BALANCE);
const startBalance = process.env.START_BALANCE;
/** @type {} */
const config: Partial<TCustomConfig['balance']> = removeNullishValues({
enabled: isLegacyEnabled,
startBalance: startBalance != null && startBalance ? parseInt(startBalance, 10) : undefined,
});
if (!appConfig) {
return config;
}
return { ...config, ...(appConfig?.['balance'] ?? {}) };
}
export const getCustomEndpointConfig = ({
endpoint,
appConfig,
}: {
endpoint: string | EModelEndpoint;
appConfig?: AppConfig;
}): Partial<TEndpoint> | undefined => {
if (!appConfig) {
throw new Error(`Config not found for the ${endpoint} custom endpoint.`);
}
const customEndpoints = appConfig.endpoints?.[EModelEndpoint.custom] ?? [];
return customEndpoints.find(
(endpointConfig) => normalizeEndpointName(endpointConfig.name) === endpoint,
);
};
export function hasCustomUserVars(appConfig?: AppConfig): boolean {
const mcpServers = appConfig?.mcpConfig;
return Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
}

View file

@ -0,0 +1,3 @@
export * from './config';
export * from './interface';
export * from './permissions';

View file

@ -0,0 +1,108 @@
import { logger } from '@librechat/data-schemas';
import { removeNullishValues } from 'librechat-data-provider';
import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider';
import type { AppConfig } from '~/types/config';
import { isMemoryEnabled } from '~/memory/config';
/**
* Loads the default interface object.
* @param params - The loaded custom configuration.
* @param params.config - The loaded custom configuration.
* @param params.configDefaults - The custom configuration default values.
* @returns default interface object.
*/
export async function loadDefaultInterface({
config,
configDefaults,
}: {
config?: Partial<TCustomConfig>;
configDefaults: TConfigDefaults;
}): Promise<AppConfig['interfaceConfig']> {
const { interface: interfaceConfig } = config ?? {};
const { interface: defaults } = configDefaults;
const hasModelSpecs = (config?.modelSpecs?.list?.length ?? 0) > 0;
const includesAddedEndpoints = (config?.modelSpecs?.addedEndpoints?.length ?? 0) > 0;
const memoryConfig = config?.memory;
const memoryEnabled = isMemoryEnabled(memoryConfig);
/** Only disable memories if memory config is present but disabled/invalid */
const shouldDisableMemories = memoryConfig && !memoryEnabled;
const loadedInterface: AppConfig['interfaceConfig'] = removeNullishValues({
// UI elements - use schema defaults
endpointsMenu:
interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu),
modelSelect:
interfaceConfig?.modelSelect ??
(hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect),
parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters),
presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets),
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers,
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
// Permissions - only include if explicitly configured
bookmarks: interfaceConfig?.bookmarks,
memories: shouldDisableMemories ? false : interfaceConfig?.memories,
prompts: interfaceConfig?.prompts,
multiConvo: interfaceConfig?.multiConvo,
agents: interfaceConfig?.agents,
temporaryChat: interfaceConfig?.temporaryChat,
runCode: interfaceConfig?.runCode,
webSearch: interfaceConfig?.webSearch,
fileSearch: interfaceConfig?.fileSearch,
fileCitations: interfaceConfig?.fileCitations,
peoplePicker: interfaceConfig?.peoplePicker,
marketplace: interfaceConfig?.marketplace,
});
let i = 0;
const logSettings = () => {
// log interface object and model specs object (without list) for reference
logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`);
logger.warn(
`\`modelSpecs\` settings:\n${JSON.stringify(
{ ...(config?.modelSpecs ?? {}), list: undefined },
null,
2,
)}`,
);
};
// warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs.
if (config?.modelSpecs?.prioritize && loadedInterface.presets) {
logger.warn(
"Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.",
);
if (i === 0) i++;
}
// warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options.
if (
config?.modelSpecs?.enforce &&
(loadedInterface.endpointsMenu ||
loadedInterface.modelSelect ||
loadedInterface.presets ||
loadedInterface.parameters)
) {
logger.warn(
"Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.",
);
if (i === 0) i++;
}
// warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior.
if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) {
logger.warn(
"Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.",
);
if (i === 0) i++;
}
if (i > 0) {
logSettings();
}
return loadedInterface;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,234 @@
import { logger } from '@librechat/data-schemas';
import {
SystemRoles,
Permissions,
roleDefaults,
PermissionTypes,
getConfigDefaults,
} from 'librechat-data-provider';
import type { IRole } from '@librechat/data-schemas';
import type { AppConfig } from '~/types/config';
import { isMemoryEnabled } from '~/memory/config';
/**
* Checks if a permission type has explicit configuration
*/
function hasExplicitConfig(
interfaceConfig: AppConfig['interfaceConfig'],
permissionType: PermissionTypes,
) {
switch (permissionType) {
case PermissionTypes.PROMPTS:
return interfaceConfig?.prompts !== undefined;
case PermissionTypes.BOOKMARKS:
return interfaceConfig?.bookmarks !== undefined;
case PermissionTypes.MEMORIES:
return interfaceConfig?.memories !== undefined;
case PermissionTypes.MULTI_CONVO:
return interfaceConfig?.multiConvo !== undefined;
case PermissionTypes.AGENTS:
return interfaceConfig?.agents !== undefined;
case PermissionTypes.TEMPORARY_CHAT:
return interfaceConfig?.temporaryChat !== undefined;
case PermissionTypes.RUN_CODE:
return interfaceConfig?.runCode !== undefined;
case PermissionTypes.WEB_SEARCH:
return interfaceConfig?.webSearch !== undefined;
case PermissionTypes.PEOPLE_PICKER:
return interfaceConfig?.peoplePicker !== undefined;
case PermissionTypes.MARKETPLACE:
return interfaceConfig?.marketplace !== undefined;
case PermissionTypes.FILE_SEARCH:
return interfaceConfig?.fileSearch !== undefined;
case PermissionTypes.FILE_CITATIONS:
return interfaceConfig?.fileCitations !== undefined;
default:
return false;
}
}
export async function updateInterfacePermissions({
appConfig,
getRoleByName,
updateAccessPermissions,
}: {
appConfig: AppConfig;
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
updateAccessPermissions: (
roleName: string,
permissionsUpdate: Partial<Record<PermissionTypes, Record<string, boolean | undefined>>>,
roleData?: IRole | null,
) => Promise<void>;
}) {
const loadedInterface = appConfig?.interfaceConfig;
if (!loadedInterface) {
return;
}
/** Configured values for interface object structure */
const interfaceConfig = appConfig?.config?.interface;
const memoryConfig = appConfig?.config?.memory;
const memoryEnabled = isMemoryEnabled(memoryConfig);
/** Check if personalization is enabled (defaults to true if memory is configured and enabled) */
const isPersonalizationEnabled =
memoryConfig && memoryEnabled && memoryConfig.personalize !== false;
/** Helper to get permission value with proper precedence */
const getPermissionValue = (
configValue?: boolean,
roleDefault?: boolean,
schemaDefault?: boolean,
) => {
if (configValue !== undefined) return configValue;
if (roleDefault !== undefined) return roleDefault;
return schemaDefault;
};
const defaults = getConfigDefaults().interface;
// Permission precedence order:
// 1. Explicit user configuration (from librechat.yaml)
// 2. Role-specific defaults (from roleDefaults)
// 3. Interface schema defaults (from interfaceSchema.default())
for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) {
const defaultPerms = roleDefaults[roleName]?.permissions;
const existingRole = await getRoleByName(roleName);
const existingPermissions = existingRole?.permissions;
const permissionsToUpdate: Partial<
Record<PermissionTypes, Record<string, boolean | undefined>>
> = {};
/**
* Helper to add permission if it should be updated
*/
const addPermissionIfNeeded = (
permType: PermissionTypes,
permissions: Record<string, boolean | undefined>,
) => {
const permTypeExists = existingPermissions?.[permType];
const isExplicitlyConfigured =
interfaceConfig && hasExplicitConfig(interfaceConfig, permType);
// Only update if: doesn't exist OR explicitly configured
if (!permTypeExists || isExplicitlyConfigured) {
permissionsToUpdate[permType] = permissions;
if (!permTypeExists) {
logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`);
} else if (isExplicitlyConfigured) {
logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`);
}
} else {
logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`);
}
};
const allPermissions: Partial<Record<PermissionTypes, Record<string, boolean | undefined>>> = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.prompts,
defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE],
defaults.prompts,
),
},
[PermissionTypes.BOOKMARKS]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.bookmarks,
defaultPerms[PermissionTypes.BOOKMARKS]?.[Permissions.USE],
defaults.bookmarks,
),
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.memories,
defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE],
defaults.memories,
),
[Permissions.OPT_OUT]: isPersonalizationEnabled,
},
[PermissionTypes.MULTI_CONVO]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.multiConvo,
defaultPerms[PermissionTypes.MULTI_CONVO]?.[Permissions.USE],
defaults.multiConvo,
),
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.agents,
defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE],
defaults.agents,
),
},
[PermissionTypes.TEMPORARY_CHAT]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.temporaryChat,
defaultPerms[PermissionTypes.TEMPORARY_CHAT]?.[Permissions.USE],
defaults.temporaryChat,
),
},
[PermissionTypes.RUN_CODE]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.runCode,
defaultPerms[PermissionTypes.RUN_CODE]?.[Permissions.USE],
defaults.runCode,
),
},
[PermissionTypes.WEB_SEARCH]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.webSearch,
defaultPerms[PermissionTypes.WEB_SEARCH]?.[Permissions.USE],
defaults.webSearch,
),
},
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: getPermissionValue(
loadedInterface.peoplePicker?.users,
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_USERS],
defaults.peoplePicker?.users,
),
[Permissions.VIEW_GROUPS]: getPermissionValue(
loadedInterface.peoplePicker?.groups,
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_GROUPS],
defaults.peoplePicker?.groups,
),
[Permissions.VIEW_ROLES]: getPermissionValue(
loadedInterface.peoplePicker?.roles,
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_ROLES],
defaults.peoplePicker?.roles,
),
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.marketplace?.use,
defaultPerms[PermissionTypes.MARKETPLACE]?.[Permissions.USE],
defaults.marketplace?.use,
),
},
[PermissionTypes.FILE_SEARCH]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.fileSearch,
defaultPerms[PermissionTypes.FILE_SEARCH]?.[Permissions.USE],
defaults.fileSearch,
),
},
[PermissionTypes.FILE_CITATIONS]: {
[Permissions.USE]: getPermissionValue(
loadedInterface.fileCitations,
defaultPerms[PermissionTypes.FILE_CITATIONS]?.[Permissions.USE],
defaults.fileCitations,
),
},
};
// Check and add each permission type if needed
for (const [permType, permissions] of Object.entries(allPermissions)) {
addPermissionIfNeeded(permType as PermissionTypes, permissions);
}
// Update permissions if any need updating
if (Object.keys(permissionsToUpdate).length > 0) {
await updateAccessPermissions(roleName, permissionsToUpdate, existingRole);
}
}
}

View file

@ -0,0 +1,56 @@
import { EModelEndpoint, extractEnvVariable } from 'librechat-data-provider';
import type { TCustomEndpoints, TEndpoint, TConfig } from 'librechat-data-provider';
import type { TCustomEndpointsConfig } from '~/types/endpoints';
import { isUserProvided, normalizeEndpointName } from '~/utils';
/**
* Load config endpoints from the cached configuration object
* @param customEndpointsConfig - The configuration object
*/
export function loadCustomEndpointsConfig(
customEndpoints?: TCustomEndpoints,
): TCustomEndpointsConfig | undefined {
if (!customEndpoints) {
return;
}
const customEndpointsConfig: TCustomEndpointsConfig = {};
if (Array.isArray(customEndpoints)) {
const filteredEndpoints = customEndpoints.filter(
(endpoint) =>
endpoint.baseURL &&
endpoint.apiKey &&
endpoint.name &&
endpoint.models &&
(endpoint.models.fetch || endpoint.models.default),
);
for (let i = 0; i < filteredEndpoints.length; i++) {
const endpoint = filteredEndpoints[i] as TEndpoint;
const {
baseURL,
apiKey,
name: configName,
iconURL,
modelDisplayLabel,
customParams,
} = endpoint;
const name = normalizeEndpointName(configName);
const resolvedApiKey = extractEnvVariable(apiKey ?? '');
const resolvedBaseURL = extractEnvVariable(baseURL ?? '');
customEndpointsConfig[name] = {
type: EModelEndpoint.custom,
userProvide: isUserProvided(resolvedApiKey),
userProvideURL: isUserProvided(resolvedBaseURL),
customParams: customParams as TConfig['customParams'],
modelDisplayLabel,
iconURL,
};
}
}
return customEndpointsConfig;
}

View file

@ -0,0 +1 @@
export * from './config';

View file

@ -1,2 +1,3 @@
export * from './custom';
export * from './google';
export * from './openai';

View file

@ -1,9 +1,9 @@
import { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } from 'librechat-data-provider';
import type {
UserKeyValues,
InitializeOpenAIOptionsParams,
OpenAIOptionsResult,
OpenAIConfigOptions,
InitializeOpenAIOptionsParams,
UserKeyValues,
} from '~/types';
import { createHandleLLMNewToken } from '~/utils/generators';
import { getAzureCredentials } from '~/utils/azure';
@ -21,6 +21,7 @@ import { getOpenAIConfig } from './llm';
*/
export const initializeOpenAI = async ({
req,
appConfig,
overrideModel,
endpointOption,
overrideEndpoint,
@ -71,7 +72,7 @@ export const initializeOpenAI = async ({
};
const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI;
const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI];
const azureConfig = isAzureOpenAI && appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
if (isAzureOpenAI && azureConfig) {
const { modelGroupMap, groupMap } = azureConfig;
@ -142,8 +143,8 @@ export const initializeOpenAI = async ({
const options = getOpenAIConfig(apiKey, finalClientOptions, endpoint);
const openAIConfig = req.app.locals[EModelEndpoint.openAI];
const allConfig = req.app.locals.all;
const openAIConfig = appConfig.endpoints?.[EModelEndpoint.openAI];
const allConfig = appConfig.endpoints?.all;
const azureRate = modelName?.includes('gpt-4') ? 30 : 17;
let streamRate: number | undefined;

View file

@ -1 +1,2 @@
export * from './mistral/crud';
export * from './parse';

View file

@ -46,7 +46,12 @@ import * as fs from 'fs';
import axios from 'axios';
import type { Request as ExpressRequest } from 'express';
import type { Readable } from 'stream';
import type { MistralFileUploadResponse, MistralSignedUrlResponse, OCRResult } from '~/types';
import type {
MistralFileUploadResponse,
MistralSignedUrlResponse,
OCRResult,
AppConfig,
} from '~/types';
import { logger as mockLogger } from '@librechat/data-schemas';
import {
uploadDocumentToMistral,
@ -497,18 +502,17 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
// Use environment variable syntax to ensure loadAuthValues is called
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-medium',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
// Use environment variable syntax to ensure loadAuthValues is called
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-medium',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -517,6 +521,7 @@ describe('MistralOCR Service', () => {
const result = await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -599,17 +604,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user456' },
app: {
locals: {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-medium',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-medium',
},
} as AppConfig;
const file = {
path: '/tmp/upload/image.png',
originalname: 'image.png',
@ -618,6 +622,7 @@ describe('MistralOCR Service', () => {
const result = await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -698,17 +703,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${CUSTOM_API_KEY}',
baseURL: '${CUSTOM_BASEURL}',
mistralModel: '${CUSTOM_MODEL}',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${CUSTOM_API_KEY}',
baseURL: '${CUSTOM_BASEURL}',
mistralModel: '${CUSTOM_MODEL}',
},
} as AppConfig;
// Set environment variable for model
process.env.CUSTOM_MODEL = 'mistral-large';
@ -720,6 +724,7 @@ describe('MistralOCR Service', () => {
const result = await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -790,18 +795,17 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
// Use environment variable syntax to ensure loadAuthValues is called
apiKey: '${INVALID_FORMAT}', // Using valid env var format but with an invalid name
baseURL: '${OCR_BASEURL}', // Using valid env var format
mistralModel: 'mistral-ocr-latest', // Plain string value
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
// Use environment variable syntax to ensure loadAuthValues is called
apiKey: '${INVALID_FORMAT}', // Using valid env var format but with an invalid name
baseURL: '${OCR_BASEURL}', // Using valid env var format
mistralModel: 'mistral-ocr-latest', // Plain string value
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -810,6 +814,7 @@ describe('MistralOCR Service', () => {
await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -845,16 +850,15 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: 'OCR_API_KEY',
baseURL: 'OCR_BASEURL',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: 'OCR_API_KEY',
baseURL: 'OCR_BASEURL',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -864,6 +868,7 @@ describe('MistralOCR Service', () => {
await expect(
uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
}),
@ -931,17 +936,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: 'OCR_API_KEY',
baseURL: 'OCR_BASEURL',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: 'OCR_API_KEY',
baseURL: 'OCR_BASEURL',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'single-page.pdf',
@ -950,6 +954,7 @@ describe('MistralOCR Service', () => {
const result = await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1019,18 +1024,17 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
// Direct values that should be used as-is, without variable substitution
apiKey: 'actual-api-key-value',
baseURL: 'https://direct-api-url.mistral.ai/v1',
mistralModel: 'mistral-direct-model',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
// Direct values that should be used as-is, without variable substitution
apiKey: 'actual-api-key-value',
baseURL: 'https://direct-api-url.mistral.ai/v1',
mistralModel: 'mistral-direct-model',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'direct-values.pdf',
@ -1039,6 +1043,7 @@ describe('MistralOCR Service', () => {
const result = await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1133,18 +1138,17 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
// Empty string values - should fall back to defaults
apiKey: '',
baseURL: '',
mistralModel: '',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
// Empty string values - should fall back to defaults
apiKey: '',
baseURL: '',
mistralModel: '',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'empty-config.pdf',
@ -1153,6 +1157,7 @@ describe('MistralOCR Service', () => {
const result = await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1276,17 +1281,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
baseURL: 'https://endpoint.models.ai.azure.com/v1',
mistralModel: 'mistral-ocr-2503',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
baseURL: 'https://endpoint.models.ai.azure.com/v1',
mistralModel: 'mistral-ocr-2503',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1295,6 +1299,7 @@ describe('MistralOCR Service', () => {
await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1360,17 +1365,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user456' },
app: {
locals: {
ocr: {
apiKey: 'hardcoded-api-key-12345',
baseURL: '${CUSTOM_OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: 'hardcoded-api-key-12345',
baseURL: '${CUSTOM_OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1379,6 +1383,7 @@ describe('MistralOCR Service', () => {
await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1484,17 +1489,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1503,6 +1507,7 @@ describe('MistralOCR Service', () => {
await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1553,17 +1558,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1573,6 +1577,7 @@ describe('MistralOCR Service', () => {
await expect(
uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
}),
@ -1641,17 +1646,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1661,6 +1665,7 @@ describe('MistralOCR Service', () => {
// Should not throw even if delete fails
const result = await uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1701,17 +1706,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1721,6 +1725,7 @@ describe('MistralOCR Service', () => {
await expect(
uploadMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
}),
@ -1775,17 +1780,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${OCR_API_KEY}',
baseURL: '${OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/azure-file.pdf',
originalname: 'azure-document.pdf',
@ -1794,6 +1798,7 @@ describe('MistralOCR Service', () => {
const result = await uploadAzureMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1851,17 +1856,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user123' },
app: {
locals: {
ocr: {
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
baseURL: 'https://endpoint.models.ai.azure.com/v1',
mistralModel: 'mistral-ocr-2503',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
baseURL: 'https://endpoint.models.ai.azure.com/v1',
mistralModel: 'mistral-ocr-2503',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1870,6 +1874,7 @@ describe('MistralOCR Service', () => {
await uploadAzureMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});
@ -1915,17 +1920,16 @@ describe('MistralOCR Service', () => {
const req = {
user: { id: 'user456' },
app: {
locals: {
ocr: {
apiKey: 'hardcoded-api-key-12345',
baseURL: '${CUSTOM_OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
},
},
} as unknown as ExpressRequest;
const appConfig = {
ocr: {
apiKey: 'hardcoded-api-key-12345',
baseURL: '${CUSTOM_OCR_BASEURL}',
mistralModel: 'mistral-ocr-latest',
},
} as AppConfig;
const file = {
path: '/tmp/upload/file.pdf',
originalname: 'document.pdf',
@ -1934,6 +1938,7 @@ describe('MistralOCR Service', () => {
await uploadAzureMistralOCR({
req,
appConfig,
file,
loadAuthValues: mockLoadAuthValues,
});

View file

@ -17,6 +17,7 @@ import type {
MistralOCRUploadResult,
MistralOCRError,
OCRResultPage,
AppConfig,
OCRResult,
OCRImage,
} from '~/types';
@ -42,14 +43,10 @@ interface GoogleServiceAccount {
/** Helper type for OCR request context */
interface OCRContext {
req: Pick<ServerRequest, 'user' | 'app'> & {
req: Pick<ServerRequest, 'user'> & {
user?: { id: string };
app: {
locals?: {
ocr?: TCustomConfig['ocr'];
};
};
};
appConfig: AppConfig;
file: Express.Multer.File;
loadAuthValues: (params: {
userId: string;
@ -241,7 +238,7 @@ async function resolveConfigValue(
* Loads authentication configuration from OCR config
*/
async function loadAuthConfig(context: OCRContext): Promise<AuthConfig> {
const ocrConfig = context.req.app.locals?.ocr;
const ocrConfig = context.appConfig?.ocr;
const apiKeyConfig = ocrConfig?.apiKey || '';
const baseURLConfig = ocrConfig?.baseURL || '';
@ -357,6 +354,7 @@ function createOCRError(error: unknown, baseMessage: string): Error {
* @param params - The params object.
* @param params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user
* @param params.appConfig - Application configuration object
* @param params.file - The file object, which is part of the request. The file object should
* have a `mimetype` property that tells us the file type
* @param params.loadAuthValues - Function to load authentication values
@ -372,7 +370,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
const authConfig = await loadAuthConfig(context);
apiKey = authConfig.apiKey;
baseURL = authConfig.baseURL;
const model = getModelConfig(context.req.app.locals?.ocr);
const model = getModelConfig(context.appConfig?.ocr);
const mistralFile = await uploadDocumentToMistral({
filePath: context.file.path,
@ -430,6 +428,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
* @param params - The params object.
* @param params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user
* @param params.appConfig - Application configuration object
* @param params.file - The file object, which is part of the request. The file object should
* have a `mimetype` property that tells us the file type
* @param params.loadAuthValues - Function to load authentication values
@ -441,7 +440,7 @@ export const uploadAzureMistralOCR = async (
): Promise<MistralOCRUploadResult> => {
try {
const { apiKey, baseURL } = await loadAuthConfig(context);
const model = getModelConfig(context.req.app.locals?.ocr);
const model = getModelConfig(context.appConfig?.ocr);
const buffer = fs.readFileSync(context.file.path);
const base64 = buffer.toString('base64');
@ -644,6 +643,7 @@ async function performGoogleVertexOCR({
* @param params - The params object.
* @param params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user
* @param params.appConfig - Application configuration object
* @param params.file - The file object, which is part of the request. The file object should
* have a `mimetype` property that tells us the file type
* @param params.loadAuthValues - Function to load authentication values
@ -655,7 +655,7 @@ export const uploadGoogleVertexMistralOCR = async (
): Promise<MistralOCRUploadResult> => {
try {
const { serviceAccount, accessToken } = await loadGoogleAuthConfig();
const model = getModelConfig(context.req.app.locals?.ocr);
const model = getModelConfig(context.appConfig?.ocr);
const buffer = fs.readFileSync(context.file.path);
const base64 = buffer.toString('base64');

View file

@ -0,0 +1,40 @@
import path from 'path';
import { URL } from 'url';
const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg|webp)$/i;
/**
* Extracts the image basename from a given URL.
*
* @param urlString - The URL string from which the image basename is to be extracted.
* @returns The basename of the image file from the URL.
* Returns an empty string if the URL does not contain a valid image basename.
*/
export function getImageBasename(urlString: string) {
try {
const url = new URL(urlString);
const basename = path.basename(url.pathname);
return imageExtensionRegex.test(basename) ? basename : '';
} catch {
// If URL parsing fails, return an empty string
return '';
}
}
/**
* Extracts the basename of a file from a given URL.
*
* @param urlString - The URL string from which the file basename is to be extracted.
* @returns The basename of the file from the URL.
* Returns an empty string if the URL parsing fails.
*/
export function getFileBasename(urlString: string) {
try {
const url = new URL(urlString);
return path.basename(url.pathname);
} catch {
// If URL parsing fails, return an empty string
return '';
}
}

View file

@ -1,3 +1,4 @@
export * from './app';
/* MCP */
export * from './mcp/MCPManager';
export * from './mcp/connection';
@ -33,3 +34,4 @@ export * from './web';
/* types */
export type * from './mcp/types';
export type * from './flow/types';
export type * from './types';

View file

@ -54,6 +54,11 @@ export class MCPManager extends UserConnectionManager {
return this.serversRegistry.oauthServers!;
}
/** Get all servers */
public getAllServers(): t.MCPServers | null {
return this.serversRegistry.rawConfigs!;
}
/** Returns all available tool functions from app-level connections */
public getAppToolFunctions(): t.LCAvailableTools | null {
return this.serversRegistry.toolFunctions!;

View file

@ -2,7 +2,7 @@ import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { logger, balanceSchema } from '@librechat/data-schemas';
import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express';
import type { IBalance, BalanceConfig } from '@librechat/data-schemas';
import type { IBalance } from '@librechat/data-schemas';
import { createSetBalanceConfig } from './balance';
jest.mock('@librechat/data-schemas', () => ({
@ -48,23 +48,22 @@ describe('createSetBalanceConfig', () => {
});
const mockNext: NextFunction = jest.fn();
const defaultBalanceConfig: BalanceConfig = {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
};
describe('Basic Functionality', () => {
test('should create balance record for new user with start balance', async () => {
const userId = new mongoose.Types.ObjectId();
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -73,7 +72,7 @@ describe('createSetBalanceConfig', () => {
await middleware(req as ServerRequest, res as ServerResponse, mockNext);
expect(getBalanceConfig).toHaveBeenCalled();
expect(getAppConfig).toHaveBeenCalled();
expect(mockNext).toHaveBeenCalled();
const balanceRecord = await Balance.findOne({ user: userId });
@ -88,10 +87,14 @@ describe('createSetBalanceConfig', () => {
test('should skip if balance config is not enabled', async () => {
const userId = new mongoose.Types.ObjectId();
const getBalanceConfig = jest.fn().mockResolvedValue({ enabled: false });
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: false,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -108,13 +111,15 @@ describe('createSetBalanceConfig', () => {
test('should skip if startBalance is null', async () => {
const userId = new mongoose.Types.ObjectId();
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: null,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: null,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -131,10 +136,19 @@ describe('createSetBalanceConfig', () => {
test('should handle user._id as string', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -151,10 +165,19 @@ describe('createSetBalanceConfig', () => {
});
test('should skip if user is not present in request', async () => {
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -164,7 +187,7 @@ describe('createSetBalanceConfig', () => {
await middleware(req, res as ServerResponse, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(getBalanceConfig).toHaveBeenCalled();
expect(getAppConfig).toHaveBeenCalled();
});
});
@ -183,17 +206,19 @@ describe('createSetBalanceConfig', () => {
// Remove lastRefill to simulate existing user without it
await Balance.updateOne({ _id: doc._id }, { $unset: { lastRefill: 1 } });
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -233,17 +258,19 @@ describe('createSetBalanceConfig', () => {
lastRefill: existingLastRefill,
});
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -275,17 +302,19 @@ describe('createSetBalanceConfig', () => {
// Remove lastRefill to simulate the edge case
await Balance.updateOne({ _id: doc._id }, { $unset: { lastRefill: 1 } });
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -306,14 +335,17 @@ describe('createSetBalanceConfig', () => {
test('should not set lastRefill when auto-refill is disabled', async () => {
const userId = new mongoose.Types.ObjectId();
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 1000,
autoRefillEnabled: false,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: false,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -347,17 +379,19 @@ describe('createSetBalanceConfig', () => {
refillAmount: 100,
});
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -389,10 +423,19 @@ describe('createSetBalanceConfig', () => {
lastRefill: lastRefillTime,
});
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -417,13 +460,16 @@ describe('createSetBalanceConfig', () => {
tokenCredits: null,
});
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 2000,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 2000,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -440,7 +486,16 @@ describe('createSetBalanceConfig', () => {
describe('Error Handling', () => {
test('should handle database errors gracefully', async () => {
const userId = new mongoose.Types.ObjectId();
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const dbError = new Error('Database error');
// Mock Balance.findOne to throw an error
@ -451,7 +506,7 @@ describe('createSetBalanceConfig', () => {
}) as unknown as mongoose.Model<IBalance>['findOne']);
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -464,13 +519,13 @@ describe('createSetBalanceConfig', () => {
expect(mockNext).toHaveBeenCalledWith(dbError);
});
test('should handle getBalanceConfig errors', async () => {
test('should handle getAppConfig errors', async () => {
const userId = new mongoose.Types.ObjectId();
const configError = new Error('Config error');
const getBalanceConfig = jest.fn().mockRejectedValue(configError);
const getAppConfig = jest.fn().mockRejectedValue(configError);
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -487,17 +542,20 @@ describe('createSetBalanceConfig', () => {
const userId = new mongoose.Types.ObjectId();
// Missing required auto-refill fields
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: null, // Invalid
refillIntervalUnit: 'days',
refillAmount: 500,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: null, // Invalid
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -519,10 +577,19 @@ describe('createSetBalanceConfig', () => {
describe('Concurrent Updates', () => {
test('should handle concurrent middleware calls for same user', async () => {
const userId = new mongoose.Types.ObjectId();
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 500,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});
@ -554,17 +621,20 @@ describe('createSetBalanceConfig', () => {
async (unit) => {
const userId = new mongoose.Types.ObjectId();
const getBalanceConfig = jest.fn().mockResolvedValue({
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 10,
refillIntervalUnit: unit,
refillAmount: 100,
const getAppConfig = jest.fn().mockResolvedValue({
balance: {
enabled: true,
startBalance: 1000,
autoRefillEnabled: true,
refillIntervalValue: 10,
refillIntervalUnit: unit,
refillAmount: 100,
},
});
const middleware = createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
});

View file

@ -2,10 +2,11 @@ import { logger } from '@librechat/data-schemas';
import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express';
import type { IBalance, IUser, BalanceConfig, ObjectId } from '@librechat/data-schemas';
import type { Model } from 'mongoose';
import type { BalanceUpdateFields } from '~/types';
import type { AppConfig, BalanceUpdateFields } from '~/types';
import { getBalanceConfig } from '~/app/config';
export interface BalanceMiddlewareOptions {
getBalanceConfig: () => Promise<BalanceConfig | null>;
getAppConfig: (options?: { role?: string; refresh?: boolean }) => Promise<AppConfig>;
Balance: Model<IBalance>;
}
@ -73,7 +74,7 @@ function buildUpdateFields(
* @returns Express middleware function
*/
export function createSetBalanceConfig({
getBalanceConfig,
getAppConfig,
Balance,
}: BalanceMiddlewareOptions): (
req: ServerRequest,
@ -82,7 +83,9 @@ export function createSetBalanceConfig({
) => Promise<void> {
return async (req: ServerRequest, res: ServerResponse, next: NextFunction): Promise<void> => {
try {
const balanceConfig = await getBalanceConfig();
const user = req.user as IUser & { _id: string | ObjectId };
const appConfig = await getAppConfig({ role: user?.role });
const balanceConfig = getBalanceConfig(appConfig);
if (!balanceConfig?.enabled) {
return next();
}
@ -90,7 +93,6 @@ export function createSetBalanceConfig({
return next();
}
const user = req.user as IUser & { _id: string | ObjectId };
if (!user || !user._id) {
return next();
}

View file

@ -1,5 +1,6 @@
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
import type { TPlugin, FunctionTool, TCustomConfig } from 'librechat-data-provider';
import type { TPlugin, FunctionTool } from 'librechat-data-provider';
import type { MCPManager } from '~/mcp/MCPManager';
import {
convertMCPToolsToPlugins,
filterUniquePlugins,
@ -277,19 +278,18 @@ describe('format.ts helper functions', () => {
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
iconPath: '/path/to/icon.png',
},
},
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
command: 'test',
args: [],
iconPath: '/path/to/icon.png',
}),
} as unknown as MCPManager;
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
expect(result).toHaveLength(1);
expect(result![0].icon).toBe('/path/to/icon.png');
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
});
it('should handle customUserVars in server config', () => {
@ -300,26 +300,25 @@ describe('format.ts helper functions', () => {
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
SECRET: { title: 'Secret', description: 'Your secret' },
},
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
command: 'test',
args: [],
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
SECRET: { title: 'Secret', description: 'Your secret' },
},
},
};
}),
} as unknown as MCPManager;
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
expect(result).toHaveLength(1);
expect(result![0].authConfig).toHaveLength(2);
expect(result![0].authConfig).toEqual([
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
{ authField: 'SECRET', label: 'Secret', description: 'Your secret' },
]);
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
});
it('should use key as label when title is missing in customUserVars', () => {
@ -330,23 +329,22 @@ describe('format.ts helper functions', () => {
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
command: 'test',
args: [],
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
},
};
}),
} as unknown as MCPManager;
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
expect(result).toHaveLength(1);
expect(result![0].authConfig).toEqual([
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
]);
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
});
it('should handle empty customUserVars', () => {
@ -357,19 +355,51 @@ describe('format.ts helper functions', () => {
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
customUserVars: {},
},
},
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
command: 'test',
args: [],
customUserVars: {},
}),
} as unknown as MCPManager;
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
expect(result).toHaveLength(1);
expect(result![0].authConfig).toEqual([]);
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
});
it('should handle missing mcpManager', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1', description: 'Tool 1' },
} as FunctionTool,
};
const result = convertMCPToolsToPlugins({ functionTools });
expect(result).toHaveLength(1);
expect(result![0].icon).toBeUndefined();
expect(result![0].authConfig).toEqual([]);
});
it('should handle when getRawConfig returns undefined', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1', description: 'Tool 1' },
} as FunctionTool,
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue(undefined),
} as unknown as MCPManager;
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
expect(result).toHaveLength(1);
expect(result![0].icon).toBeUndefined();
expect(result![0].authConfig).toEqual([]);
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
});
});

View file

@ -1,5 +1,6 @@
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
import type { TCustomConfig, TPlugin } from 'librechat-data-provider';
import type { TPlugin } from 'librechat-data-provider';
import type { MCPManager } from '~/mcp/MCPManager';
import { LCAvailableTools, LCFunctionTool } from '~/mcp/types';
/**
@ -58,11 +59,11 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => {
export function convertMCPToolToPlugin({
toolKey,
toolData,
customConfig,
mcpManager,
}: {
toolKey: string;
toolData: LCFunctionTool;
customConfig?: Partial<TCustomConfig> | null;
mcpManager?: MCPManager;
}): TPlugin | undefined {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
return;
@ -72,7 +73,7 @@ export function convertMCPToolToPlugin({
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const serverConfig = mcpManager?.getRawConfig(serverName);
const plugin: TPlugin = {
/** Tool name without server suffix */
@ -111,10 +112,10 @@ export function convertMCPToolToPlugin({
*/
export function convertMCPToolsToPlugins({
functionTools,
customConfig,
mcpManager,
}: {
functionTools?: LCAvailableTools;
customConfig?: Partial<TCustomConfig> | null;
mcpManager?: MCPManager;
}): TPlugin[] | undefined {
if (!functionTools || typeof functionTools !== 'object') {
return;
@ -122,7 +123,7 @@ export function convertMCPToolsToPlugins({
const plugins: TPlugin[] = [];
for (const [toolKey, toolData] of Object.entries(functionTools)) {
const plugin = convertMCPToolToPlugin({ toolKey, toolData, customConfig });
const plugin = convertMCPToolToPlugin({ toolKey, toolData, mcpManager });
if (plugin) {
plugins.push(plugin);
}

View file

@ -1 +1,2 @@
export * from './format';
export * from './toolkits';

View file

@ -0,0 +1,2 @@
export * from './oai';
export * from './yt';

View file

@ -0,0 +1,153 @@
import { z } from 'zod';
/** Default descriptions for image generation tool */
const DEFAULT_IMAGE_GEN_DESCRIPTION =
`Generates high-quality, original images based solely on text, not using any uploaded reference images.
When to use \`image_gen_oai\`:
- To create entirely new images from detailed text descriptions that do NOT reference any image files.
When NOT to use \`image_gen_oai\`:
- If the user has uploaded any images and requests modifications, enhancements, or remixing based on those uploads use \`image_edit_oai\` instead.
Generated image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.` as const;
const getImageGenDescription = () => {
return process.env.IMAGE_GEN_OAI_DESCRIPTION || DEFAULT_IMAGE_GEN_DESCRIPTION;
};
/** Default prompt descriptions */
const DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION = `Describe the image you want in detail.
Be highly specificbreak your idea into layers:
(1) main concept and subject,
(2) composition and position,
(3) lighting and mood,
(4) style, medium, or camera details,
(5) important features (age, expression, clothing, etc.),
(6) background.
Use positive, descriptive language and specify what should be included, not what to avoid.
List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour").
Do not reference any uploaded imagesuse for new image creation from text only.` as const;
const getImageGenPromptDescription = () => {
return process.env.IMAGE_GEN_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION;
};
/** Default description for image editing tool */
const DEFAULT_IMAGE_EDIT_DESCRIPTION =
`Generates high-quality, original images based on text and one or more uploaded/referenced images.
When to use \`image_edit_oai\`:
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
- Any current or existing images are to be used as visual guides.
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.
When NOT to use \`image_edit_oai\`:
- Brand-new generations that do not rely on an existing image use \`image_gen_oai\` instead.
Both generated and referenced image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.
`.trim();
const getImageEditDescription = () => {
return process.env.IMAGE_EDIT_OAI_DESCRIPTION || DEFAULT_IMAGE_EDIT_DESCRIPTION;
};
const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s).
Be highly specificbreak your request into layers:
(1) main concept or transformation,
(2) specific edits/replacements or composition guidance,
(3) desired style, mood, or technique,
(4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.).
Use positive, descriptive language and clarify what should be included or changed, not what to avoid.
Always base this prompt on the most recently uploaded reference images.`;
const getImageEditPromptDescription = () => {
return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION;
};
export const oaiToolkit = {
image_gen_oai: {
name: 'image_gen_oai' as const,
description: getImageGenDescription(),
schema: z.object({
prompt: z.string().max(32000).describe(getImageGenPromptDescription()),
background: z
.enum(['transparent', 'opaque', 'auto'])
.optional()
.describe(
'Sets transparency for the background. Must be one of transparent, opaque or auto (default). When transparent, the output format should be png or webp.',
),
/*
n: z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe('The number of images to generate. Must be between 1 and 10.'),
output_compression: z
.number()
.int()
.min(0)
.max(100)
.optional()
.describe('The compression level (0-100%) for webp or jpeg formats. Defaults to 100.'),
*/
quality: z
.enum(['auto', 'high', 'medium', 'low'])
.optional()
.describe('The quality of the image. One of auto (default), high, medium, or low.'),
size: z
.enum(['auto', '1024x1024', '1536x1024', '1024x1536'])
.optional()
.describe(
'The size of the generated image. One of 1024x1024, 1536x1024 (landscape), 1024x1536 (portrait), or auto (default).',
),
}),
responseFormat: 'content_and_artifact' as const,
} as const,
image_edit_oai: {
name: 'image_edit_oai' as const,
description: getImageEditDescription(),
schema: z.object({
image_ids: z
.array(z.string())
.min(1)
.describe(
`
IDs (image ID strings) of previously generated or uploaded images that should guide the edit.
Guidelines:
- If the user's request depends on any prior image(s), copy their image IDs into the \`image_ids\` array (in the same order the user refers to them).
- Never invent or hallucinate IDs; only use IDs that are still visible in the conversation context.
- If no earlier image is relevant, omit the field entirely.
`.trim(),
),
prompt: z.string().max(32000).describe(getImageEditPromptDescription()),
/*
n: z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe('The number of images to generate. Must be between 1 and 10. Defaults to 1.'),
*/
quality: z
.enum(['auto', 'high', 'medium', 'low'])
.optional()
.describe(
'The quality of the image. One of auto (default), high, medium, or low. High/medium/low only supported for gpt-image-1.',
),
size: z
.enum(['auto', '1024x1024', '1536x1024', '1024x1536', '256x256', '512x512'])
.optional()
.describe(
'The size of the generated images. For gpt-image-1: auto (default), 1024x1024, 1536x1024, 1024x1536. For dall-e-2: 256x256, 512x512, 1024x1024.',
),
}),
responseFormat: 'content_and_artifact' as const,
},
} as const;

View file

@ -0,0 +1,61 @@
import { z } from 'zod';
export const ytToolkit = {
youtube_search: {
name: 'youtube_search' as const,
description: `Search for YouTube videos by keyword or phrase.
- Required: query (search terms to find videos)
- Optional: maxResults (number of videos to return, 1-50, default: 5)
- Returns: List of videos with titles, descriptions, and URLs
- Use for: Finding specific videos, exploring content, research
Example: query="cooking pasta tutorials" maxResults=3` as const,
schema: z.object({
query: z.string().describe('Search query terms'),
maxResults: z.number().int().min(1).max(50).optional().describe('Number of results (1-50)'),
}),
},
youtube_info: {
name: 'youtube_info' as const,
description: `Get detailed metadata and statistics for a specific YouTube video.
- Required: url (full YouTube URL or video ID)
- Returns: Video title, description, view count, like count, comment count
- Use for: Getting video metrics and basic metadata
- DO NOT USE FOR VIDEO SUMMARIES, USE TRANSCRIPTS FOR COMPREHENSIVE ANALYSIS
- Accepts both full URLs and video IDs
Example: url="https://youtube.com/watch?v=abc123" or url="abc123"` as const,
schema: z.object({
url: z.string().describe('YouTube video URL or ID'),
}),
} as const,
youtube_comments: {
name: 'youtube_comments',
description: `Retrieve top-level comments from a YouTube video.
- Required: url (full YouTube URL or video ID)
- Optional: maxResults (number of comments, 1-50, default: 10)
- Returns: Comment text, author names, like counts
- Use for: Sentiment analysis, audience feedback, engagement review
Example: url="abc123" maxResults=20`,
schema: z.object({
url: z.string().describe('YouTube video URL or ID'),
maxResults: z
.number()
.int()
.min(1)
.max(50)
.optional()
.describe('Number of comments to retrieve'),
}),
} as const,
youtube_transcript: {
name: 'youtube_transcript',
description: `Fetch and parse the transcript/captions of a YouTube video.
- Required: url (full YouTube URL or video ID)
- Returns: Full video transcript as plain text
- Use for: Content analysis, summarization, translation reference
- This is the "Go-to" tool for analyzing actual video content
- Attempts to fetch English first, then German, then any available language
Example: url="https://youtube.com/watch?v=abc123"`,
schema: z.object({
url: z.string().describe('YouTube video URL or ID'),
}),
} as const,
} as const;

View file

@ -0,0 +1,90 @@
import type {
TEndpoint,
FileSources,
TAzureConfig,
TCustomConfig,
TMemoryConfig,
EModelEndpoint,
TAgentsEndpoint,
TCustomEndpoints,
TAssistantEndpoint,
} from 'librechat-data-provider';
import type { FunctionTool } from './tools';
/**
* Application configuration object
* Based on the configuration defined in api/server/services/Config/getAppConfig.js
*/
export interface AppConfig {
/** The main custom configuration */
config: TCustomConfig;
/** OCR configuration */
ocr?: TCustomConfig['ocr'];
/** File paths configuration */
paths: {
uploads: string;
imageOutput: string;
publicPath: string;
[key: string]: string;
};
/** Memory configuration */
memory?: TMemoryConfig;
/** Web search configuration */
webSearch?: TCustomConfig['webSearch'];
/** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */
fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob;
/** File strategies configuration */
fileStrategies: TCustomConfig['fileStrategies'];
/** Registration configurations */
registration?: TCustomConfig['registration'];
/** Actions configurations */
actions?: TCustomConfig['actions'];
/** Admin-filtered tools */
filteredTools?: string[];
/** Admin-included tools */
includedTools?: string[];
/** Image output type configuration */
imageOutputType: string;
/** Interface configuration */
interfaceConfig?: TCustomConfig['interface'];
/** Turnstile configuration */
turnstileConfig?: TCustomConfig['turnstile'];
/** Balance configuration */
balance?: TCustomConfig['balance'];
/** Speech configuration */
speech?: TCustomConfig['speech'];
/** MCP server configuration */
mcpConfig?: TCustomConfig['mcpServers'] | null;
/** File configuration */
fileConfig?: TCustomConfig['fileConfig'];
/** Secure image links configuration */
secureImageLinks?: TCustomConfig['secureImageLinks'];
/** Processed model specifications */
modelSpecs?: TCustomConfig['modelSpecs'];
/** Available tools */
availableTools?: Record<string, FunctionTool>;
endpoints?: {
/** OpenAI endpoint configuration */
openAI?: TEndpoint;
/** Google endpoint configuration */
google?: TEndpoint;
/** Bedrock endpoint configuration */
bedrock?: TEndpoint;
/** Anthropic endpoint configuration */
anthropic?: TEndpoint;
/** GPT plugins endpoint configuration */
gptPlugins?: TEndpoint;
/** Azure OpenAI endpoint configuration */
azureOpenAI?: TAzureConfig;
/** Assistants endpoint configuration */
assistants?: TAssistantEndpoint;
/** Azure assistants endpoint configuration */
azureAssistants?: TAssistantEndpoint;
/** Agents endpoint configuration */
[EModelEndpoint.agents]?: TAgentsEndpoint;
/** Custom endpoints configuration */
[EModelEndpoint.custom]?: TCustomEndpoints;
/** Global endpoint configuration */
all?: TEndpoint;
};
}

View file

@ -0,0 +1,3 @@
import type { TConfig } from 'librechat-data-provider';
export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit<TConfig, 'order'> }>;

View file

@ -1,5 +1,7 @@
export * from './config';
export * from './azure';
export * from './balance';
export * from './endpoints';
export * from './events';
export * from './error';
export * from './google';
@ -8,4 +10,5 @@ export * from './mistral';
export * from './openai';
export * from './prompts';
export * from './run';
export * from './tools';
export * from './zod';

View file

@ -4,6 +4,7 @@ import type { TEndpointOption, TAzureConfig, TEndpoint } from 'librechat-data-pr
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
import type { OpenAIClientOptions, Providers } from '@librechat/agents';
import type { AzureOptions } from './azure';
import type { AppConfig } from './config';
export type OpenAIParameters = z.infer<typeof openAISchema>;
@ -86,6 +87,7 @@ export type CheckUserKeyExpiryFunction = (expiresAt: string, endpoint: string) =
*/
export interface InitializeOpenAIOptionsParams {
req: RequestData;
appConfig: AppConfig;
overrideModel?: string;
overrideEndpoint?: string;
endpointOption: Partial<TEndpointOption>;

View file

@ -0,0 +1,10 @@
import type { JsonSchemaType } from './zod';
export interface FunctionTool {
type: 'function';
function: {
description: string;
name: string;
parameters: JsonSchemaType;
};
}

View file

@ -1,3 +1,6 @@
import { Providers } from '@librechat/agents';
import { AuthType } from 'librechat-data-provider';
/**
* Checks if the given value is truthy by being either the boolean `true` or a string
* that case-insensitively matches 'true'.
@ -31,7 +34,7 @@ export function isEnabled(value?: string | boolean | null | undefined): boolean
* @param value - The value to check.
* @returns - Returns true if the value is 'user_provided', otherwise false.
*/
export const isUserProvided = (value?: string): boolean => value === 'user_provided';
export const isUserProvided = (value?: string): boolean => value === AuthType.USER_PROVIDED;
/**
* @param values
@ -46,3 +49,11 @@ export function optionalChainWithEmptyCheck(
}
return values[values.length - 1];
}
/**
* Normalize the endpoint name to system-expected value.
* @param name
*/
export function normalizeEndpointName(name = ''): string {
return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name;
}

View file

@ -0,0 +1,16 @@
/**
* Check if email configuration is set
* @returns Returns `true` if either Mailgun or SMTP is properly configured
*/
export function checkEmailConfig(): boolean {
const hasMailgunConfig =
!!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM;
const hasSMTPConfig =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM;
return hasMailgunConfig || hasSMTPConfig;
}

View file

@ -1,6 +1,7 @@
export * from './axios';
export * from './azure';
export * from './common';
export * from './email';
export * from './env';
export * from './events';
export * from './files';

View file

@ -1,11 +1,11 @@
import type { AppConfig } from '~/types';
import {
createTempChatExpirationDate,
getTempChatRetentionHours,
DEFAULT_RETENTION_HOURS,
MIN_RETENTION_HOURS,
MAX_RETENTION_HOURS,
DEFAULT_RETENTION_HOURS,
getTempChatRetentionHours,
createTempChatExpirationDate,
} from './tempChatRetention';
import type { TCustomConfig } from 'librechat-data-provider';
describe('tempChatRetention', () => {
const originalEnv = process.env;
@ -33,43 +33,43 @@ describe('tempChatRetention', () => {
});
it('should use config value when set', () => {
const config: Partial<TCustomConfig> = {
interface: {
const config: Partial<AppConfig> = {
interfaceConfig: {
temporaryChatRetention: 12,
},
};
const result = getTempChatRetentionHours(config);
const result = getTempChatRetentionHours(config?.interfaceConfig);
expect(result).toBe(12);
});
it('should prioritize config over environment variable', () => {
process.env.TEMP_CHAT_RETENTION_HOURS = '48';
const config: Partial<TCustomConfig> = {
interface: {
const config: Partial<AppConfig> = {
interfaceConfig: {
temporaryChatRetention: 12,
},
};
const result = getTempChatRetentionHours(config);
const result = getTempChatRetentionHours(config?.interfaceConfig);
expect(result).toBe(12);
});
it('should enforce minimum retention period', () => {
const config: Partial<TCustomConfig> = {
interface: {
const config: Partial<AppConfig> = {
interfaceConfig: {
temporaryChatRetention: 0,
},
};
const result = getTempChatRetentionHours(config);
const result = getTempChatRetentionHours(config?.interfaceConfig);
expect(result).toBe(MIN_RETENTION_HOURS);
});
it('should enforce maximum retention period', () => {
const config: Partial<TCustomConfig> = {
interface: {
const config: Partial<AppConfig> = {
interfaceConfig: {
temporaryChatRetention: 10000,
},
};
const result = getTempChatRetentionHours(config);
const result = getTempChatRetentionHours(config?.interfaceConfig);
expect(result).toBe(MAX_RETENTION_HOURS);
});
@ -80,12 +80,12 @@ describe('tempChatRetention', () => {
});
it('should handle invalid config value', () => {
const config: Partial<TCustomConfig> = {
interface: {
const config: Partial<AppConfig> = {
interfaceConfig: {
temporaryChatRetention: 'invalid' as unknown as number,
},
};
const result = getTempChatRetentionHours(config);
const result = getTempChatRetentionHours(config?.interfaceConfig);
expect(result).toBe(DEFAULT_RETENTION_HOURS);
});
});
@ -103,13 +103,13 @@ describe('tempChatRetention', () => {
});
it('should create expiration date with custom retention period', () => {
const config: Partial<TCustomConfig> = {
interface: {
const config: Partial<AppConfig> = {
interfaceConfig: {
temporaryChatRetention: 12,
},
};
const result = createTempChatExpirationDate(config);
const result = createTempChatExpirationDate(config?.interfaceConfig);
const expectedDate = new Date();
expectedDate.setHours(expectedDate.getHours() + 12);

View file

@ -1,5 +1,5 @@
import { logger } from '@librechat/data-schemas';
import type { TCustomConfig } from 'librechat-data-provider';
import type { AppConfig } from '~/types';
/**
* Default retention period for temporary chats in hours
@ -18,10 +18,12 @@ export const MAX_RETENTION_HOURS = 8760;
/**
* Gets the temporary chat retention period from environment variables or config
* @param config - The custom configuration object
* @param interfaceConfig - The custom configuration object
* @returns The retention period in hours
*/
export function getTempChatRetentionHours(config?: Partial<TCustomConfig> | null): number {
export function getTempChatRetentionHours(
interfaceConfig?: AppConfig['interfaceConfig'] | null,
): number {
let retentionHours = DEFAULT_RETENTION_HOURS;
// Check environment variable first
@ -37,8 +39,8 @@ export function getTempChatRetentionHours(config?: Partial<TCustomConfig> | null
}
// Check config file (takes precedence over environment variable)
if (config?.interface?.temporaryChatRetention !== undefined) {
const configValue = config.interface.temporaryChatRetention;
if (interfaceConfig?.temporaryChatRetention !== undefined) {
const configValue = interfaceConfig.temporaryChatRetention;
if (typeof configValue === 'number' && !isNaN(configValue)) {
retentionHours = configValue;
} else {
@ -66,11 +68,11 @@ export function getTempChatRetentionHours(config?: Partial<TCustomConfig> | null
/**
* Creates an expiration date for temporary chats
* @param config - The custom configuration object
* @param interfaceConfig - The custom configuration object
* @returns The expiration date
*/
export function createTempChatExpirationDate(config?: Partial<TCustomConfig>): Date {
const retentionHours = getTempChatRetentionHours(config);
export function createTempChatExpirationDate(interfaceConfig?: AppConfig['interfaceConfig']): Date {
const retentionHours = getTempChatRetentionHours(interfaceConfig);
const expiredAt = new Date();
expiredAt.setHours(expiredAt.getHours() + retentionHours);
return expiredAt;