From 838fb53208057adf4660b1e575df0a41a85d7795 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Oct 2025 06:37:57 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=83=20refactor:=20Decouple=20Effects?= =?UTF-8?q?=20from=20AppService,=20move=20to=20`data-schemas`=20(#9974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: linting for `loadCustomConfig` * refactor: decouple CDN init and variable/health checks from AppService * refactor: move AppService to packages/data-schemas * chore: update AppConfig import path to use data-schemas * chore: update JsonSchemaType import path to use data-schemas * refactor: update UserController to import webSearchKeys and redefine FunctionTool typedef * chore: remove AppService.js * refactor: update AppConfig interface to use Partial and make paths and fileStrategies optional * refactor: update checkConfig function to accept Partial * chore: fix types * refactor: move handleRateLimits to startup checks as is an effect * test: remove outdated rate limit tests from AppService.spec and add new handleRateLimits tests in checks.spec --- api/server/controllers/UserController.js | 3 +- api/server/index.js | 11 +- .../services/AppService.interface.spec.js | 198 ------ api/server/services/Config/app.js | 20 +- .../services/Config/handleRateLimits.js | 48 -- .../services/Config/loadCustomConfig.js | 3 - api/server/services/Files/Azure/crud.js | 10 +- api/server/services/Files/Azure/index.js | 2 - api/server/services/Files/Firebase/crud.js | 2 +- api/server/services/Files/Firebase/index.js | 2 - .../services/Files/Firebase/initialize.js | 39 -- api/server/services/Files/S3/crud.js | 4 +- api/server/services/Files/S3/index.js | 2 - api/server/services/start/modelSpecs.js | 75 -- api/server/services/start/turnstile.js | 44 -- api/typedefs.js | 11 +- package-lock.json | 5 + packages/api/package.json | 5 + packages/api/src/agents/index.ts | 1 - packages/api/src/agents/resources.test.ts | 3 +- packages/api/src/agents/resources.ts | 3 +- .../api/src/app/AppService.interface.spec.ts | 157 +++++ .../api/src/app/AppService.spec.ts | 638 ++++++------------ packages/api/src/app/cdn.ts | 26 + .../api/src/app/checks.spec.ts | 164 ++++- .../api/src/app/checks.ts | 202 ++++-- packages/api/src/app/config.test.ts | 2 +- packages/api/src/app/config.ts | 8 +- packages/api/src/app/index.ts | 3 +- packages/api/src/app/limits.ts | 55 ++ packages/api/src/app/permissions.spec.ts | 4 +- packages/api/src/app/permissions.ts | 3 +- .../api/src/cdn/azure.ts | 29 +- packages/api/src/cdn/firebase.ts | 42 ++ packages/api/src/cdn/index.ts | 3 + .../api/src/cdn/s3.ts | 12 +- packages/api/src/index.ts | 1 + packages/api/src/mcp/MCPServersRegistry.ts | 2 +- packages/api/src/mcp/__tests__/zod.spec.ts | 2 +- packages/api/src/mcp/types/index.ts | 3 +- packages/api/src/mcp/zod.ts | 2 +- packages/api/src/middleware/balance.ts | 4 +- packages/api/src/types/http.ts | 3 +- packages/api/src/types/index.ts | 3 - packages/api/src/types/openai.ts | 2 +- packages/api/src/types/tools.ts | 10 - packages/api/src/types/zod.ts | 15 - .../api/src/utils/tempChatRetention.spec.ts | 2 +- packages/api/src/utils/tempChatRetention.ts | 2 +- packages/api/src/web/web.spec.ts | 5 +- packages/api/src/web/web.ts | 110 +-- packages/data-provider/src/azure.ts | 62 +- packages/data-provider/src/config.ts | 30 +- .../src/app/agents.ts} | 4 +- .../data-schemas/src/app/assistants.ts | 37 +- .../data-schemas/src/app/azure.ts | 36 +- .../data-schemas/src/app/endpoints.ts | 33 +- packages/data-schemas/src/app/index.ts | 6 + .../src/app/interface.ts | 51 +- packages/data-schemas/src/app/memory.ts | 28 + packages/data-schemas/src/app/ocr.ts | 15 + .../data-schemas/src/app/service.ts | 92 ++- packages/data-schemas/src/app/specs.ts | 94 +++ packages/data-schemas/src/app/turnstile.ts | 45 ++ packages/data-schemas/src/app/web.ts | 84 +++ packages/data-schemas/src/index.ts | 1 + .../data-schemas/src/models/pluginAuth.ts | 3 +- packages/data-schemas/src/models/prompt.ts | 3 +- .../data-schemas/src/models/promptGroup.ts | 3 +- .../src/types/app.ts} | 54 +- packages/data-schemas/src/types/index.ts | 3 + packages/data-schemas/src/types/role.ts | 4 +- packages/data-schemas/src/types/web.ts | 16 + 73 files changed, 1383 insertions(+), 1326 deletions(-) delete mode 100644 api/server/services/AppService.interface.spec.js delete mode 100644 api/server/services/Config/handleRateLimits.js delete mode 100644 api/server/services/Files/Firebase/initialize.js delete mode 100644 api/server/services/start/modelSpecs.js delete mode 100644 api/server/services/start/turnstile.js create mode 100644 packages/api/src/app/AppService.interface.spec.ts rename api/server/services/AppService.spec.js => packages/api/src/app/AppService.spec.ts (58%) create mode 100644 packages/api/src/app/cdn.ts rename api/server/services/start/checks.spec.js => packages/api/src/app/checks.spec.ts (52%) rename api/server/services/start/checks.js => packages/api/src/app/checks.ts (53%) create mode 100644 packages/api/src/app/limits.ts rename api/server/services/Files/Azure/initialize.js => packages/api/src/cdn/azure.ts (63%) create mode 100644 packages/api/src/cdn/firebase.ts create mode 100644 packages/api/src/cdn/index.ts rename api/server/services/Files/S3/initialize.js => packages/api/src/cdn/s3.ts (82%) delete mode 100644 packages/api/src/types/tools.ts delete mode 100644 packages/api/src/types/zod.ts rename packages/{api/src/agents/config.ts => data-schemas/src/app/agents.ts} (91%) rename api/server/services/start/assistants.js => packages/data-schemas/src/app/assistants.ts (64%) rename api/server/services/start/azureOpenAI.js => packages/data-schemas/src/app/azure.ts (65%) rename api/server/services/start/endpoints.js => packages/data-schemas/src/app/endpoints.ts (63%) create mode 100644 packages/data-schemas/src/app/index.ts rename packages/{api => data-schemas}/src/app/interface.ts (55%) create mode 100644 packages/data-schemas/src/app/memory.ts create mode 100644 packages/data-schemas/src/app/ocr.ts rename api/server/services/AppService.js => packages/data-schemas/src/app/service.ts (52%) create mode 100644 packages/data-schemas/src/app/specs.ts create mode 100644 packages/data-schemas/src/app/turnstile.ts create mode 100644 packages/data-schemas/src/app/web.ts rename packages/{api/src/types/config.ts => data-schemas/src/types/app.ts} (69%) create mode 100644 packages/data-schemas/src/types/web.ts diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index c7051f4608..dc38c59721 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -1,7 +1,6 @@ -const { logger } = require('@librechat/data-schemas'); +const { logger, webSearchKeys } = require('@librechat/data-schemas'); const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider'); const { - webSearchKeys, MCPOAuthHandler, MCPTokenStorage, normalizeHttpError, diff --git a/api/server/index.js b/api/server/index.js index e458b0349e..c084267ad1 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -10,7 +10,12 @@ const compression = require('compression'); const cookieParser = require('cookie-parser'); const { logger } = require('@librechat/data-schemas'); const mongoSanitize = require('express-mongo-sanitize'); -const { isEnabled, ErrorController } = require('@librechat/api'); +const { + isEnabled, + ErrorController, + performStartupChecks, + initializeFileStorage, +} = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); const createValidateImageRequest = require('./middleware/validateImageRequest'); @@ -49,9 +54,11 @@ const startServer = async () => { app.set('trust proxy', trusted_proxy); await seedDatabase(); - const appConfig = await getAppConfig(); + initializeFileStorage(appConfig); + await performStartupChecks(appConfig); await updateInterfacePermissions(appConfig); + const indexPath = path.join(appConfig.paths.dist, 'index.html'); let indexHTML = fs.readFileSync(indexPath, 'utf8'); diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js deleted file mode 100644 index f4b1c67c38..0000000000 --- a/api/server/services/AppService.interface.spec.js +++ /dev/null @@ -1,198 +0,0 @@ -jest.mock('@librechat/data-schemas', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), - loadDefaultInterface: jest.fn(), -})); -jest.mock('./start/tools', () => ({ - loadAndFormatTools: jest.fn().mockReturnValue({}), -})); -jest.mock('./start/checks', () => ({ - checkVariables: jest.fn(), - checkHealth: jest.fn(), - checkConfig: jest.fn(), - checkAzureVariables: jest.fn(), - checkWebSearchConfig: jest.fn(), -})); - -jest.mock('./Config/loadCustomConfig', () => jest.fn()); - -const AppService = require('./AppService'); -const { loadDefaultInterface } = require('@librechat/api'); - -describe('AppService interface configuration', () => { - let mockLoadCustomConfig; - - beforeEach(() => { - jest.resetModules(); - jest.clearAllMocks(); - mockLoadCustomConfig = require('./Config/loadCustomConfig'); - }); - - it('should set prompts and bookmarks to true when loadDefaultInterface returns true for both', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: true, - bookmarks: true, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should set prompts and bookmarks to false when loadDefaultInterface returns false for both', async () => { - mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } }); - loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: false, - bookmarks: false, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should not set prompts and bookmarks when loadDefaultInterface returns undefined for both', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({}); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.anything(), - }), - ); - - // Verify that prompts and bookmarks are undefined when not provided - expect(result.interfaceConfig.prompts).toBeUndefined(); - expect(result.interfaceConfig.bookmarks).toBeUndefined(); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should set prompts and bookmarks to different values when loadDefaultInterface returns different values', async () => { - mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } }); - loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: true, - bookmarks: false, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should correctly configure peoplePicker permissions including roles', async () => { - mockLoadCustomConfig.mockResolvedValue({ - interface: { - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }, - }); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: true, - roles: true, - }), - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should handle mixed peoplePicker permissions', async () => { - mockLoadCustomConfig.mockResolvedValue({ - interface: { - peoplePicker: { - users: true, - groups: false, - roles: true, - }, - }, - }); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: false, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: false, - roles: true, - }), - }), - }), - ); - }); - - it('should set default peoplePicker permissions when not provided', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: true, - roles: true, - }), - }), - }), - ); - }); -}); diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index ec6af77432..75a5cbe56d 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -1,11 +1,25 @@ -const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); -const AppService = require('~/server/services/AppService'); +const { logger, AppService } = require('@librechat/data-schemas'); +const { loadAndFormatTools } = require('~/server/services/start/tools'); +const loadCustomConfig = require('./loadCustomConfig'); const { setCachedTools } = require('./getCachedTools'); const getLogStores = require('~/cache/getLogStores'); +const paths = require('~/config/paths'); const BASE_CONFIG_KEY = '_BASE_'; +const loadBaseConfig = async () => { + /** @type {TCustomConfig} */ + const config = (await loadCustomConfig()) ?? {}; + /** @type {Record} */ + const systemTools = loadAndFormatTools({ + adminFilter: config.filteredTools, + adminIncluded: config.includedTools, + directory: paths.structuredTools, + }); + return AppService({ config, paths, systemTools }); +}; + /** * Get the app configuration based on user context * @param {Object} [options] @@ -29,7 +43,7 @@ async function getAppConfig(options = {}) { let baseConfig = await cache.get(BASE_CONFIG_KEY); if (!baseConfig) { logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...'); - baseConfig = await AppService(); + baseConfig = await loadBaseConfig(); if (!baseConfig) { throw new Error('Failed to initialize app configuration through AppService.'); diff --git a/api/server/services/Config/handleRateLimits.js b/api/server/services/Config/handleRateLimits.js deleted file mode 100644 index 5e81c5f68d..0000000000 --- a/api/server/services/Config/handleRateLimits.js +++ /dev/null @@ -1,48 +0,0 @@ -const { RateLimitPrefix } = require('librechat-data-provider'); - -/** - * - * @param {TCustomConfig['rateLimits'] | undefined} rateLimits - */ -const handleRateLimits = (rateLimits) => { - if (!rateLimits) { - return; - } - - const rateLimitKeys = { - fileUploads: RateLimitPrefix.FILE_UPLOAD, - conversationsImport: RateLimitPrefix.IMPORT, - tts: RateLimitPrefix.TTS, - stt: RateLimitPrefix.STT, - }; - - Object.entries(rateLimitKeys).forEach(([key, prefix]) => { - const rateLimit = rateLimits[key]; - if (rateLimit) { - setRateLimitEnvVars(prefix, rateLimit); - } - }); -}; - -/** - * Set environment variables for rate limit configurations - * - * @param {string} prefix - Prefix for environment variable names - * @param {object} rateLimit - Rate limit configuration object - */ -const setRateLimitEnvVars = (prefix, rateLimit) => { - const envVarsMapping = { - ipMax: `${prefix}_IP_MAX`, - ipWindowInMinutes: `${prefix}_IP_WINDOW`, - userMax: `${prefix}_USER_MAX`, - userWindowInMinutes: `${prefix}_USER_WINDOW`, - }; - - Object.entries(envVarsMapping).forEach(([key, envVar]) => { - if (rateLimit[key] !== undefined) { - process.env[envVar] = rateLimit[key]; - } - }); -}; - -module.exports = handleRateLimits; diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 479c4bada0..c0415674b9 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -5,14 +5,12 @@ const keyBy = require('lodash/keyBy'); const { loadYaml } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { - CacheKeys, configSchema, paramSettings, EImageOutputType, agentParamSettings, validateSettingDefinitions, } = require('librechat-data-provider'); -const getLogStores = require('~/cache/getLogStores'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); @@ -119,7 +117,6 @@ https://www.librechat.ai/docs/configuration/stt_tts`); .filter((endpoint) => endpoint.customParams) .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); - if (result.data.modelSpecs) { customConfig.modelSpecs = result.data.modelSpecs; } diff --git a/api/server/services/Files/Azure/crud.js b/api/server/services/Files/Azure/crud.js index 3e091d1031..25bd749276 100644 --- a/api/server/services/Files/Azure/crud.js +++ b/api/server/services/Files/Azure/crud.js @@ -4,7 +4,7 @@ const mime = require('mime'); const axios = require('axios'); const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); -const { getAzureContainerClient } = require('./initialize'); +const { getAzureContainerClient } = require('@librechat/api'); const defaultBasePath = 'images'; const { AZURE_STORAGE_PUBLIC_ACCESS = 'true', AZURE_CONTAINER_NAME = 'files' } = process.env; @@ -30,7 +30,7 @@ async function saveBufferToAzure({ containerName, }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined; // Create the container if it doesn't exist. This is done per operation. await containerClient.createIfNotExists({ access }); @@ -84,7 +84,7 @@ async function saveURLToAzure({ */ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, containerName }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const blobPath = userId ? `${basePath}/${userId}/${fileName}` : `${basePath}/${fileName}`; const blockBlobClient = containerClient.getBlockBlobClient(blobPath); return blockBlobClient.url; @@ -103,7 +103,7 @@ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, conta */ async function deleteFileFromAzure(req, file) { try { - const containerClient = getAzureContainerClient(AZURE_CONTAINER_NAME); + const containerClient = await getAzureContainerClient(AZURE_CONTAINER_NAME); const blobPath = file.filepath.split(`${AZURE_CONTAINER_NAME}/`)[1]; if (!blobPath.includes(req.user.id)) { throw new Error('User ID not found in blob path'); @@ -140,7 +140,7 @@ async function streamFileToAzure({ containerName, }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined; // Create the container if it doesn't exist diff --git a/api/server/services/Files/Azure/index.js b/api/server/services/Files/Azure/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/Azure/index.js +++ b/api/server/services/Files/Azure/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/Files/Firebase/crud.js b/api/server/services/Files/Firebase/crud.js index 35e327148c..8e7a191609 100644 --- a/api/server/services/Files/Firebase/crud.js +++ b/api/server/services/Files/Firebase/crud.js @@ -3,9 +3,9 @@ const path = require('path'); const axios = require('axios'); const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); +const { getFirebaseStorage } = require('@librechat/api'); const { ref, uploadBytes, getDownloadURL, deleteObject } = require('firebase/storage'); const { getBufferMetadata } = require('~/server/utils'); -const { getFirebaseStorage } = require('./initialize'); /** * Deletes a file from Firebase Storage. diff --git a/api/server/services/Files/Firebase/index.js b/api/server/services/Files/Firebase/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/Firebase/index.js +++ b/api/server/services/Files/Firebase/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/Files/Firebase/initialize.js b/api/server/services/Files/Firebase/initialize.js deleted file mode 100644 index efe66be120..0000000000 --- a/api/server/services/Files/Firebase/initialize.js +++ /dev/null @@ -1,39 +0,0 @@ -const firebase = require('firebase/app'); -const { getStorage } = require('firebase/storage'); -const { logger } = require('@librechat/data-schemas'); - -let i = 0; -let firebaseApp = null; - -const initializeFirebase = () => { - // Return existing instance if already initialized - if (firebaseApp) { - return firebaseApp; - } - - const firebaseConfig = { - apiKey: process.env.FIREBASE_API_KEY, - authDomain: process.env.FIREBASE_AUTH_DOMAIN, - projectId: process.env.FIREBASE_PROJECT_ID, - storageBucket: process.env.FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.FIREBASE_APP_ID, - }; - - if (Object.values(firebaseConfig).some((value) => !value)) { - i === 0 && logger.info('[Optional] CDN not initialized.'); - i++; - return null; - } - - firebaseApp = firebase.initializeApp(firebaseConfig); - logger.info('Firebase CDN initialized'); - return firebaseApp; -}; - -const getFirebaseStorage = () => { - const app = initializeFirebase(); - return app ? getStorage(app) : null; -}; - -module.exports = { initializeFirebase, getFirebaseStorage }; diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index 7b31a4d24b..8dac767aa2 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -1,15 +1,15 @@ const fs = require('fs'); const fetch = require('node-fetch'); +const { initializeS3 } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { FileSources } = require('librechat-data-provider'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand, } = require('@aws-sdk/client-s3'); -const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); -const { initializeS3 } = require('./initialize'); const bucketName = process.env.AWS_BUCKET_NAME; const defaultBasePath = 'images'; diff --git a/api/server/services/Files/S3/index.js b/api/server/services/Files/S3/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/S3/index.js +++ b/api/server/services/Files/S3/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/start/modelSpecs.js b/api/server/services/start/modelSpecs.js deleted file mode 100644 index 057255010f..0000000000 --- a/api/server/services/start/modelSpecs.js +++ /dev/null @@ -1,75 +0,0 @@ -const { logger } = require('@librechat/data-schemas'); -const { normalizeEndpointName } = require('@librechat/api'); -const { EModelEndpoint } = require('librechat-data-provider'); - -/** - * Sets up Model Specs from the config (`librechat.yaml`) file. - * @param {TCustomConfig['endpoints']} [endpoints] - The loaded custom configuration for endpoints. - * @param {TCustomConfig['modelSpecs'] | undefined} [modelSpecs] - The loaded custom configuration for model specs. - * @param {TCustomConfig['interface'] | undefined} [interfaceConfig] - The loaded interface configuration. - * @returns {TCustomConfig['modelSpecs'] | undefined} The processed model specs, if any. - */ -function processModelSpecs(endpoints, _modelSpecs, interfaceConfig) { - if (!_modelSpecs) { - return undefined; - } - - /** @type {TCustomConfig['modelSpecs']['list']} */ - const modelSpecs = []; - /** @type {TCustomConfig['modelSpecs']['list']} */ - const list = _modelSpecs.list; - - const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? []; - - if (interfaceConfig.modelSelect !== true && (_modelSpecs.addedEndpoints?.length ?? 0) > 0) { - logger.warn( - `To utilize \`addedEndpoints\`, which allows provider/model selections alongside model specs, set \`modelSelect: true\` in the interface configuration. - - Example: - \`\`\`yaml - interface: - modelSelect: true - \`\`\` - `, - ); - } - - for (const spec of list) { - if (EModelEndpoint[spec.preset.endpoint] && spec.preset.endpoint !== EModelEndpoint.custom) { - modelSpecs.push(spec); - continue; - } else if (spec.preset.endpoint === EModelEndpoint.custom) { - logger.warn( - `Model Spec with endpoint "${spec.preset.endpoint}" is not supported. You must specify the name of the custom endpoint (case-sensitive, as defined in your config). Skipping model spec...`, - ); - continue; - } - - const normalizedName = normalizeEndpointName(spec.preset.endpoint); - const endpoint = customEndpoints.find( - (customEndpoint) => normalizedName === normalizeEndpointName(customEndpoint.name), - ); - - if (!endpoint) { - logger.warn(`Model spec with endpoint "${spec.preset.endpoint}" was skipped: Endpoint not found in configuration. The \`endpoint\` value must exactly match either a system-defined endpoint or a custom endpoint defined by the user. - -For more information, see the documentation at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/model_specs#endpoint`); - continue; - } - - modelSpecs.push({ - ...spec, - preset: { - ...spec.preset, - endpoint: normalizedName, - }, - }); - } - - return { - ..._modelSpecs, - list: modelSpecs, - }; -} - -module.exports = { processModelSpecs }; diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js deleted file mode 100644 index 9309e680c7..0000000000 --- a/api/server/services/start/turnstile.js +++ /dev/null @@ -1,44 +0,0 @@ -const { logger } = require('@librechat/data-schemas'); -const { removeNullishValues } = require('librechat-data-provider'); - -/** - * Loads and maps the Cloudflare Turnstile configuration. - * - * Expected config structure: - * - * turnstile: - * siteKey: "your-site-key-here" - * options: - * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) - * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" - * - * @param {TCustomConfig | undefined} config - The loaded custom configuration. - * @param {TConfigDefaults} configDefaults - The custom configuration default values. - * @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration. - */ -function loadTurnstileConfig(config, configDefaults) { - const { turnstile: customTurnstile = {} } = config ?? {}; - const { turnstile: defaults = {} } = configDefaults; - - /** @type {TCustomConfig['turnstile']} */ - const loadedTurnstile = removeNullishValues({ - siteKey: customTurnstile.siteKey ?? defaults.siteKey, - options: customTurnstile.options ?? defaults.options, - }); - - const enabled = Boolean(loadedTurnstile.siteKey); - - if (enabled) { - logger.info( - 'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2), - ); - } else { - logger.info('Turnstile is DISABLED (no siteKey provided).'); - } - - return loadedTurnstile; -} - -module.exports = { - loadTurnstileConfig, -}; diff --git a/api/typedefs.js b/api/typedefs.js index 6cf87ef61b..260eb84bfe 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1103,13 +1103,13 @@ /** * @exports AppConfig - * @typedef {import('@librechat/api').AppConfig} AppConfig + * @typedef {import('@librechat/data-schemas').AppConfig} AppConfig * @memberof typedefs */ /** * @exports JsonSchemaType - * @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType + * @typedef {import('@librechat/data-schemas').JsonSchemaType} JsonSchemaType * @memberof typedefs */ @@ -1371,12 +1371,7 @@ /** * @exports FunctionTool - * @typedef {Object} FunctionTool - * @property {'function'} type - The type of tool, 'function'. - * @property {Object} function - The function definition. - * @property {string} function.description - A description of what the function does. - * @property {string} function.name - The name of the function to be called. - * @property {Object} function.parameters - The parameters the function accepts, described as a JSON Schema object. + * @typedef {import('@librechat/data-schemas').FunctionTool} FunctionTool * @memberof typedefs */ diff --git a/package-lock.json b/package-lock.json index 4a70593821..0a3784fcd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51330,6 +51330,10 @@ "typescript": "^5.0.4" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.758.0", + "@azure/identity": "^4.7.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", "@librechat/agents": "^2.4.82", @@ -51341,6 +51345,7 @@ "eventsource": "^3.0.2", "express": "^4.21.2", "express-session": "^1.18.2", + "firebase": "^11.0.2", "form-data": "^4.0.4", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", diff --git a/packages/api/package.json b/packages/api/package.json index e32fa384e6..2a38366b41 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -74,6 +74,10 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.758.0", + "@azure/identity": "^4.7.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", "@librechat/agents": "^2.4.82", @@ -85,6 +89,7 @@ "eventsource": "^3.0.2", "express": "^4.21.2", "express-session": "^1.18.2", + "firebase": "^11.0.2", "form-data": "^4.0.4", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 0814e80f31..c22dc0fbf4 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -1,4 +1,3 @@ -export * from './config'; export * from './memory'; export * from './migration'; export * from './legacy'; diff --git a/packages/api/src/agents/resources.test.ts b/packages/api/src/agents/resources.test.ts index b594924934..bfd2327764 100644 --- a/packages/api/src/agents/resources.test.ts +++ b/packages/api/src/agents/resources.test.ts @@ -2,10 +2,9 @@ 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 { IUser, AppConfig } from '@librechat/data-schemas'; import type { Request as ServerRequest } from 'express'; -import type { IUser } from '@librechat/data-schemas'; import type { TGetFiles } from './resources'; -import type { AppConfig } from '~/types'; // Mock logger jest.mock('@librechat/data-schemas', () => ({ diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index d746e3c19f..9c32638a9c 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -1,10 +1,9 @@ import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider'; import type { AgentToolResources, TFile, AgentBaseResource } from 'librechat-data-provider'; +import type { IMongoFile, AppConfig, IUser } from '@librechat/data-schemas'; 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 diff --git a/packages/api/src/app/AppService.interface.spec.ts b/packages/api/src/app/AppService.interface.spec.ts new file mode 100644 index 0000000000..e15d57e329 --- /dev/null +++ b/packages/api/src/app/AppService.interface.spec.ts @@ -0,0 +1,157 @@ +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { AppService } from '@librechat/data-schemas'; + +describe('AppService interface configuration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should set prompts to true when config specifies prompts as true', async () => { + const config = { + interface: { + prompts: true, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + }), + }), + ); + }); + + it('should set prompts and bookmarks to false when config specifies them as false', async () => { + const config = { + interface: { + prompts: false, + bookmarks: false, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: false, + bookmarks: false, + }), + }), + ); + }); + + it('should not set prompts and bookmarks when not provided in config', async () => { + const config = {}; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.anything(), + }), + ); + + // Verify that prompts and bookmarks are undefined when not provided + expect(result.interfaceConfig?.prompts).toBeUndefined(); + expect(result.interfaceConfig?.bookmarks).toBeUndefined(); + }); + + it('should set prompts and bookmarks to different values when specified differently in config', async () => { + const config = { + interface: { + prompts: true, + bookmarks: false, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + bookmarks: false, + }), + }), + ); + }); + + it('should correctly configure peoplePicker permissions including roles', async () => { + const config = { + interface: { + peoplePicker: { + users: true, + groups: true, + roles: true, + }, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: true, + roles: true, + }), + }), + }), + ); + }); + + it('should handle mixed peoplePicker permissions', async () => { + const config = { + interface: { + peoplePicker: { + users: true, + groups: false, + roles: true, + }, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: false, + roles: true, + }), + }), + }), + ); + }); + + it('should not set peoplePicker when not provided in config', async () => { + const config = {}; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.anything(), + }), + ); + + // Verify that peoplePicker is undefined when not provided + expect(result.interfaceConfig?.peoplePicker).toBeUndefined(); + }); +}); diff --git a/api/server/services/AppService.spec.js b/packages/api/src/app/AppService.spec.ts similarity index 58% rename from api/server/services/AppService.spec.js rename to packages/api/src/app/AppService.spec.ts index 1b540c96c0..9c771b4bd6 100644 --- a/api/server/services/AppService.spec.js +++ b/packages/api/src/app/AppService.spec.ts @@ -1,4 +1,5 @@ -const { +import { + OCRStrategy, FileSources, EModelEndpoint, EImageOutputType, @@ -6,9 +7,8 @@ const { defaultSocialLogins, validateAzureGroups, defaultAgentCapabilities, - deprecatedAzureVariables, - conflictingAzureVariables, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), @@ -20,48 +20,7 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); -const AppService = require('./AppService'); - -jest.mock('./Files/Firebase/initialize', () => ({ - initializeFirebase: jest.fn(), -})); - -jest.mock('./Config/loadCustomConfig', () => - jest.fn(() => - Promise.resolve({ - registration: { socialLogins: ['testLogin'] }, - fileStrategy: 'testStrategy', - balance: { - enabled: true, - }, - }), - ), -); - -jest.mock('./start/tools', () => ({ - loadAndFormatTools: jest.fn().mockReturnValue({ - ExampleTool: { - type: 'function', - function: { - description: 'Example tool function', - name: 'exampleFunction', - parameters: { - type: 'object', - properties: { - param1: { type: 'string', description: 'An example parameter' }, - }, - required: ['param1'], - }, - }, - }, - }), -})); -jest.mock('./start/turnstile', () => ({ - loadTurnstileConfig: jest.fn(() => ({ - siteKey: 'default-site-key', - options: {}, - })), -})); +import { AppService } from '@librechat/data-schemas'; const azureGroups = [ { @@ -97,20 +56,26 @@ const azureGroups = [ models: { 'gpt-4-turbo': true, }, - }, + } as const, ]; -jest.mock('./start/checks', () => ({ - ...jest.requireActual('./start/checks'), - checkHealth: jest.fn(), -})); - describe('AppService', () => { - const mockedTurnstileConfig = { - siteKey: 'default-site-key', - options: {}, + const mockSystemTools = { + ExampleTool: { + type: 'function', + function: { + description: 'Example tool function', + name: 'exampleFunction', + parameters: { + type: 'object', + properties: { + param1: { type: 'string', description: 'An example parameter' }, + }, + required: ['param1'], + }, + }, + }, }; - const loadCustomConfig = require('./Config/loadCustomConfig'); beforeEach(() => { process.env.CDN_PROVIDER = undefined; @@ -118,7 +83,15 @@ describe('AppService', () => { }); it('should correctly assign process.env and initialize app config based on custom config', async () => { - const result = await AppService(); + const config: Partial = { + registration: { socialLogins: ['testLogin'] }, + fileStrategy: 'testStrategy' as FileSources, + balance: { + enabled: true, + }, + }; + + const result = await AppService({ config, systemTools: mockSystemTools }); expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); @@ -139,9 +112,6 @@ describe('AppService', () => { presets: true, }), mcpConfig: null, - turnstileConfig: mockedTurnstileConfig, - modelSpecs: undefined, - paths: expect.anything(), imageOutputType: expect.any(String), fileConfig: undefined, secureImageLinks: undefined, @@ -173,30 +143,13 @@ describe('AppService', () => { ); }); - it('should log a warning if the config version is outdated', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.9.0', // An outdated version for this test - registration: { socialLogins: ['testLogin'] }, - fileStrategy: 'testStrategy', - }), - ); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version')); - }); - it('should change the `imageOutputType` based on config value', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.10.0', - imageOutputType: EImageOutputType.WEBP, - }), - ); + const config = { + version: '0.10.0', + imageOutputType: EImageOutputType.WEBP, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.WEBP, @@ -205,13 +158,11 @@ describe('AppService', () => { }); it('should default to `PNG` `imageOutputType` with no provided type', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.10.0', - }), - ); + const config = { + version: '0.10.0', + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.PNG, @@ -220,9 +171,9 @@ describe('AppService', () => { }); it('should default to `PNG` `imageOutputType` with no provided config', async () => { - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.PNG, @@ -230,35 +181,14 @@ describe('AppService', () => { ); }); - it('should initialize Firebase when fileStrategy is firebase', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - fileStrategy: FileSources.firebase, - }), - ); - - await AppService(); - - const { initializeFirebase } = require('./Files/Firebase/initialize'); - expect(initializeFirebase).toHaveBeenCalled(); - - expect(process.env.CDN_PROVIDER).toEqual(FileSources.firebase); - }); - it('should load and format tools accurately with defined structure', async () => { - const { loadAndFormatTools } = require('./start/tools'); + const config = {}; - const result = await AppService(); - - expect(loadAndFormatTools).toHaveBeenCalledWith({ - adminFilter: undefined, - adminIncluded: undefined, - directory: expect.anything(), - }); + const result = await AppService({ config, systemTools: mockSystemTools }); // Verify tools are included in the returned config expect(result.availableTools).toBeDefined(); - expect(result.availableTools.ExampleTool).toEqual({ + expect(result.availableTools?.ExampleTool).toEqual({ type: 'function', function: { description: 'Example tool function', @@ -275,21 +205,19 @@ describe('AppService', () => { }); it('should correctly configure Assistants endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.assistants]: { - disableBuilder: true, - pollIntervalMs: 5000, - timeoutMs: 30000, - supportedIds: ['id1', 'id2'], - privateAssistants: false, - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.assistants]: { + disableBuilder: true, + pollIntervalMs: 5000, + timeoutMs: 30000, + supportedIds: ['id1', 'id2'], + privateAssistants: false, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -307,21 +235,19 @@ describe('AppService', () => { }); it('should correctly configure Agents endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.agents]: { - disableBuilder: true, - recursionLimit: 10, - maxRecursionLimit: 20, - allowedProviders: ['openai', 'anthropic'], - capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.agents]: { + disableBuilder: true, + recursionLimit: 10, + maxRecursionLimit: 20, + allowedProviders: ['openai', 'anthropic'], + capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -342,9 +268,9 @@ describe('AppService', () => { }); it('should configure Agents endpoint with defaults when no config is provided', async () => { - loadCustomConfig.mockImplementationOnce(() => Promise.resolve({})); + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -359,17 +285,15 @@ describe('AppService', () => { }); it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - }, + const config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -388,21 +312,19 @@ describe('AppService', () => { it('should correctly configure minimum Azure OpenAI Assistant values', async () => { const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: assistantGroups, - assistants: true, - }, + const config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + groups: assistantGroups, + assistants: true, }, - }), - ); + }, + }; process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ endpoints: expect.objectContaining({ @@ -419,20 +341,18 @@ describe('AppService', () => { }); it('should correctly configure Azure OpenAI endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + groups: azureGroups, }, - }), - ); + }, + }; process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - const result = await AppService(); + const result = await AppService({ config }); const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups); expect(result).toEqual( @@ -456,8 +376,9 @@ describe('AppService', () => { process.env.FILE_UPLOAD_USER_WINDOW = '20'; const initialEnv = { ...process.env }; + const config = {}; - await AppService(); + await AppService({ config }); // Expect environment variables to remain unchanged expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); @@ -466,38 +387,15 @@ describe('AppService', () => { expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW); }); - it('should correctly set FILE_UPLOAD environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits - const rateLimitsConfig = { - rateLimits: { - fileUploads: { - ipMax: '100', - ipWindowInMinutes: '60', - userMax: '50', - userWindowInMinutes: '30', - }, - }, - }; - - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(rateLimitsConfig)); - - await AppService(); - - // Verify that process.env has been updated according to the rate limits config - expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); - expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60'); - expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50'); - expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30'); - }); - it('should fallback to default FILE_UPLOAD environment variables when rate limits are unspecified', async () => { // Setup initial environment variables to non-default values process.env.FILE_UPLOAD_IP_MAX = 'initialMax'; process.env.FILE_UPLOAD_IP_WINDOW = 'initialWindow'; process.env.FILE_UPLOAD_USER_MAX = 'initialUserMax'; process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow'; + const config = {}; - await AppService(); + await AppService({ config }); // Verify that process.env falls back to the initial values expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax'); @@ -514,8 +412,9 @@ describe('AppService', () => { process.env.IMPORT_USER_WINDOW = '20'; const initialEnv = { ...process.env }; + const config = {}; - await AppService(); + await AppService({ config }); // Expect environment variables to remain unchanged expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); @@ -524,38 +423,15 @@ describe('AppService', () => { expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW); }); - it('should correctly set IMPORT environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits - const importLimitsConfig = { - rateLimits: { - conversationsImport: { - ipMax: '150', - ipWindowInMinutes: '60', - userMax: '50', - userWindowInMinutes: '30', - }, - }, - }; - - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(importLimitsConfig)); - - await AppService(); - - // Verify that process.env has been updated according to the rate limits config - expect(process.env.IMPORT_IP_MAX).toEqual('150'); - expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); - expect(process.env.IMPORT_USER_MAX).toEqual('50'); - expect(process.env.IMPORT_USER_WINDOW).toEqual('30'); - }); - it('should fallback to default IMPORT environment variables when rate limits are unspecified', async () => { // Setup initial environment variables to non-default values process.env.IMPORT_IP_MAX = 'initialMax'; process.env.IMPORT_IP_WINDOW = 'initialWindow'; process.env.IMPORT_USER_MAX = 'initialUserMax'; process.env.IMPORT_USER_WINDOW = 'initialUserWindow'; + const config = {}; - await AppService(); + await AppService({ config }); // Verify that process.env falls back to the initial values expect(process.env.IMPORT_IP_MAX).toEqual('initialMax'); @@ -565,34 +441,32 @@ describe('AppService', () => { }); it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleMethod: 'structured', - titlePrompt: 'Custom title prompt for conversation', - titlePromptTemplate: 'Summarize this conversation: {{conversation}}', - }, - [EModelEndpoint.assistants]: { - titleMethod: 'functions', - titlePrompt: 'Generate a title for this assistant conversation', - titlePromptTemplate: 'Assistant conversation template: {{messages}}', - }, - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - titleConvo: true, - titleMethod: 'completion', - titleModel: 'gpt-4', - titlePrompt: 'Azure title prompt', - titlePromptTemplate: 'Azure conversation: {{context}}', - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleMethod: 'structured', + titlePrompt: 'Custom title prompt for conversation', + titlePromptTemplate: 'Summarize this conversation: {{conversation}}', }, - }), - ); + [EModelEndpoint.assistants]: { + titleMethod: 'functions', + titlePrompt: 'Generate a title for this assistant conversation', + titlePromptTemplate: 'Assistant conversation template: {{messages}}', + }, + [EModelEndpoint.azureOpenAI]: { + groups: azureGroups, + titleConvo: true, + titleMethod: 'completion', + titleModel: 'gpt-4', + titlePrompt: 'Azure title prompt', + titlePromptTemplate: 'Azure conversation: {{context}}', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -625,24 +499,25 @@ describe('AppService', () => { }); it('should configure Agent endpoint with title generation settings', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.agents]: { - disableBuilder: false, - titleConvo: true, - titleModel: 'gpt-4', - titleMethod: 'structured', - titlePrompt: 'Generate a descriptive title for this agent conversation', - titlePromptTemplate: 'Agent conversation summary: {{content}}', - recursionLimit: 15, - capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.agents]: { + disableBuilder: false, + titleConvo: true, + titleModel: 'gpt-4', + titleMethod: 'structured', + titlePrompt: 'Generate a descriptive title for this agent conversation', + titlePromptTemplate: 'Agent conversation summary: {{content}}', + recursionLimit: 15, + capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], + maxCitations: 30, + maxCitationsPerFile: 7, + minRelevanceScore: 0.45, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -666,18 +541,16 @@ describe('AppService', () => { }); it('should handle missing title configuration options with defaults', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - // titlePrompt and titlePromptTemplate are not provided - }, + const config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + // titlePrompt and titlePromptTemplate are not provided }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -696,24 +569,27 @@ describe('AppService', () => { }); it('should correctly configure titleEndpoint when specified', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleEndpoint: EModelEndpoint.anthropic, - titlePrompt: 'Generate a concise title', - }, - [EModelEndpoint.agents]: { - titleEndpoint: 'custom-provider', - titleMethod: 'structured', - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleEndpoint: EModelEndpoint.anthropic, + titlePrompt: 'Generate a concise title', }, - }), - ); + [EModelEndpoint.agents]: { + disableBuilder: false, + capabilities: [AgentCapabilities.tools], + maxCitations: 30, + maxCitationsPerFile: 7, + minRelevanceScore: 0.45, + titleEndpoint: 'custom-provider', + titleMethod: 'structured', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -736,27 +612,25 @@ describe('AppService', () => { }); it('should correctly configure all endpoint when specified', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - all: { - titleConvo: true, - titleModel: 'gpt-4o-mini', - titleMethod: 'structured', - titlePrompt: 'Default title prompt for all endpoints', - titlePromptTemplate: 'Default template: {{conversation}}', - titleEndpoint: EModelEndpoint.anthropic, - streamRate: 50, - }, - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - }, + const config: Partial = { + endpoints: { + all: { + titleConvo: true, + titleModel: 'gpt-4o-mini', + titleMethod: 'structured', + titlePrompt: 'Default title prompt for all endpoints', + titlePromptTemplate: 'Default template: {{conversation}}', + titleEndpoint: EModelEndpoint.anthropic, + streamRate: 50, }, - }), - ); + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -783,8 +657,7 @@ describe('AppService', () => { }); describe('AppService updating app config and issuing warnings', () => { - let initialEnv; - const loadCustomConfig = require('./Config/loadCustomConfig'); + let initialEnv: NodeJS.ProcessEnv; beforeEach(() => { // Store initial environment variables to restore them after each test @@ -799,15 +672,13 @@ describe('AppService updating app config and issuing warnings', () => { process.env = { ...initialEnv }; }); - it('should initialize app config with default values if loadCustomConfig returns undefined', async () => { - // Mock loadCustomConfig to return undefined - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); + it('should initialize app config with default values if config is empty', async () => { + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ - paths: expect.anything(), config: {}, fileStrategy: FileSources.local, registration: expect.objectContaining({ @@ -821,10 +692,10 @@ describe('AppService updating app config and issuing warnings', () => { ); }); - it('should initialize app config with values from loadCustomConfig', async () => { + it('should initialize app config with values from config', async () => { // Mock loadCustomConfig to return a specific config object with a complete balance config - const customConfig = { - fileStrategy: 'firebase', + const config: Partial = { + fileStrategy: FileSources.firebase, registration: { socialLogins: ['testLogin'] }, balance: { enabled: false, @@ -835,27 +706,28 @@ describe('AppService updating app config and issuing warnings', () => { refillAmount: 5000, }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(customConfig)); - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ - paths: expect.anything(), - config: customConfig, - fileStrategy: customConfig.fileStrategy, + config, + fileStrategy: config.fileStrategy, registration: expect.objectContaining({ - socialLogins: customConfig.registration.socialLogins, + socialLogins: config.registration?.socialLogins, }), - balance: customConfig.balance, + balance: config.balance, }), ); }); it('should apply the assistants endpoint configuration correctly to app config', async () => { - const mockConfig = { + const config: Partial = { endpoints: { assistants: { + version: 'v2', + retrievalModels: ['gpt-4', 'gpt-3.5-turbo'], + capabilities: [], disableBuilder: true, pollIntervalMs: 5000, timeoutMs: 30000, @@ -863,9 +735,8 @@ describe('AppService updating app config and issuing warnings', () => { }, }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -884,119 +755,22 @@ describe('AppService updating app config and issuing warnings', () => { expect(result.endpoints.assistants.excludedIds).toBeUndefined(); }); - it('should log a warning when both supportedIds and excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - disableBuilder: false, - pollIntervalMs: 3000, - timeoutMs: 20000, - supportedIds: ['id1', 'id2'], - excludedIds: ['id3'], - }, - }, - }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - "The 'assistants' endpoint has both 'supportedIds' and 'excludedIds' defined.", - ), - ); - }); - - it('should log a warning when privateAssistants and supportedIds or excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - privateAssistants: true, - supportedIds: ['id1'], - }, - }, - }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - "The 'assistants' endpoint has both 'privateAssistants' and 'supportedIds' or 'excludedIds' defined.", - ), - ); - }); - - it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - deprecatedAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - deprecatedAzureVariables.forEach(({ key, description }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`, - ); - }); - }); - - it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - conflictingAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - conflictingAzureVariables.forEach(({ key }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`, - ); - }); - }); - it('should not parse environment variable references in OCR config', async () => { // Mock custom configuration with env variable references in OCR config - const mockConfig = { + const config: Partial = { ocr: { apiKey: '${OCR_API_KEY_CUSTOM_VAR_NAME}', baseURL: '${OCR_BASEURL_CUSTOM_VAR_NAME}', - strategy: 'mistral_ocr', + strategy: OCRStrategy.MISTRAL_OCR, mistralModel: 'mistral-medium', }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - // Set actual environment variables with different values process.env.OCR_API_KEY_CUSTOM_VAR_NAME = 'actual-api-key'; process.env.OCR_BASEURL_CUSTOM_VAR_NAME = 'https://actual-ocr-url.com'; - const result = await AppService(); + const result = await AppService({ config }); // Verify that the raw string references were preserved and not interpolated expect(result).toEqual( @@ -1012,7 +786,7 @@ describe('AppService updating app config and issuing warnings', () => { }); it('should correctly configure peoplePicker permissions when specified', async () => { - const mockConfig = { + const config = { interface: { peoplePicker: { users: true, @@ -1022,9 +796,7 @@ describe('AppService updating app config and issuing warnings', () => { }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - const result = await AppService(); + const result = await AppService({ config }); // Check that interface config includes the permissions expect(result).toEqual( diff --git a/packages/api/src/app/cdn.ts b/packages/api/src/app/cdn.ts new file mode 100644 index 0000000000..451eeb8897 --- /dev/null +++ b/packages/api/src/app/cdn.ts @@ -0,0 +1,26 @@ +import { logger } from '@librechat/data-schemas'; +import { FileSources } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { initializeAzureBlobService } from '~/cdn/azure'; +import { initializeFirebase } from '~/cdn/firebase'; +import { initializeS3 } from '~/cdn/s3'; + +/** + * Initializes file storage clients based on the configured file strategy. + * This should be called after loading the app configuration. + * @param {Object} options + * @param {AppConfig} options.appConfig - The application configuration + */ +export function initializeFileStorage(appConfig: AppConfig) { + const { fileStrategy } = appConfig; + + if (fileStrategy === FileSources.firebase) { + initializeFirebase(); + } else if (fileStrategy === FileSources.azure_blob) { + initializeAzureBlobService().catch((error) => { + logger.error('Error initializing Azure Blob Service:', error); + }); + } else if (fileStrategy === FileSources.s3) { + initializeS3(); + } +} diff --git a/api/server/services/start/checks.spec.js b/packages/api/src/app/checks.spec.ts similarity index 52% rename from api/server/services/start/checks.spec.js rename to packages/api/src/app/checks.spec.ts index 1281331266..cab5b727f9 100644 --- a/api/server/services/start/checks.spec.js +++ b/packages/api/src/app/checks.spec.ts @@ -11,12 +11,15 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); -const { checkWebSearchConfig } = require('./checks'); -const { logger } = require('@librechat/data-schemas'); -const { extractVariableName } = require('librechat-data-provider'); +import { handleRateLimits } from './limits'; +import { checkWebSearchConfig } from './checks'; +import { logger } from '@librechat/data-schemas'; +import { extractVariableName as extract } from 'librechat-data-provider'; + +const extractVariableName = extract as jest.MockedFunction; describe('checkWebSearchConfig', () => { - let originalEnv; + let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { // Clear all mocks @@ -178,6 +181,8 @@ describe('checkWebSearchConfig', () => { anotherKey: '${SOME_VAR}', }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + /** @ts-expect-error */ checkWebSearchConfig(config); expect(extractVariableName).not.toHaveBeenCalled(); @@ -200,3 +205,154 @@ describe('checkWebSearchConfig', () => { }); }); }); + +describe('handleRateLimits', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Store original environment + originalEnv = process.env; + + // Reset process.env + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + it('should correctly set FILE_UPLOAD environment variables based on rate limits', () => { + const rateLimits = { + fileUploads: { + ipMax: 100, + ipWindowInMinutes: 60, + userMax: 50, + userWindowInMinutes: 30, + }, + }; + + handleRateLimits(rateLimits); + + // Verify that process.env has been updated according to the rate limits config + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); + expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60'); + expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50'); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30'); + }); + + it('should correctly set IMPORT environment variables based on rate limits', () => { + const rateLimits = { + conversationsImport: { + ipMax: 150, + ipWindowInMinutes: 60, + userMax: 50, + userWindowInMinutes: 30, + }, + }; + + handleRateLimits(rateLimits); + + // Verify that process.env has been updated according to the rate limits config + expect(process.env.IMPORT_IP_MAX).toEqual('150'); + expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); + expect(process.env.IMPORT_USER_MAX).toEqual('50'); + expect(process.env.IMPORT_USER_WINDOW).toEqual('30'); + }); + + it('should not modify FILE_UPLOAD environment variables without rate limits', () => { + // Setup initial environment variables + process.env.FILE_UPLOAD_IP_MAX = '10'; + process.env.FILE_UPLOAD_IP_WINDOW = '15'; + process.env.FILE_UPLOAD_USER_MAX = '5'; + process.env.FILE_UPLOAD_USER_WINDOW = '20'; + + const initialEnv = { ...process.env }; + + handleRateLimits({}); + + // Expect environment variables to remain unchanged + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); + expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual(initialEnv.FILE_UPLOAD_IP_WINDOW); + expect(process.env.FILE_UPLOAD_USER_MAX).toEqual(initialEnv.FILE_UPLOAD_USER_MAX); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW); + }); + + it('should not modify IMPORT environment variables without rate limits', () => { + // Setup initial environment variables + process.env.IMPORT_IP_MAX = '10'; + process.env.IMPORT_IP_WINDOW = '15'; + process.env.IMPORT_USER_MAX = '5'; + process.env.IMPORT_USER_WINDOW = '20'; + + const initialEnv = { ...process.env }; + + handleRateLimits({}); + + // Expect environment variables to remain unchanged + expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); + expect(process.env.IMPORT_IP_WINDOW).toEqual(initialEnv.IMPORT_IP_WINDOW); + expect(process.env.IMPORT_USER_MAX).toEqual(initialEnv.IMPORT_USER_MAX); + expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW); + }); + + it('should handle undefined rateLimits parameter', () => { + // Setup initial environment variables + process.env.FILE_UPLOAD_IP_MAX = 'initial'; + process.env.IMPORT_IP_MAX = 'initial'; + + handleRateLimits(undefined); + + // Should not modify any environment variables + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initial'); + expect(process.env.IMPORT_IP_MAX).toEqual('initial'); + }); + + it('should handle partial rate limit configurations', () => { + const rateLimits = { + fileUploads: { + ipMax: 200, + // Only setting ipMax, other properties undefined + }, + }; + + handleRateLimits(rateLimits); + + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('200'); + // Other FILE_UPLOAD env vars should not be set + expect(process.env.FILE_UPLOAD_IP_WINDOW).toBeUndefined(); + expect(process.env.FILE_UPLOAD_USER_MAX).toBeUndefined(); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toBeUndefined(); + }); + + it('should correctly set TTS and STT environment variables based on rate limits', () => { + const rateLimits = { + tts: { + ipMax: 75, + ipWindowInMinutes: 45, + userMax: 25, + userWindowInMinutes: 15, + }, + stt: { + ipMax: 80, + ipWindowInMinutes: 50, + userMax: 30, + userWindowInMinutes: 20, + }, + }; + + handleRateLimits(rateLimits); + + // Verify TTS environment variables + expect(process.env.TTS_IP_MAX).toEqual('75'); + expect(process.env.TTS_IP_WINDOW).toEqual('45'); + expect(process.env.TTS_USER_MAX).toEqual('25'); + expect(process.env.TTS_USER_WINDOW).toEqual('15'); + + // Verify STT environment variables + expect(process.env.STT_IP_MAX).toEqual('80'); + expect(process.env.STT_IP_WINDOW).toEqual('50'); + expect(process.env.STT_USER_MAX).toEqual('30'); + expect(process.env.STT_USER_WINDOW).toEqual('20'); + }); +}); diff --git a/api/server/services/start/checks.js b/packages/api/src/app/checks.ts similarity index 53% rename from api/server/services/start/checks.js rename to packages/api/src/app/checks.ts index 5b13d41d59..66f5b620e6 100644 --- a/api/server/services/start/checks.js +++ b/packages/api/src/app/checks.ts @@ -1,11 +1,9 @@ -const { logger } = require('@librechat/data-schemas'); -const { isEnabled, webSearchKeys, checkEmailConfig } = require('@librechat/api'); -const { - Constants, - extractVariableName, - deprecatedAzureVariables, - conflictingAzureVariables, -} = require('librechat-data-provider'); +import { logger, webSearchKeys } from '@librechat/data-schemas'; +import { Constants, extractVariableName } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { isEnabled, checkEmailConfig } from '~/utils'; +import { handleRateLimits } from './limits'; const secretDefaults = { CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', @@ -32,17 +30,84 @@ const deprecatedVariables = [ }, ]; +export const deprecatedAzureVariables = [ + /* "related to" precedes description text */ + { key: 'AZURE_OPENAI_DEFAULT_MODEL', description: 'setting a default model' }, + { key: 'AZURE_OPENAI_MODELS', description: 'setting models' }, + { + key: 'AZURE_USE_MODEL_AS_DEPLOYMENT_NAME', + description: 'using model names as deployment names', + }, + { key: 'AZURE_API_KEY', description: 'setting a single Azure API key' }, + { key: 'AZURE_OPENAI_API_INSTANCE_NAME', description: 'setting a single Azure instance name' }, + { + key: 'AZURE_OPENAI_API_DEPLOYMENT_NAME', + description: 'setting a single Azure deployment name', + }, + { key: 'AZURE_OPENAI_API_VERSION', description: 'setting a single Azure API version' }, + { + key: 'AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME', + description: 'setting a single Azure completions deployment name', + }, + { + key: 'AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME', + description: 'setting a single Azure embeddings deployment name', + }, + { + key: 'PLUGINS_USE_AZURE', + description: 'using Azure for Plugins', + }, +]; + +export const conflictingAzureVariables = [ + { + key: 'INSTANCE_NAME', + }, + { + key: 'DEPLOYMENT_NAME', + }, +]; + +/** + * Checks the password reset configuration for security issues. + */ +function checkPasswordReset() { + const emailEnabled = checkEmailConfig(); + const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET); + + if (!emailEnabled && passwordResetAllowed) { + logger.warn( + `❗❗❗ + + Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured. + + This setup is insecure as password reset links will be issued with a recognized email. + + Please configure email service for secure password reset functionality. + + https://www.librechat.ai/docs/configuration/authentication/email + + ❗❗❗`, + ); + } +} + /** * Checks environment variables for default secrets and deprecated variables. - * Logs warnings for any default secret values being used and for usage of deprecated `GOOGLE_API_KEY`. + * Logs warnings for any default secret values being used and for usage of deprecated variables. * Advises on replacing default secrets and updating deprecated variables. + * @param {Object} options + * @param {Function} options.isEnabled - Function to check if a feature is enabled + * @param {Function} options.checkEmailConfig - Function to check email configuration */ -function checkVariables() { +export function checkVariables() { let hasDefaultSecrets = false; for (const [key, value] of Object.entries(secretDefaults)) { if (process.env[key] === value) { logger.warn(`Default value for ${key} is being used.`); - !hasDefaultSecrets && (hasDefaultSecrets = true); + if (!hasDefaultSecrets) { + hasDefaultSecrets = true; + } } } @@ -69,7 +134,7 @@ function checkVariables() { * Checks the health of auxiliary API's by attempting a fetch request to their respective `/health` endpoints. * Logs information or warning based on the API's availability and response. */ -async function checkHealth() { +export async function checkHealth() { try { const response = await fetch(`${process.env.RAG_API_URL}/health`); if (response?.ok && response?.status === 200) { @@ -104,11 +169,85 @@ function checkAzureVariables() { }); } +export function checkInterfaceConfig(appConfig: AppConfig) { + const interfaceConfig = appConfig.interfaceConfig; + let i = 0; + const logSettings = () => { + // log interface object and model specs object (without list) for reference + logger.warn(`\`interface\` settings:\n${JSON.stringify(interfaceConfig, null, 2)}`); + logger.warn( + `\`modelSpecs\` settings:\n${JSON.stringify( + { ...(appConfig?.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 (appConfig?.modelSpecs?.prioritize && interfaceConfig?.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 ( + appConfig?.modelSpecs?.enforce && + (interfaceConfig?.endpointsMenu || + interfaceConfig?.modelSelect || + interfaceConfig?.presets || + interfaceConfig?.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 (appConfig?.modelSpecs?.enforce && !appConfig?.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(); + } +} + +/** + * Performs startup checks including environment variable validation and health checks. + * This should be called during application startup before initializing services. + * @param [appConfig] - The application configuration object. + */ +export async function performStartupChecks(appConfig?: AppConfig) { + checkVariables(); + if (appConfig?.endpoints?.azureOpenAI) { + checkAzureVariables(); + } + if (appConfig) { + checkInterfaceConfig(appConfig); + } + if (appConfig?.config) { + checkConfig(appConfig.config); + } + if (appConfig?.config?.webSearch) { + checkWebSearchConfig(appConfig.config.webSearch); + } + if (appConfig?.config?.rateLimits) { + handleRateLimits(appConfig.config.rateLimits); + } + await checkHealth(); +} + /** * Performs basic checks on the loaded config object. - * @param {TCustomConfig} config - The loaded custom configuration. + * @param config - The loaded custom configuration. */ -function checkConfig(config) { +export function checkConfig(config: Partial) { if (config.version !== Constants.CONFIG_VERSION) { logger.info( `\nOutdated Config version: ${config.version} @@ -121,40 +260,19 @@ Latest version: ${Constants.CONFIG_VERSION} } } -function checkPasswordReset() { - const emailEnabled = checkEmailConfig(); - const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET); - - if (!emailEnabled && passwordResetAllowed) { - logger.warn( - `❗❗❗ - - Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured. - - This setup is insecure as password reset links will be issued with a recognized email. - - Please configure email service for secure password reset functionality. - - https://www.librechat.ai/docs/configuration/authentication/email - - ❗❗❗`, - ); - } -} - /** * Checks web search configuration values to ensure they are environment variable references. * Warns if actual API keys or URLs are used instead of environment variable references. * Logs debug information for properly configured environment variable references. - * @param {Object} webSearchConfig - The loaded web search configuration object. + * @param webSearchConfig - The loaded web search configuration object. */ -function checkWebSearchConfig(webSearchConfig) { +export function checkWebSearchConfig(webSearchConfig?: Partial | null) { if (!webSearchConfig) { return; } webSearchKeys.forEach((key) => { - const value = webSearchConfig[key]; + const value = webSearchConfig[key as keyof typeof webSearchConfig]; if (typeof value === 'string') { const varName = extractVariableName(value); @@ -187,11 +305,3 @@ function checkWebSearchConfig(webSearchConfig) { } }); } - -module.exports = { - checkHealth, - checkConfig, - checkVariables, - checkAzureVariables, - checkWebSearchConfig, -}; diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index 82f8b3e8cd..d55f9977fe 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -1,8 +1,8 @@ import { getTransactionsConfig, getBalanceConfig } from './config'; import { logger } from '@librechat/data-schemas'; import { FileSources } from 'librechat-data-provider'; -import type { AppConfig } from '~/types'; import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; // Helper function to create a minimal AppConfig for testing const createTestAppConfig = (overrides: Partial = {}): AppConfig => { diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts index 8bc9c9af26..8a2a681e65 100644 --- a/packages/api/src/app/config.ts +++ b/packages/api/src/app/config.ts @@ -1,8 +1,8 @@ +import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider'; -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; import { isEnabled, normalizeEndpointName } from '~/utils'; -import { logger } from '@librechat/data-schemas'; /** * Retrieves the balance configuration object @@ -24,7 +24,7 @@ export function getBalanceConfig(appConfig?: AppConfig): Partial { const defaultConfig: TTransactionsConfig = { enabled: true }; if (!appConfig) { @@ -66,5 +66,5 @@ export const getCustomEndpointConfig = ({ export function hasCustomUserVars(appConfig?: AppConfig): boolean { const mcpServers = appConfig?.mcpConfig; - return Object.values(mcpServers ?? {}).some((server) => server.customUserVars); + return Object.values(mcpServers ?? {}).some((server) => server?.customUserVars); } diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 74ae27abf3..b95193e943 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1,3 +1,4 @@ export * from './config'; -export * from './interface'; export * from './permissions'; +export * from './cdn'; +export * from './checks'; diff --git a/packages/api/src/app/limits.ts b/packages/api/src/app/limits.ts new file mode 100644 index 0000000000..a38a3c6310 --- /dev/null +++ b/packages/api/src/app/limits.ts @@ -0,0 +1,55 @@ +import { RateLimitPrefix } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +/** + * + * @param rateLimits + */ +export const handleRateLimits = (rateLimits?: TCustomConfig['rateLimits']) => { + if (!rateLimits) { + return; + } + + const rateLimitKeys = { + fileUploads: RateLimitPrefix.FILE_UPLOAD, + conversationsImport: RateLimitPrefix.IMPORT, + tts: RateLimitPrefix.TTS, + stt: RateLimitPrefix.STT, + }; + + Object.entries(rateLimitKeys).forEach(([key, prefix]) => { + const rateLimit = rateLimits[key as keyof typeof rateLimitKeys]; + if (rateLimit) { + setRateLimitEnvVars(prefix, rateLimit); + } + }); +}; + +type RateLimitConfig = { + ipMax?: number | undefined; + ipWindowInMinutes?: number | undefined; + userMax?: number | undefined; + userWindowInMinutes?: number | undefined; +}; + +/** + * Set environment variables for rate limit configurations + * + * @param prefix - Prefix for environment variable names + * @param rateLimit - Rate limit configuration object + */ +const setRateLimitEnvVars = (prefix: string, rateLimit: RateLimitConfig) => { + const envVarsMapping = { + ipMax: `${prefix}_IP_MAX`, + ipWindowInMinutes: `${prefix}_IP_WINDOW`, + userMax: `${prefix}_USER_MAX`, + userWindowInMinutes: `${prefix}_USER_WINDOW`, + }; + + Object.entries(envVarsMapping).forEach(([key, envVar]) => { + const value = rateLimit[key as keyof RateLimitConfig]; + if (value !== undefined) { + process.env[envVar] = value.toString(); + } + }); +}; diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index e3816d4013..6eeb70d7d7 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -1,8 +1,8 @@ +import { loadDefaultInterface } from '@librechat/data-schemas'; import { SystemRoles, Permissions, PermissionTypes, roleDefaults } from 'librechat-data-provider'; import type { TConfigDefaults, TCustomConfig } from 'librechat-data-provider'; -import type { AppConfig } from '~/types/config'; +import type { AppConfig } from '@librechat/data-schemas'; import { updateInterfacePermissions } from './permissions'; -import { loadDefaultInterface } from './interface'; const mockUpdateAccessPermissions = jest.fn(); const mockGetRoleByName = jest.fn(); diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index a039a1503e..eaaa5c9705 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -6,8 +6,7 @@ import { PermissionTypes, getConfigDefaults, } from 'librechat-data-provider'; -import type { IRole } from '@librechat/data-schemas'; -import type { AppConfig } from '~/types/config'; +import type { IRole, AppConfig } from '@librechat/data-schemas'; import { isMemoryEnabled } from '~/memory/config'; /** diff --git a/api/server/services/Files/Azure/initialize.js b/packages/api/src/cdn/azure.ts similarity index 63% rename from api/server/services/Files/Azure/initialize.js rename to packages/api/src/cdn/azure.ts index 8c63b48122..247530286c 100644 --- a/api/server/services/Files/Azure/initialize.js +++ b/packages/api/src/cdn/azure.ts @@ -1,7 +1,8 @@ -const { logger } = require('@librechat/data-schemas'); -const { BlobServiceClient } = require('@azure/storage-blob'); +import { logger } from '@librechat/data-schemas'; +import { DefaultAzureCredential } from '@azure/identity'; +import type { ContainerClient, BlobServiceClient } from '@azure/storage-blob'; -let blobServiceClient = null; +let blobServiceClient: BlobServiceClient | null = null; let azureWarningLogged = false; /** @@ -9,18 +10,18 @@ let azureWarningLogged = false; * This function establishes a connection by checking if a connection string is provided. * If available, the connection string is used; otherwise, Managed Identity (via DefaultAzureCredential) is utilized. * Note: Container creation (and its public access settings) is handled later in the CRUD functions. - * @returns {BlobServiceClient|null} The initialized client, or null if the required configuration is missing. + * @returns The initialized client, or null if the required configuration is missing. */ -const initializeAzureBlobService = () => { +export const initializeAzureBlobService = async (): Promise => { if (blobServiceClient) { return blobServiceClient; } const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; if (connectionString) { + const { BlobServiceClient } = await import('@azure/storage-blob'); blobServiceClient = BlobServiceClient.fromConnectionString(connectionString); logger.info('Azure Blob Service initialized using connection string'); } else { - const { DefaultAzureCredential } = require('@azure/identity'); const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME; if (!accountName) { if (!azureWarningLogged) { @@ -33,6 +34,7 @@ const initializeAzureBlobService = () => { } const url = `https://${accountName}.blob.core.windows.net`; const credential = new DefaultAzureCredential(); + const { BlobServiceClient } = await import('@azure/storage-blob'); blobServiceClient = new BlobServiceClient(url, credential); logger.info('Azure Blob Service initialized using Managed Identity'); } @@ -41,15 +43,12 @@ const initializeAzureBlobService = () => { /** * Retrieves the Azure ContainerClient for the given container name. - * @param {string} [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name. - * @returns {ContainerClient|null} The Azure ContainerClient. + * @param [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name. + * @returns The Azure ContainerClient. */ -const getAzureContainerClient = (containerName = process.env.AZURE_CONTAINER_NAME || 'files') => { - const serviceClient = initializeAzureBlobService(); +export const getAzureContainerClient = async ( + containerName = process.env.AZURE_CONTAINER_NAME || 'files', +): Promise => { + const serviceClient = await initializeAzureBlobService(); return serviceClient ? serviceClient.getContainerClient(containerName) : null; }; - -module.exports = { - initializeAzureBlobService, - getAzureContainerClient, -}; diff --git a/packages/api/src/cdn/firebase.ts b/packages/api/src/cdn/firebase.ts new file mode 100644 index 0000000000..30fca65b1b --- /dev/null +++ b/packages/api/src/cdn/firebase.ts @@ -0,0 +1,42 @@ +import firebase from 'firebase/app'; +import { getStorage } from 'firebase/storage'; +import { logger } from '@librechat/data-schemas'; +import type { FirebaseStorage } from 'firebase/storage'; +import type { FirebaseApp } from 'firebase/app'; + +let firebaseInitCount = 0; +let firebaseApp: FirebaseApp | null = null; + +export const initializeFirebase = () => { + if (firebaseApp) { + return firebaseApp; + } + + const firebaseConfig = { + apiKey: process.env.FIREBASE_API_KEY, + authDomain: process.env.FIREBASE_AUTH_DOMAIN, + projectId: process.env.FIREBASE_PROJECT_ID, + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.FIREBASE_APP_ID, + }; + + if (Object.values(firebaseConfig).some((value) => !value)) { + if (firebaseInitCount === 0) { + logger.info( + '[Optional] Firebase CDN not initialized. To enable, set FIREBASE_API_KEY, FIREBASE_AUTH_DOMAIN, FIREBASE_PROJECT_ID, FIREBASE_STORAGE_BUCKET, FIREBASE_MESSAGING_SENDER_ID, and FIREBASE_APP_ID environment variables.', + ); + } + firebaseInitCount++; + return null; + } + + firebaseApp = firebase.initializeApp(firebaseConfig); + logger.info('Firebase CDN initialized'); + return firebaseApp; +}; + +export const getFirebaseStorage = (): FirebaseStorage | null => { + const app = initializeFirebase(); + return app ? getStorage(app) : null; +}; diff --git a/packages/api/src/cdn/index.ts b/packages/api/src/cdn/index.ts new file mode 100644 index 0000000000..04be850216 --- /dev/null +++ b/packages/api/src/cdn/index.ts @@ -0,0 +1,3 @@ +export * from './azure'; +export * from './firebase'; +export * from './s3'; diff --git a/api/server/services/Files/S3/initialize.js b/packages/api/src/cdn/s3.ts similarity index 82% rename from api/server/services/Files/S3/initialize.js rename to packages/api/src/cdn/s3.ts index 59a2568b47..683a7887fa 100644 --- a/api/server/services/Files/S3/initialize.js +++ b/packages/api/src/cdn/s3.ts @@ -1,7 +1,7 @@ -const { S3Client } = require('@aws-sdk/client-s3'); -const { logger } = require('@librechat/data-schemas'); +import { S3Client } from '@aws-sdk/client-s3'; +import { logger } from '@librechat/data-schemas'; -let s3 = null; +let s3: S3Client | null = null; /** * Initializes and returns an instance of the AWS S3 client. @@ -11,9 +11,9 @@ let s3 = null; * * If AWS_ENDPOINT_URL is provided, it will be used as the endpoint. * - * @returns {S3Client|null} An instance of S3Client if the region is provided; otherwise, null. + * @returns An instance of S3Client if the region is provided; otherwise, null. */ -const initializeS3 = () => { +export const initializeS3 = (): S3Client | null => { if (s3) { return s3; } @@ -49,5 +49,3 @@ const initializeS3 = () => { return s3; }; - -module.exports = { initializeS3 }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index bc52c02229..e839a335a4 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,5 @@ export * from './app'; +export * from './cdn'; /* Auth */ export * from './auth'; /* MCP */ diff --git a/packages/api/src/mcp/MCPServersRegistry.ts b/packages/api/src/mcp/MCPServersRegistry.ts index b6eaa09f01..668ad7d2c0 100644 --- a/packages/api/src/mcp/MCPServersRegistry.ts +++ b/packages/api/src/mcp/MCPServersRegistry.ts @@ -1,8 +1,8 @@ import mapValues from 'lodash/mapValues'; import { logger } from '@librechat/data-schemas'; import { Constants } from 'librechat-data-provider'; +import type { JsonSchemaType } from '@librechat/data-schemas'; import type { MCPConnection } from '~/mcp/connection'; -import type { JsonSchemaType } from '~/types'; import type * as t from '~/mcp/types'; import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; import { detectOAuthRequirement } from '~/mcp/oauth'; diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index bc579f0166..07e62cf5ae 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // zod.spec.ts import { z } from 'zod'; -import type { JsonSchemaType } from '~/types'; +import type { JsonSchemaType } from '@librechat/data-schemas'; import { resolveJsonSchemaRefs, convertJsonSchemaToZod, convertWithResolvedRefs } from '../zod'; describe('convertJsonSchemaToZod', () => { diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 7d137afd0b..5cf003b9f5 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -10,9 +10,8 @@ import { } from 'librechat-data-provider'; import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider'; import type * as t from '@modelcontextprotocol/sdk/types.js'; -import type { TokenMethods } from '@librechat/data-schemas'; +import type { TokenMethods, JsonSchemaType } from '@librechat/data-schemas'; import type { FlowStateManager } from '~/flow/manager'; -import type { JsonSchemaType } from '~/types/zod'; import type { RequestBody } from '~/types/http'; import type * as o from '~/mcp/oauth/types'; diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index 305765dfa6..ea9d17c0b2 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '~/types'; +import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '@librechat/data-schemas'; function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean { return ( diff --git a/packages/api/src/middleware/balance.ts b/packages/api/src/middleware/balance.ts index f51666a155..e3eb1e7ae1 100644 --- a/packages/api/src/middleware/balance.ts +++ b/packages/api/src/middleware/balance.ts @@ -1,8 +1,8 @@ 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 { IBalance, IUser, BalanceConfig, ObjectId, AppConfig } from '@librechat/data-schemas'; import type { Model } from 'mongoose'; -import type { AppConfig, BalanceUpdateFields } from '~/types'; +import type { BalanceUpdateFields } from '~/types'; import { getBalanceConfig } from '~/app/config'; export interface BalanceMiddlewareOptions { diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index 585797fe9c..f57e4674f9 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -1,6 +1,5 @@ import type { Request } from 'express'; -import type { IUser } from '@librechat/data-schemas'; -import type { AppConfig } from './config'; +import type { IUser, AppConfig } from '@librechat/data-schemas'; /** * LibreChat-specific request body type that extends Express Request body diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 5603c09a56..92a46bb064 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -1,4 +1,3 @@ -export * from './config'; export * from './azure'; export * from './balance'; export * from './endpoints'; @@ -11,6 +10,4 @@ export * from './mistral'; export * from './openai'; export * from './prompts'; export * from './run'; -export * from './tools'; -export * from './zod'; export * from './anthropic'; diff --git a/packages/api/src/types/openai.ts b/packages/api/src/types/openai.ts index 3389314937..d0bbffe194 100644 --- a/packages/api/src/types/openai.ts +++ b/packages/api/src/types/openai.ts @@ -3,8 +3,8 @@ import { openAISchema, EModelEndpoint } from 'librechat-data-provider'; import type { TEndpointOption, TAzureConfig, TEndpoint, TConfig } from 'librechat-data-provider'; import type { BindToolsInput } from '@langchain/core/language_models/chat_models'; import type { OpenAIClientOptions, Providers } from '@librechat/agents'; +import type { AppConfig } from '@librechat/data-schemas'; import type { AzureOptions } from './azure'; -import type { AppConfig } from './config'; export type OpenAIParameters = z.infer; diff --git a/packages/api/src/types/tools.ts b/packages/api/src/types/tools.ts deleted file mode 100644 index 591c10da8a..0000000000 --- a/packages/api/src/types/tools.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { JsonSchemaType } from './zod'; - -export interface FunctionTool { - type: 'function'; - function: { - description: string; - name: string; - parameters: JsonSchemaType; - }; -} diff --git a/packages/api/src/types/zod.ts b/packages/api/src/types/zod.ts deleted file mode 100644 index 75d1a0b4e6..0000000000 --- a/packages/api/src/types/zod.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type JsonSchemaType = { - type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object'; - enum?: string[]; - items?: JsonSchemaType; - properties?: Record; - required?: string[]; - description?: string; - additionalProperties?: boolean | JsonSchemaType; -}; - -export type ConvertJsonSchemaToZodOptions = { - allowEmptyObject?: boolean; - dropFields?: string[]; - transformOneOfAnyOf?: boolean; -}; diff --git a/packages/api/src/utils/tempChatRetention.spec.ts b/packages/api/src/utils/tempChatRetention.spec.ts index 847088ab7c..ef029cdde5 100644 --- a/packages/api/src/utils/tempChatRetention.spec.ts +++ b/packages/api/src/utils/tempChatRetention.spec.ts @@ -1,4 +1,4 @@ -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; import { createTempChatExpirationDate, getTempChatRetentionHours, diff --git a/packages/api/src/utils/tempChatRetention.ts b/packages/api/src/utils/tempChatRetention.ts index 3505a51a1b..eaa6ad2029 100644 --- a/packages/api/src/utils/tempChatRetention.ts +++ b/packages/api/src/utils/tempChatRetention.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; /** * Default retention period for temporary chats in hours diff --git a/packages/api/src/web/web.spec.ts b/packages/api/src/web/web.spec.ts index b91b534908..e0c66047c1 100644 --- a/packages/api/src/web/web.spec.ts +++ b/packages/api/src/web/web.spec.ts @@ -1,3 +1,5 @@ +import { webSearchAuth } from '@librechat/data-schemas'; +import { SafeSearchTypes, AuthType } from 'librechat-data-provider'; import type { ScraperTypes, TCustomConfig, @@ -5,8 +7,7 @@ import type { SearchProviders, TWebSearchConfig, } from 'librechat-data-provider'; -import { webSearchAuth, loadWebSearchAuth, extractWebSearchEnvVars } from './web'; -import { SafeSearchTypes, AuthType } from 'librechat-data-provider'; +import { loadWebSearchAuth, extractWebSearchEnvVars } from './web'; // Mock the extractVariableName function jest.mock('../utils', () => ({ diff --git a/packages/api/src/web/web.ts b/packages/api/src/web/web.ts index 681a42e34b..da34114bc5 100644 --- a/packages/api/src/web/web.ts +++ b/packages/api/src/web/web.ts @@ -1,3 +1,9 @@ +import { + AuthType, + SafeSearchTypes, + SearchCategories, + extractVariableName, +} from 'librechat-data-provider'; import type { ScraperTypes, RerankerTypes, @@ -5,108 +11,8 @@ import type { SearchProviders, TWebSearchConfig, } from 'librechat-data-provider'; -import { - SearchCategories, - SafeSearchTypes, - extractVariableName, - AuthType, -} from 'librechat-data-provider'; - -export function loadWebSearchConfig( - config: TCustomConfig['webSearch'], -): TCustomConfig['webSearch'] { - const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}'; - const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}'; - const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}'; - const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; - const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; - const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; - const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}'; - const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; - const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE; - - return { - ...config, - safeSearch, - jinaApiKey, - jinaApiUrl, - cohereApiKey, - serperApiKey, - searxngInstanceUrl, - searxngApiKey, - firecrawlApiKey, - firecrawlApiUrl, - }; -} - -export type TWebSearchKeys = - | 'serperApiKey' - | 'searxngInstanceUrl' - | 'searxngApiKey' - | 'firecrawlApiKey' - | 'firecrawlApiUrl' - | 'jinaApiKey' - | 'jinaApiUrl' - | 'cohereApiKey'; - -export type TWebSearchCategories = - | SearchCategories.PROVIDERS - | SearchCategories.SCRAPERS - | SearchCategories.RERANKERS; - -export const webSearchAuth = { - providers: { - serper: { - serperApiKey: 1 as const, - }, - searxng: { - searxngInstanceUrl: 1 as const, - /** Optional (0) */ - searxngApiKey: 0 as const, - }, - }, - scrapers: { - firecrawl: { - firecrawlApiKey: 1 as const, - /** Optional (0) */ - firecrawlApiUrl: 0 as const, - }, - }, - rerankers: { - jina: { - jinaApiKey: 1 as const, - /** Optional (0) */ - jinaApiUrl: 0 as const, - }, - cohere: { cohereApiKey: 1 as const }, - }, -}; - -/** - * Extracts all API keys from the webSearchAuth configuration object - */ -export function getWebSearchKeys(): TWebSearchKeys[] { - const keys: TWebSearchKeys[] = []; - - // Iterate through each category (providers, scrapers, rerankers) - for (const category of Object.keys(webSearchAuth)) { - const categoryObj = webSearchAuth[category as TWebSearchCategories]; - - // Iterate through each service within the category - for (const service of Object.keys(categoryObj)) { - const serviceObj = categoryObj[service as keyof typeof categoryObj]; - - // Extract the API keys from the service - for (const key of Object.keys(serviceObj)) { - keys.push(key as TWebSearchKeys); - } - } - } - - return keys; -} - -export const webSearchKeys: TWebSearchKeys[] = getWebSearchKeys(); +import { webSearchAuth } from '@librechat/data-schemas'; +import type { TWebSearchKeys, TWebSearchCategories } from '@librechat/data-schemas'; export function extractWebSearchEnvVars({ keys, diff --git a/packages/data-provider/src/azure.ts b/packages/data-provider/src/azure.ts index 17188ec551..99d7032177 100644 --- a/packages/data-provider/src/azure.ts +++ b/packages/data-provider/src/azure.ts @@ -10,44 +10,6 @@ import { extractEnvVariable, envVarRegex } from '../src/utils'; import { azureGroupConfigsSchema } from '../src/config'; import { errorsToString } from '../src/parsers'; -export const deprecatedAzureVariables = [ - /* "related to" precedes description text */ - { key: 'AZURE_OPENAI_DEFAULT_MODEL', description: 'setting a default model' }, - { key: 'AZURE_OPENAI_MODELS', description: 'setting models' }, - { - key: 'AZURE_USE_MODEL_AS_DEPLOYMENT_NAME', - description: 'using model names as deployment names', - }, - { key: 'AZURE_API_KEY', description: 'setting a single Azure API key' }, - { key: 'AZURE_OPENAI_API_INSTANCE_NAME', description: 'setting a single Azure instance name' }, - { - key: 'AZURE_OPENAI_API_DEPLOYMENT_NAME', - description: 'setting a single Azure deployment name', - }, - { key: 'AZURE_OPENAI_API_VERSION', description: 'setting a single Azure API version' }, - { - key: 'AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME', - description: 'setting a single Azure completions deployment name', - }, - { - key: 'AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME', - description: 'setting a single Azure embeddings deployment name', - }, - { - key: 'PLUGINS_USE_AZURE', - description: 'using Azure for Plugins', - }, -]; - -export const conflictingAzureVariables = [ - { - key: 'INSTANCE_NAME', - }, - { - key: 'DEPLOYMENT_NAME', - }, -]; - export function validateAzureGroups(configs: TAzureGroups): TAzureConfigValidationResult { let isValid = true; const modelNames: string[] = []; @@ -239,13 +201,13 @@ export function mapModelToAzureConfig({ const { deploymentName = '', version = '' } = typeof modelDetails === 'object' ? { - deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, - version: modelDetails.version ?? groupConfig.version, - } + deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, + version: modelDetails.version ?? groupConfig.version, + } : { - deploymentName: groupConfig.deploymentName, - version: groupConfig.version, - }; + deploymentName: groupConfig.deploymentName, + version: groupConfig.version, + }; if (!deploymentName || !version) { throw new Error( @@ -335,13 +297,13 @@ export function mapGroupToAzureConfig({ const { deploymentName = '', version = '' } = typeof modelDetails === 'object' ? { - deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, - version: modelDetails.version ?? groupConfig.version, - } + deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, + version: modelDetails.version ?? groupConfig.version, + } : { - deploymentName: groupConfig.deploymentName, - version: groupConfig.version, - }; + deploymentName: groupConfig.deploymentName, + version: groupConfig.version, + }; if (!deploymentName || !version) { throw new Error( diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 1747cd7511..4e318dc709 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -155,8 +155,10 @@ export type TAzureGroupMap = Record< export type TValidatedAzureConfig = { modelNames: string[]; - modelGroupMap: TAzureModelGroupMap; groupMap: TAzureGroupMap; + assistantModels?: string[]; + assistantGroups?: string[]; + modelGroupMap: TAzureModelGroupMap; }; export type TAzureConfigValidationResult = TValidatedAzureConfig & { @@ -752,7 +754,7 @@ export const webSearchSchema = z.object({ .optional(), }); -export type TWebSearchConfig = z.infer; +export type TWebSearchConfig = DeepPartial>; export const ocrSchema = z.object({ mistralModel: z.string().optional(), @@ -799,7 +801,7 @@ export const memorySchema = z.object({ .optional(), }); -export type TMemoryConfig = z.infer; +export type TMemoryConfig = DeepPartial>; const customEndpointsSchema = z.array(endpointSchema.partial()).optional(); @@ -862,9 +864,27 @@ export const configSchema = z.object({ .optional(), }); -export const getConfigDefaults = () => getSchemaDefaults(configSchema); +/** + * Recursively makes all properties of T optional, including nested objects. + * Handles arrays, primitives, functions, and Date objects correctly. + */ +export type DeepPartial = T extends (infer U)[] + ? DeepPartial[] + : T extends ReadonlyArray + ? ReadonlyArray> + : // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + T extends Function + ? T + : T extends Date + ? T + : T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; -export type TCustomConfig = z.infer; +export const getConfigDefaults = () => getSchemaDefaults(configSchema); +export type TCustomConfig = DeepPartial>; export type TCustomEndpoints = z.infer; export type TProviderSchema = diff --git a/packages/api/src/agents/config.ts b/packages/data-schemas/src/app/agents.ts similarity index 91% rename from packages/api/src/agents/config.ts rename to packages/data-schemas/src/app/agents.ts index 36b1f600eb..c75546f588 100644 --- a/packages/api/src/agents/config.ts +++ b/packages/data-schemas/src/app/agents.ts @@ -10,8 +10,8 @@ import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider'; * @returns The Agents endpoint configuration. */ export function agentsConfigSetup( - config: TCustomConfig, - defaultConfig: Partial, + config: Partial, + defaultConfig?: Partial, ): Partial { const agentsConfig = config?.endpoints?.[EModelEndpoint.agents]; diff --git a/api/server/services/start/assistants.js b/packages/data-schemas/src/app/assistants.ts similarity index 64% rename from api/server/services/start/assistants.js rename to packages/data-schemas/src/app/assistants.ts index febc170a95..c41a8d603e 100644 --- a/api/server/services/start/assistants.js +++ b/packages/data-schemas/src/app/assistants.ts @@ -1,15 +1,20 @@ -const { logger } = require('@librechat/data-schemas'); -const { +import logger from '~/config/winston'; +import { Capabilities, + EModelEndpoint, assistantEndpointSchema, defaultAssistantsVersion, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig, TAssistantEndpoint } from 'librechat-data-provider'; /** * Sets up the minimum, default Assistants configuration if Azure OpenAI Assistants option is enabled. - * @returns {Partial} The Assistants endpoint configuration. + * @returns The Assistants endpoint configuration. */ -function azureAssistantsDefaults() { +export function azureAssistantsDefaults(): { + capabilities: TAssistantEndpoint['capabilities']; + version: TAssistantEndpoint['version']; +} { return { capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter], version: defaultAssistantsVersion.azureAssistants, @@ -18,22 +23,26 @@ function azureAssistantsDefaults() { /** * Sets up the Assistants configuration from the config (`librechat.yaml`) file. - * @param {TCustomConfig} config - The loaded custom configuration. - * @param {EModelEndpoint.assistants|EModelEndpoint.azureAssistants} assistantsEndpoint - The Assistants endpoint name. + * @param config - The loaded custom configuration. + * @param assistantsEndpoint - The Assistants endpoint name. * - The previously loaded assistants configuration from Azure OpenAI Assistants option. - * @param {Partial} [prevConfig] - * @returns {Partial} The Assistants endpoint configuration. + * @param [prevConfig] + * @returns The Assistants endpoint configuration. */ -function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) { - const assistantsConfig = config.endpoints[assistantsEndpoint]; +export function assistantsConfigSetup( + config: Partial, + assistantsEndpoint: EModelEndpoint.assistants | EModelEndpoint.azureAssistants, + prevConfig: Partial = {}, +): Partial { + const assistantsConfig = config.endpoints?.[assistantsEndpoint]; const parsedConfig = assistantEndpointSchema.parse(assistantsConfig); - if (assistantsConfig.supportedIds?.length && assistantsConfig.excludedIds?.length) { + if (assistantsConfig?.supportedIds?.length && assistantsConfig.excludedIds?.length) { logger.warn( `Configuration conflict: The '${assistantsEndpoint}' endpoint has both 'supportedIds' and 'excludedIds' defined. The 'excludedIds' will be ignored.`, ); } if ( - assistantsConfig.privateAssistants && + assistantsConfig?.privateAssistants && (assistantsConfig.supportedIds?.length || assistantsConfig.excludedIds?.length) ) { logger.warn( @@ -59,5 +68,3 @@ function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) { titlePromptTemplate: parsedConfig.titlePromptTemplate, }; } - -module.exports = { azureAssistantsDefaults, assistantsConfigSetup }; diff --git a/api/server/services/start/azureOpenAI.js b/packages/data-schemas/src/app/azure.ts similarity index 65% rename from api/server/services/start/azureOpenAI.js rename to packages/data-schemas/src/app/azure.ts index 1598b28ba9..35d855ba43 100644 --- a/api/server/services/start/azureOpenAI.js +++ b/packages/data-schemas/src/app/azure.ts @@ -1,18 +1,22 @@ -const { logger } = require('@librechat/data-schemas'); -const { +import logger from '~/config/winston'; +import { EModelEndpoint, validateAzureGroups, mapModelToAzureConfig, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig, TAzureConfig } from 'librechat-data-provider'; /** * Sets up the Azure OpenAI configuration from the config (`librechat.yaml`) file. - * @param {TCustomConfig} config - The loaded custom configuration. - * @returns {TAzureConfig} The Azure OpenAI configuration. + * @param config - The loaded custom configuration. + * @returns The Azure OpenAI configuration. */ -function azureConfigSetup(config) { - const { groups, ...azureConfiguration } = config.endpoints[EModelEndpoint.azureOpenAI]; - /** @type {TAzureConfigValidationResult} */ +export function azureConfigSetup(config: Partial): TAzureConfig { + const azureConfig = config.endpoints?.[EModelEndpoint.azureOpenAI]; + if (!azureConfig) { + throw new Error('Azure OpenAI configuration is missing.'); + } + const { groups, ...azureConfiguration } = azureConfig; const { isValid, modelNames, modelGroupMap, groupMap, errors } = validateAzureGroups(groups); if (!isValid) { @@ -22,16 +26,18 @@ function azureConfigSetup(config) { throw new Error(errorMessage); } - const assistantModels = []; - const assistantGroups = new Set(); + const assistantModels: string[] = []; + const assistantGroups = new Set(); for (const modelName of modelNames) { mapModelToAzureConfig({ modelName, modelGroupMap, groupMap }); const groupName = modelGroupMap?.[modelName]?.group; const modelGroup = groupMap?.[groupName]; - let supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; + const supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; if (supportsAssistants) { assistantModels.push(modelName); - !assistantGroups.has(groupName) && assistantGroups.add(groupName); + if (!assistantGroups.has(groupName)) { + assistantGroups.add(groupName); + } } } @@ -53,13 +59,13 @@ function azureConfigSetup(config) { } return { + errors, + isValid, + groupMap, modelNames, modelGroupMap, - groupMap, assistantModels, assistantGroups: Array.from(assistantGroups), ...azureConfiguration, }; } - -module.exports = { azureConfigSetup }; diff --git a/api/server/services/start/endpoints.js b/packages/data-schemas/src/app/endpoints.ts similarity index 63% rename from api/server/services/start/endpoints.js rename to packages/data-schemas/src/app/endpoints.ts index 3e9bd0df82..b4bdf2985b 100644 --- a/api/server/services/start/endpoints.js +++ b/packages/data-schemas/src/app/endpoints.ts @@ -1,22 +1,24 @@ -const { agentsConfigSetup } = require('@librechat/api'); -const { EModelEndpoint } = require('librechat-data-provider'); -const { azureAssistantsDefaults, assistantsConfigSetup } = require('./assistants'); -const { azureConfigSetup } = require('./azureOpenAI'); -const { checkAzureVariables } = require('./checks'); +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '~/types'; +import { azureAssistantsDefaults, assistantsConfigSetup } from './assistants'; +import { agentsConfigSetup } from './agents'; +import { azureConfigSetup } from './azure'; /** * Loads custom config endpoints - * @param {TCustomConfig} [config] - * @param {TCustomConfig['endpoints']['agents']} [agentsDefaults] + * @param [config] + * @param [agentsDefaults] */ -const loadEndpoints = (config, agentsDefaults) => { - /** @type {AppConfig['endpoints']} */ - const loadedEndpoints = {}; +export const loadEndpoints = ( + config: Partial, + agentsDefaults?: Partial, +) => { + const loadedEndpoints: AppConfig['endpoints'] = {}; const endpoints = config?.endpoints; if (endpoints?.[EModelEndpoint.azureOpenAI]) { loadedEndpoints[EModelEndpoint.azureOpenAI] = azureConfigSetup(config); - checkAzureVariables(); } if (endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { @@ -50,8 +52,9 @@ const loadEndpoints = (config, agentsDefaults) => { ]; endpointKeys.forEach((key) => { - if (endpoints?.[key]) { - loadedEndpoints[key] = endpoints[key]; + const currentKey = key as keyof typeof endpoints; + if (endpoints?.[currentKey]) { + loadedEndpoints[currentKey] = endpoints[currentKey]; } }); @@ -61,7 +64,3 @@ const loadEndpoints = (config, agentsDefaults) => { return loadedEndpoints; }; - -module.exports = { - loadEndpoints, -}; diff --git a/packages/data-schemas/src/app/index.ts b/packages/data-schemas/src/app/index.ts new file mode 100644 index 0000000000..4912946329 --- /dev/null +++ b/packages/data-schemas/src/app/index.ts @@ -0,0 +1,6 @@ +export * from './agents'; +export * from './interface'; +export * from './service'; +export * from './specs'; +export * from './turnstile'; +export * from './web'; diff --git a/packages/api/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts similarity index 55% rename from packages/api/src/app/interface.ts rename to packages/data-schemas/src/app/interface.ts index 3a03d09434..f8afdefd33 100644 --- a/packages/api/src/app/interface.ts +++ b/packages/data-schemas/src/app/interface.ts @@ -1,8 +1,7 @@ -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'; +import type { AppConfig } from '~/types/app'; +import { isMemoryEnabled } from './memory'; /** * Loads the default interface object. @@ -58,51 +57,5 @@ export async function loadDefaultInterface({ 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; } diff --git a/packages/data-schemas/src/app/memory.ts b/packages/data-schemas/src/app/memory.ts new file mode 100644 index 0000000000..8c27047cf6 --- /dev/null +++ b/packages/data-schemas/src/app/memory.ts @@ -0,0 +1,28 @@ +import { memorySchema } from 'librechat-data-provider'; +import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider'; + +const hasValidAgent = (agent: TMemoryConfig['agent']) => + !!agent && + (('id' in agent && !!agent.id) || + ('provider' in agent && 'model' in agent && !!agent.provider && !!agent.model)); + +const isDisabled = (config?: TMemoryConfig | TCustomConfig['memory']) => + !config || config.disabled === true; + +export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig | undefined { + if (!config) return undefined; + if (isDisabled(config)) return config as TMemoryConfig; + + if (!hasValidAgent(config.agent)) { + return { ...config, disabled: true } as TMemoryConfig; + } + + const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000; + + return { ...config, charLimit }; +} + +export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean { + if (isDisabled(config)) return false; + return hasValidAgent(config!.agent); +} diff --git a/packages/data-schemas/src/app/ocr.ts b/packages/data-schemas/src/app/ocr.ts new file mode 100644 index 0000000000..02060a858f --- /dev/null +++ b/packages/data-schemas/src/app/ocr.ts @@ -0,0 +1,15 @@ +import { OCRStrategy } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr'] | undefined { + if (!config) return; + const baseURL = config?.baseURL ?? ''; + const apiKey = config?.apiKey ?? ''; + const mistralModel = config?.mistralModel ?? ''; + return { + apiKey, + baseURL, + mistralModel, + strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR, + }; +} diff --git a/api/server/services/AppService.js b/packages/data-schemas/src/app/service.ts similarity index 52% rename from api/server/services/AppService.js rename to packages/data-schemas/src/app/service.ts index 49f9e324fb..aef2472d5f 100644 --- a/api/server/services/AppService.js +++ b/packages/data-schemas/src/app/service.ts @@ -1,48 +1,56 @@ -const { FileSources, EModelEndpoint, getConfigDefaults } = require('librechat-data-provider'); -const { - isEnabled, - loadOCRConfig, - loadMemoryConfig, - agentsConfigSetup, - loadWebSearchConfig, - loadDefaultInterface, -} = require('@librechat/api'); -const { - checkWebSearchConfig, - checkVariables, - checkHealth, - checkConfig, -} = require('./start/checks'); -const { initializeAzureBlobService } = require('./Files/Azure/initialize'); -const { initializeFirebase } = require('./Files/Firebase/initialize'); -const handleRateLimits = require('./Config/handleRateLimits'); -const loadCustomConfig = require('./Config/loadCustomConfig'); -const { loadTurnstileConfig } = require('./start/turnstile'); -const { processModelSpecs } = require('./start/modelSpecs'); -const { initializeS3 } = require('./Files/S3/initialize'); -const { loadAndFormatTools } = require('./start/tools'); -const { loadEndpoints } = require('./start/endpoints'); -const paths = require('~/config/paths'); +import { EModelEndpoint, getConfigDefaults } from 'librechat-data-provider'; +import type { TCustomConfig, FileSources, DeepPartial } from 'librechat-data-provider'; +import type { AppConfig, FunctionTool } from '~/types/app'; +import { loadDefaultInterface } from './interface'; +import { loadTurnstileConfig } from './turnstile'; +import { agentsConfigSetup } from './agents'; +import { loadWebSearchConfig } from './web'; +import { processModelSpecs } from './specs'; +import { loadMemoryConfig } from './memory'; +import { loadEndpoints } from './endpoints'; +import { loadOCRConfig } from './ocr'; + +export type Paths = { + root: string; + uploads: string; + clientPath: string; + dist: string; + publicPath: string; + fonts: string; + assets: string; + imageOutput: string; + structuredTools: string; + pluginManifest: string; +}; /** * Loads custom config and initializes app-wide variables. * @function AppService */ -const AppService = async () => { - /** @type {TCustomConfig} */ - const config = (await loadCustomConfig()) ?? {}; +export const AppService = async (params?: { + config: DeepPartial; + paths?: Paths; + systemTools?: Record; +}): Promise => { + const { config, paths, systemTools } = params || {}; + if (!config) { + throw new Error('Config is required'); + } const configDefaults = getConfigDefaults(); const ocr = loadOCRConfig(config.ocr); const webSearch = loadWebSearchConfig(config.webSearch); - checkWebSearchConfig(webSearch); const memory = loadMemoryConfig(config.memory); const filteredTools = config.filteredTools; const includedTools = config.includedTools; - const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy; + const fileStrategy = (config.fileStrategy ?? configDefaults.fileStrategy) as + | FileSources.local + | FileSources.s3 + | FileSources.firebase + | FileSources.azure_blob; const startBalance = process.env.START_BALANCE; const balance = config.balance ?? { - enabled: isEnabled(process.env.CHECK_BALANCE), + enabled: process.env.CHECK_BALANCE?.toLowerCase().trim() === 'true', startBalance: startBalance ? parseInt(startBalance, 10) : undefined, }; const transactions = config.transactions ?? configDefaults.transactions; @@ -50,23 +58,7 @@ const AppService = async () => { process.env.CDN_PROVIDER = fileStrategy; - checkVariables(); - await checkHealth(); - - if (fileStrategy === FileSources.firebase) { - initializeFirebase(); - } else if (fileStrategy === FileSources.azure_blob) { - initializeAzureBlobService(); - } else if (fileStrategy === FileSources.s3) { - initializeS3(); - } - - /** @type {Record} */ - const availableTools = loadAndFormatTools({ - adminFilter: filteredTools, - adminIncluded: includedTools, - directory: paths.structuredTools, - }); + const availableTools = systemTools; const mcpConfig = config.mcpServers || null; const registration = config.registration ?? configDefaults.registration; @@ -107,8 +99,6 @@ const AppService = async () => { return appConfig; } - checkConfig(config); - handleRateLimits(config?.rateLimits); const loadedEndpoints = loadEndpoints(config, agentsDefaults); const appConfig = { @@ -121,5 +111,3 @@ const AppService = async () => { return appConfig; }; - -module.exports = AppService; diff --git a/packages/data-schemas/src/app/specs.ts b/packages/data-schemas/src/app/specs.ts new file mode 100644 index 0000000000..4fa82d37a4 --- /dev/null +++ b/packages/data-schemas/src/app/specs.ts @@ -0,0 +1,94 @@ +import logger from '~/config/winston'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +/** + * Normalize the endpoint name to system-expected value. + * @param name + */ +function normalizeEndpointName(name = ''): string { + return name.toLowerCase() === 'ollama' ? 'ollama' : name; +} + +/** + * Sets up Model Specs from the config (`librechat.yaml`) file. + * @param [endpoints] - The loaded custom configuration for endpoints. + * @param [modelSpecs] - The loaded custom configuration for model specs. + * @param [interfaceConfig] - The loaded interface configuration. + * @returns The processed model specs, if any. + */ +export function processModelSpecs( + endpoints?: TCustomConfig['endpoints'], + _modelSpecs?: TCustomConfig['modelSpecs'], + interfaceConfig?: TCustomConfig['interface'], +): TCustomConfig['modelSpecs'] | undefined { + if (!_modelSpecs) { + return undefined; + } + + const list = _modelSpecs.list; + const modelSpecs: typeof list = []; + + const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? []; + + if (interfaceConfig?.modelSelect !== true && (_modelSpecs.addedEndpoints?.length ?? 0) > 0) { + logger.warn( + `To utilize \`addedEndpoints\`, which allows provider/model selections alongside model specs, set \`modelSelect: true\` in the interface configuration. + + Example: + \`\`\`yaml + interface: + modelSelect: true + \`\`\` + `, + ); + } + + if (!list || list.length === 0) { + return undefined; + } + + for (const spec of list) { + const currentEndpoint = spec.preset?.endpoint as EModelEndpoint | undefined; + if (!currentEndpoint) { + logger.warn( + 'A model spec is missing the `endpoint` field within its `preset`. Skipping model spec...', + ); + continue; + } + if (EModelEndpoint[currentEndpoint] && currentEndpoint !== EModelEndpoint.custom) { + modelSpecs.push(spec); + continue; + } else if (currentEndpoint === EModelEndpoint.custom) { + logger.warn( + `Model Spec with endpoint "${currentEndpoint}" is not supported. You must specify the name of the custom endpoint (case-sensitive, as defined in your config). Skipping model spec...`, + ); + continue; + } + + const normalizedName = normalizeEndpointName(currentEndpoint); + const endpoint = customEndpoints.find( + (customEndpoint) => normalizedName === normalizeEndpointName(customEndpoint.name), + ); + + if (!endpoint) { + logger.warn(`Model spec with endpoint "${currentEndpoint}" was skipped: Endpoint not found in configuration. The \`endpoint\` value must exactly match either a system-defined endpoint or a custom endpoint defined by the user. + +For more information, see the documentation at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/model_specs#endpoint`); + continue; + } + + modelSpecs.push({ + ...spec, + preset: { + ...spec.preset, + endpoint: normalizedName, + }, + }); + } + + return { + ..._modelSpecs, + list: modelSpecs, + }; +} diff --git a/packages/data-schemas/src/app/turnstile.ts b/packages/data-schemas/src/app/turnstile.ts new file mode 100644 index 0000000000..df0ae5a998 --- /dev/null +++ b/packages/data-schemas/src/app/turnstile.ts @@ -0,0 +1,45 @@ +import logger from '~/config/winston'; +import { removeNullishValues } from 'librechat-data-provider'; +import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider'; + +/** + * Loads and maps the Cloudflare Turnstile configuration. + * + * Expected config structure: + * + * turnstile: + * siteKey: "your-site-key-here" + * options: + * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) + * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" + * + * @param config - The loaded custom configuration. + * @param configDefaults - The custom configuration default values. + * @returns The mapped Turnstile configuration. + */ +export function loadTurnstileConfig( + config: Partial | undefined, + configDefaults: TConfigDefaults, +): Partial { + const { turnstile: customTurnstile } = config ?? {}; + const { turnstile: defaults } = configDefaults; + + const loadedTurnstile = removeNullishValues({ + siteKey: + customTurnstile?.siteKey ?? (defaults as TCustomConfig['turnstile'] | undefined)?.siteKey, + options: + customTurnstile?.options ?? (defaults as TCustomConfig['turnstile'] | undefined)?.options, + }); + + const enabled = Boolean(loadedTurnstile.siteKey); + + if (enabled) { + logger.debug( + 'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2), + ); + } else { + logger.debug('Turnstile is DISABLED (no siteKey provided).'); + } + + return loadedTurnstile; +} diff --git a/packages/data-schemas/src/app/web.ts b/packages/data-schemas/src/app/web.ts new file mode 100644 index 0000000000..5fbf2c674e --- /dev/null +++ b/packages/data-schemas/src/app/web.ts @@ -0,0 +1,84 @@ +import { SafeSearchTypes } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { TWebSearchKeys, TWebSearchCategories } from '~/types/web'; + +export const webSearchAuth = { + providers: { + serper: { + serperApiKey: 1 as const, + }, + searxng: { + searxngInstanceUrl: 1 as const, + /** Optional (0) */ + searxngApiKey: 0 as const, + }, + }, + scrapers: { + firecrawl: { + firecrawlApiKey: 1 as const, + /** Optional (0) */ + firecrawlApiUrl: 0 as const, + }, + }, + rerankers: { + jina: { + jinaApiKey: 1 as const, + /** Optional (0) */ + jinaApiUrl: 0 as const, + }, + cohere: { cohereApiKey: 1 as const }, + }, +}; + +/** + * Extracts all API keys from the webSearchAuth configuration object + */ +export function getWebSearchKeys(): TWebSearchKeys[] { + const keys: TWebSearchKeys[] = []; + + // Iterate through each category (providers, scrapers, rerankers) + for (const category of Object.keys(webSearchAuth)) { + const categoryObj = webSearchAuth[category as TWebSearchCategories]; + + // Iterate through each service within the category + for (const service of Object.keys(categoryObj)) { + const serviceObj = categoryObj[service as keyof typeof categoryObj]; + + // Extract the API keys from the service + for (const key of Object.keys(serviceObj)) { + keys.push(key as TWebSearchKeys); + } + } + } + + return keys; +} + +export const webSearchKeys: TWebSearchKeys[] = getWebSearchKeys(); + +export function loadWebSearchConfig( + config: TCustomConfig['webSearch'], +): TCustomConfig['webSearch'] { + const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}'; + const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}'; + const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}'; + const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; + const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; + const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; + const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}'; + const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; + const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE; + + return { + ...config, + safeSearch, + jinaApiKey, + jinaApiUrl, + cohereApiKey, + serperApiKey, + searxngInstanceUrl, + searxngApiKey, + firecrawlApiKey, + firecrawlApiUrl, + }; +} diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 503c99385f..0754dfe258 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -1,3 +1,4 @@ +export * from './app'; export * from './common'; export * from './crypto'; export * from './schema'; diff --git a/packages/data-schemas/src/models/pluginAuth.ts b/packages/data-schemas/src/models/pluginAuth.ts index cf466a145f..5075fe6f43 100644 --- a/packages/data-schemas/src/models/pluginAuth.ts +++ b/packages/data-schemas/src/models/pluginAuth.ts @@ -1,4 +1,5 @@ -import pluginAuthSchema, { IPluginAuth } from '~/schema/pluginAuth'; +import pluginAuthSchema from '~/schema/pluginAuth'; +import type { IPluginAuth } from '~/types/pluginAuth'; /** * Creates or returns the PluginAuth model using the provided mongoose instance and schema diff --git a/packages/data-schemas/src/models/prompt.ts b/packages/data-schemas/src/models/prompt.ts index 74cc4ea2da..87edfa1ef8 100644 --- a/packages/data-schemas/src/models/prompt.ts +++ b/packages/data-schemas/src/models/prompt.ts @@ -1,4 +1,5 @@ -import promptSchema, { IPrompt } from '~/schema/prompt'; +import promptSchema from '~/schema/prompt'; +import type { IPrompt } from '~/types/prompts'; /** * Creates or returns the Prompt model using the provided mongoose instance and schema diff --git a/packages/data-schemas/src/models/promptGroup.ts b/packages/data-schemas/src/models/promptGroup.ts index 41e3d2e347..8de3dc9e16 100644 --- a/packages/data-schemas/src/models/promptGroup.ts +++ b/packages/data-schemas/src/models/promptGroup.ts @@ -1,4 +1,5 @@ -import promptGroupSchema, { IPromptGroupDocument } from '~/schema/promptGroup'; +import promptGroupSchema from '~/schema/promptGroup'; +import type { IPromptGroupDocument } from '~/types/prompts'; /** * Creates or returns the PromptGroup model using the provided mongoose instance and schema diff --git a/packages/api/src/types/config.ts b/packages/data-schemas/src/types/app.ts similarity index 69% rename from packages/api/src/types/config.ts rename to packages/data-schemas/src/types/app.ts index ff38cbd8d1..1078cb3f92 100644 --- a/packages/api/src/types/config.ts +++ b/packages/data-schemas/src/types/app.ts @@ -9,7 +9,31 @@ import type { TCustomEndpoints, TAssistantEndpoint, } from 'librechat-data-provider'; -import type { FunctionTool } from './tools'; + +export type JsonSchemaType = { + type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object'; + enum?: string[]; + items?: JsonSchemaType; + properties?: Record; + required?: string[]; + description?: string; + additionalProperties?: boolean | JsonSchemaType; +}; + +export type ConvertJsonSchemaToZodOptions = { + allowEmptyObject?: boolean; + dropFields?: string[]; + transformOneOfAnyOf?: boolean; +}; + +export interface FunctionTool { + type: 'function'; + function: { + description: string; + name: string; + parameters: JsonSchemaType; + }; +} /** * Application configuration object @@ -17,11 +41,11 @@ import type { FunctionTool } from './tools'; */ export interface AppConfig { /** The main custom configuration */ - config: TCustomConfig; + config: Partial; /** OCR configuration */ ocr?: TCustomConfig['ocr']; /** File paths configuration */ - paths: { + paths?: { uploads: string; imageOutput: string; publicPath: string; @@ -34,7 +58,7 @@ export interface AppConfig { /** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */ fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob; /** File strategies configuration */ - fileStrategies: TCustomConfig['fileStrategies']; + fileStrategies?: TCustomConfig['fileStrategies']; /** Registration configurations */ registration?: TCustomConfig['registration']; /** Actions configurations */ @@ -48,9 +72,9 @@ export interface AppConfig { /** Interface configuration */ interfaceConfig?: TCustomConfig['interface']; /** Turnstile configuration */ - turnstileConfig?: TCustomConfig['turnstile']; + turnstileConfig?: Partial; /** Balance configuration */ - balance?: TCustomConfig['balance']; + balance?: Partial; /** Transactions configuration */ transactions?: TCustomConfig['transactions']; /** Speech configuration */ @@ -67,26 +91,26 @@ export interface AppConfig { availableTools?: Record; endpoints?: { /** OpenAI endpoint configuration */ - openAI?: TEndpoint; + openAI?: Partial; /** Google endpoint configuration */ - google?: TEndpoint; + google?: Partial; /** Bedrock endpoint configuration */ - bedrock?: TEndpoint; + bedrock?: Partial; /** Anthropic endpoint configuration */ - anthropic?: TEndpoint; + anthropic?: Partial; /** GPT plugins endpoint configuration */ - gptPlugins?: TEndpoint; + gptPlugins?: Partial; /** Azure OpenAI endpoint configuration */ azureOpenAI?: TAzureConfig; /** Assistants endpoint configuration */ - assistants?: TAssistantEndpoint; + assistants?: Partial; /** Azure assistants endpoint configuration */ - azureAssistants?: TAssistantEndpoint; + azureAssistants?: Partial; /** Agents endpoint configuration */ - [EModelEndpoint.agents]?: TAgentsEndpoint; + [EModelEndpoint.agents]?: Partial; /** Custom endpoints configuration */ [EModelEndpoint.custom]?: TCustomEndpoints; /** Global endpoint configuration */ - all?: TEndpoint; + all?: Partial; }; } diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 4a755beda7..58122cbc55 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -1,6 +1,7 @@ import type { Types } from 'mongoose'; export type ObjectId = Types.ObjectId; +export * from './app'; export * from './user'; export * from './token'; export * from './convo'; @@ -24,3 +25,5 @@ export * from './prompts'; export * from './accessRole'; export * from './aclEntry'; export * from './group'; +/* Web */ +export * from './web'; diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index b1cb4b87c3..a672e3fae2 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -1,4 +1,5 @@ import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import type { DeepPartial } from 'librechat-data-provider'; import type { Document } from 'mongoose'; import { CursorPaginationParams } from '~/common'; @@ -54,9 +55,6 @@ export interface IRole extends Document { } export type RolePermissions = IRole['permissions']; -type DeepPartial = { - [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; -}; export type RolePermissionsInput = DeepPartial; export interface CreateRoleRequest { diff --git a/packages/data-schemas/src/types/web.ts b/packages/data-schemas/src/types/web.ts new file mode 100644 index 0000000000..c424be48d2 --- /dev/null +++ b/packages/data-schemas/src/types/web.ts @@ -0,0 +1,16 @@ +import type { SearchCategories } from 'librechat-data-provider'; + +export type TWebSearchKeys = + | 'serperApiKey' + | 'searxngInstanceUrl' + | 'searxngApiKey' + | 'firecrawlApiKey' + | 'firecrawlApiUrl' + | 'jinaApiKey' + | 'jinaApiUrl' + | 'cohereApiKey'; + +export type TWebSearchCategories = + | SearchCategories.PROVIDERS + | SearchCategories.SCRAPERS + | SearchCategories.RERANKERS;