diff --git a/.env.example b/.env.example index c0537a0bc1..a890a7ca86 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,7 @@ DEBUG_CONSOLE=false #===============# # Configuration # #===============# +# Use an absolute path, a relative path, or a URL # CONFIG_PATH="/alternative/path/to/librechat.yaml" diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 7440a79d68..617cd7d946 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,8 +1,10 @@ const path = require('path'); const { CacheKeys, configSchema } = require('librechat-data-provider'); +const getLogStores = require('~/cache/getLogStores'); const loadYaml = require('~/utils/loadYaml'); -const { getLogStores } = require('~/cache'); const { logger } = require('~/config'); +const axios = require('axios'); +const yaml = require('js-yaml'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); @@ -19,19 +21,43 @@ async function loadCustomConfig() { // Use CONFIG_PATH if set, otherwise fallback to defaultConfigPath const configPath = process.env.CONFIG_PATH || defaultConfigPath; - const customConfig = loadYaml(configPath); - if (!customConfig) { - i === 0 && - logger.info( - 'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://docs.librechat.ai/install/configuration/custom_config.html\n\n', - ); - i === 0 && i++; - return null; + let customConfig; + + if (/^https?:\/\//.test(configPath)) { + try { + const response = await axios.get(configPath); + customConfig = response.data; + } catch (error) { + i === 0 && logger.error(`Failed to fetch the remote config file from ${configPath}`, error); + i === 0 && i++; + return null; + } + } else { + customConfig = loadYaml(configPath); + if (!customConfig) { + i === 0 && + logger.info( + 'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://docs.librechat.ai/install/configuration/custom_config.html\n\n', + ); + i === 0 && i++; + return null; + } + } + + if (typeof customConfig === 'string') { + try { + customConfig = yaml.load(customConfig); + } catch (parseError) { + i === 0 && logger.info(`Failed to parse the YAML config from ${configPath}`, parseError); + i === 0 && i++; + return null; + } } const result = configSchema.strict().safeParse(customConfig); if (!result.success) { - logger.error(`Invalid custom config file at ${configPath}`, result.error); + i === 0 && logger.error(`Invalid custom config file at ${configPath}`, result.error); + i === 0 && i++; return null; } else { logger.info('Custom config file loaded:'); @@ -44,8 +70,6 @@ async function loadCustomConfig() { await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); } - // TODO: handle remote config - return customConfig; } diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js new file mode 100644 index 0000000000..24553b9f3e --- /dev/null +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -0,0 +1,153 @@ +jest.mock('axios'); +jest.mock('~/cache/getLogStores'); +jest.mock('~/utils/loadYaml'); + +const axios = require('axios'); +const loadCustomConfig = require('./loadCustomConfig'); +const getLogStores = require('~/cache/getLogStores'); +const loadYaml = require('~/utils/loadYaml'); +const { logger } = require('~/config'); + +describe('loadCustomConfig', () => { + const mockSet = jest.fn(); + const mockCache = { set: mockSet }; + + beforeEach(() => { + jest.resetAllMocks(); + delete process.env.CONFIG_PATH; + getLogStores.mockReturnValue(mockCache); + }); + + it('should return null and log error if remote config fetch fails', async () => { + process.env.CONFIG_PATH = 'http://example.com/config.yaml'; + axios.get.mockRejectedValue(new Error('Network error')); + const result = await loadCustomConfig(); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('should return null for an invalid local config file', async () => { + process.env.CONFIG_PATH = 'localConfig.yaml'; + loadYaml.mockReturnValueOnce(null); + const result = await loadCustomConfig(); + expect(result).toBeNull(); + }); + + it('should parse, validate, and cache a valid local configuration', async () => { + const mockConfig = { + version: '1.0', + cache: true, + endpoints: { + custom: [ + { + name: 'mistral', + apiKey: 'user_provided', + baseURL: 'https://api.mistral.ai/v1', + }, + ], + }, + }; + process.env.CONFIG_PATH = 'validConfig.yaml'; + loadYaml.mockReturnValueOnce(mockConfig); + const result = await loadCustomConfig(); + + expect(result).toEqual(mockConfig); + expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig); + }); + + it('should return null and log if config schema validation fails', async () => { + const invalidConfig = { invalidField: true }; + process.env.CONFIG_PATH = 'invalidConfig.yaml'; + loadYaml.mockReturnValueOnce(invalidConfig); + + const result = await loadCustomConfig(); + + expect(result).toBeNull(); + }); + + it('should handle and return null on YAML parse error for a string response from remote', async () => { + process.env.CONFIG_PATH = 'http://example.com/config.yaml'; + axios.get.mockResolvedValue({ data: 'invalidYAMLContent' }); + + const result = await loadCustomConfig(); + + expect(result).toBeNull(); + }); + + it('should return the custom config object for a valid remote config file', async () => { + const mockConfig = { + version: '1.0', + cache: true, + endpoints: { + custom: [ + { + name: 'mistral', + apiKey: 'user_provided', + baseURL: 'https://api.mistral.ai/v1', + }, + ], + }, + }; + process.env.CONFIG_PATH = 'http://example.com/config.yaml'; + axios.get.mockResolvedValue({ data: mockConfig }); + const result = await loadCustomConfig(); + expect(result).toEqual(mockConfig); + expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig); + }); + + it('should return null if the remote config file is not found', async () => { + process.env.CONFIG_PATH = 'http://example.com/config.yaml'; + axios.get.mockRejectedValue({ response: { status: 404 } }); + const result = await loadCustomConfig(); + expect(result).toBeNull(); + }); + + it('should return null if the local config file is not found', async () => { + process.env.CONFIG_PATH = 'nonExistentConfig.yaml'; + loadYaml.mockReturnValueOnce(null); + const result = await loadCustomConfig(); + expect(result).toBeNull(); + }); + + it('should not cache the config if cache is set to false', async () => { + const mockConfig = { + version: '1.0', + cache: false, + endpoints: { + custom: [ + { + name: 'mistral', + apiKey: 'user_provided', + baseURL: 'https://api.mistral.ai/v1', + }, + ], + }, + }; + process.env.CONFIG_PATH = 'validConfig.yaml'; + loadYaml.mockReturnValueOnce(mockConfig); + await loadCustomConfig(); + expect(mockSet).not.toHaveBeenCalled(); + }); + + it('should log the loaded custom config', async () => { + const mockConfig = { + version: '1.0', + cache: true, + endpoints: { + custom: [ + { + name: 'mistral', + apiKey: 'user_provided', + baseURL: 'https://api.mistral.ai/v1', + }, + ], + }, + }; + process.env.CONFIG_PATH = 'validConfig.yaml'; + loadYaml.mockReturnValueOnce(mockConfig); + await loadCustomConfig(); + expect(logger.info).toHaveBeenCalledWith('Custom config file loaded:'); + expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2)); + expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig); + }); +}); diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 6ed2fb3615..526c9cfaca 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -147,7 +147,11 @@ const processDeleteRequest = async ({ req, files }) => { const processFileURL = async ({ fileStrategy, userId, URL, fileName, basePath, context }) => { const { saveURL, getFileURL } = getStrategyFunctions(fileStrategy); try { - const { bytes, type, dimensions } = await saveURL({ userId, URL, fileName, basePath }); + const { + bytes = 0, + type = '', + dimensions = {}, + } = (await saveURL({ userId, URL, fileName, basePath })) || {}; const filepath = await getFileURL({ fileName: `${userId}/${fileName}`, basePath }); return await createFile( { diff --git a/docs/install/configuration/dotenv.md b/docs/install/configuration/dotenv.md index a377db4060..c89b2166f5 100644 --- a/docs/install/configuration/dotenv.md +++ b/docs/install/configuration/dotenv.md @@ -106,13 +106,15 @@ UID=1000 GID=1000 ``` -### librechat.yaml path -Set an alternative path for the LibreChat config file +### Configuration Path - `librechat.yaml` +Specify an alternative location for the LibreChat configuration file. +You may specify an **absolute path**, a **relative path**, or a **URL**. The filename in the path is flexible and does not have to be `librechat.yaml`; any valid configuration file will work. -> Note: leave commented out to have LibreChat look for the config file in the root folder (default behavior) +> **Note**: If you prefer LibreChat to search for the configuration file in the root directory (which is the default behavior), simply leave this option commented out. ```sh -CONFIG_PATH="/alternative/path/to/librechat.yaml" +# To set an alternative configuration path or URL, uncomment the line below and replace it with your desired path or URL. +# CONFIG_PATH="/your/alternative/path/to/config.yaml" ``` ## Endpoints